diff --git a/CHANGELOG.md b/CHANGELOG.md index df36510..fd861d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.2] — 2026-09-16 + +A second deep test of the published crates, this time reaching the autograd +surface the first round never got to. No public API changed. + +### Fixed + +- **A deep tape aborted the process.** The tape is a linked structure and its + derived drop recursed once per node, overflowing the stack at roughly 20k + operations in debug and 65k in release — an abort, which no caller can + catch. `AutogradMeta` now dismantles the graph with an explicit worklist, + so depth costs heap instead of stack; a 200,000-node tape builds and drops + cleanly. Unrolled RNNs and long chains built before `backward` are the + ordinary way to hit this (#96). +- **`matmul` disagreed with itself across its row paths.** The single-row + path skipped the multiply when the left operand was `0.0`, so `0 × ∞` + never happened there while the four-row block produced `NaN` — one call + returned `[NaN × 8, 0.0, 0.0]` for uniform input. Both paths now do the + same arithmetic (#98). +- **`max`/`min` reductions silently discarded NaN.** `f32::max` returns the + non-NaN operand, so a NaN that `sum` propagates and `argmax` refuses + vanished through `max` — the op on the common path through pooling and + attention. NaN now propagates, matching `sum` (#97). +- **Degenerate inputs panicked, aborted, or were silently accepted** where a + typed error is promised: `try_zeros`/`try_ones`/`try_full` *aborted the + process* on an allocation the allocator refused (they now use + `try_reserve_exact`, which is the entire point of a `try_` constructor); + `eye(n)` wrapped `n * n` and surfaced as an index-out-of-bounds (now a + checked, documented panic); `pad_dim` panicked on an out-of-range `dim` + and accepted it silently when the padding was zero, where its sibling + `cat` has always reported it; `kaiming_uniform`/`xavier_uniform` panicked + inside `rand` on a zero fan; and `slice` turned a reversed range into an + empty view (#99). +- **Errors named the wrong thing.** `trace` reported an error naming `diag`, + an op the caller never wrote; `index_select` reported a negative index as + `index [0]`, an index that is valid, sending the reader to the wrong + element; and `matmul`'s rank error claimed "supported ranks are 2 and 3" + although rank-4 and rank-5 both work (#100). +- **Documentation described behaviour the code does not have.** + `oxmera-tensor`'s crate doc and `register_backend` still taught load-time + registration, which 0.5.0 removed — following them produced a + `BackendUnavailable` the docs called impossible. `check_param_dtype` + promised `DTypeMismatch` and returns `InvalidArgument`. The README called + `reshape` zero-copy unconditionally. The top-level `--help` omitted + `--json` and `--seed`, which the subcommands' own help documents. And no + crate carried `[package.metadata.docs.rs]`, so docs.rs rendered + `oxmera-metal` as its non-macOS stub (#101). + +### Documented + +- **The `cholesky`/`logdet`/`det` gradient convention** (#95). The gradient + is taken with respect to *symmetric* perturbations — `d logdet/dA = A⁻¹`, + the standard result, matching PyTorch and verified against `eigh` in the + suite. Because the forward reads only the lower triangle, an elementwise + finite difference breaks symmetry and disagrees with it, so + `oxmera_autograd::gradcheck` cannot be applied to these three ops + directly; check them through a symmetrizer. This was true before and + written down nowhere, which is what made it look like a defect. The + rustdoc also claimed the backward pass runs in `f64`; it takes its inputs + as `f32`, so an `f64` gradient carries `f32` precision, and it now says so. + ## [0.5.1] — 2026-09-16 A patch release: ten defects found by deep-testing the published 0.5.0 as a diff --git a/Cargo.lock b/Cargo.lock index 9b92e89..8d6bf7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,7 +955,7 @@ dependencies = [ [[package]] name = "oxmera" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-autograd", "oxmera-core", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "oxmera-autograd" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -979,7 +979,7 @@ dependencies = [ [[package]] name = "oxmera-cli" -version = "0.5.1" +version = "0.5.2" dependencies = [ "crossterm", "oxmera", @@ -993,7 +993,7 @@ dependencies = [ [[package]] name = "oxmera-core" -version = "0.5.1" +version = "0.5.2" dependencies = [ "proptest", "thiserror 2.0.20", @@ -1001,7 +1001,7 @@ dependencies = [ [[package]] name = "oxmera-cpu" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1009,7 +1009,7 @@ dependencies = [ [[package]] name = "oxmera-cuda" -version = "0.5.1" +version = "0.5.2" dependencies = [ "cudarc", "libloading", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "oxmera-metal" -version = "0.5.1" +version = "0.5.2" dependencies = [ "metal", "oxmera-core", @@ -1032,7 +1032,7 @@ dependencies = [ [[package]] name = "oxmera-nn" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "oxmera-ops" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1050,7 +1050,7 @@ dependencies = [ [[package]] name = "oxmera-optim" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-nn", @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "oxmera-runtime" -version = "0.5.1" +version = "0.5.2" dependencies = [ "oxmera-core", "oxmera-cpu", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "oxmera-tensor" -version = "0.5.1" +version = "0.5.2" dependencies = [ "metal", "oxmera-core", diff --git a/Cargo.toml b/Cargo.toml index 48dd1f6..5522566 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ exclude = ["research"] [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2024" rust-version = "1.88" readme = "README.md" @@ -36,17 +36,17 @@ toml = "0.9" # Internal crates. The version field is required so path dependencies # survive `cargo publish`. -oxmera = { path = "crates/oxmera", version = "0.5.1" } -oxmera-core = { path = "crates/oxmera-core", version = "0.5.1" } -oxmera-tensor = { path = "crates/oxmera-tensor", version = "0.5.1" } -oxmera-ops = { path = "crates/oxmera-ops", version = "0.5.1" } +oxmera = { path = "crates/oxmera", version = "0.5.2" } +oxmera-core = { path = "crates/oxmera-core", version = "0.5.2" } +oxmera-tensor = { path = "crates/oxmera-tensor", version = "0.5.2" } +oxmera-ops = { path = "crates/oxmera-ops", version = "0.5.2" } # default-features off at the workspace level so the facade can forward its # own `cuda` feature through rather than inheriting an unconditional one. # Every consumer opts in explicitly; there is exactly one (the facade). -oxmera-runtime = { path = "crates/oxmera-runtime", version = "0.5.1", default-features = false } -oxmera-cpu = { path = "crates/oxmera-cpu", version = "0.5.1" } -oxmera-metal = { path = "crates/oxmera-metal", version = "0.5.1" } -oxmera-cuda = { path = "crates/oxmera-cuda", version = "0.5.1" } -oxmera-autograd = { path = "crates/oxmera-autograd", version = "0.5.1" } -oxmera-nn = { path = "crates/oxmera-nn", version = "0.5.1" } -oxmera-optim = { path = "crates/oxmera-optim", version = "0.5.1" } +oxmera-runtime = { path = "crates/oxmera-runtime", version = "0.5.2", default-features = false } +oxmera-cpu = { path = "crates/oxmera-cpu", version = "0.5.2" } +oxmera-metal = { path = "crates/oxmera-metal", version = "0.5.2" } +oxmera-cuda = { path = "crates/oxmera-cuda", version = "0.5.2" } +oxmera-autograd = { path = "crates/oxmera-autograd", version = "0.5.2" } +oxmera-nn = { path = "crates/oxmera-nn", version = "0.5.2" } +oxmera-optim = { path = "crates/oxmera-optim", version = "0.5.2" } diff --git a/README.md b/README.md index c714e8b..8c23d9a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ let logits = model.forward(&x)?; | area | what you get | |---|---| -| tensors | `f32` (and CPU `f64`) strided views (`reshape`/`permute`/`narrow`/`broadcast_to` are zero-copy), NumPy broadcasting, batched matmul, operator overloading (`&a + &b`, `a * 2.0`) | +| tensors | `f32` (and CPU `f64`) strided views (`permute`/`narrow`/`broadcast_to` are zero-copy; `reshape` is too when the input is contiguous, and copies otherwise), NumPy broadcasting, batched matmul, operator overloading (`&a + &b`, `a * 2.0`) | | devices | CPU (rayon-parallel, cache-tiled GEMM), Apple Metal (MSL compute kernels, threadgroup reductions, tiled GEMM over unified memory) and NVIDIA CUDA (the same kernels in CUDA C, shipped as PTX and driven through the driver API — no CUDA toolkit needed to build, `libcuda` found at runtime); `tensor.to_device(...)` moves data, autograd flows across the move | | autograd | tape-based reverse mode: `requires_grad`, `backward()`, gradient accumulation, `no_grad` RAII guard — every VJP validated by finite differences in CI | | nn | `Linear`, `Conv2d`, `Embedding`, `LayerNorm`, `BatchNorm2d`, `Dropout`, `Sequential`; `MSELoss`, `CrossEntropyLoss`, `BCEWithLogitsLoss`; Kaiming/Xavier initializers | diff --git a/crates/oxmera-autograd/Cargo.toml b/crates/oxmera-autograd/Cargo.toml index a7da960..d96c7d5 100644 --- a/crates/oxmera-autograd/Cargo.toml +++ b/crates/oxmera-autograd/Cargo.toml @@ -14,3 +14,7 @@ categories.workspace = true [dependencies] oxmera-core.workspace = true oxmera-tensor.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-cli/Cargo.toml b/crates/oxmera-cli/Cargo.toml index 396e665..22deb6c 100644 --- a/crates/oxmera-cli/Cargo.toml +++ b/crates/oxmera-cli/Cargo.toml @@ -40,3 +40,7 @@ signal-hook = "0.3" # It costs no new crate here: serde_json is already in the lockfile. termlens = { version = "0.11", features = ["serde"] } serde_json = "1" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-cli/src/main.rs b/crates/oxmera-cli/src/main.rs index c8e46e9..bc76fca 100644 --- a/crates/oxmera-cli/src/main.rs +++ b/crates/oxmera-cli/src/main.rs @@ -15,7 +15,7 @@ mod tui; use std::process::ExitCode; -const USAGE: &str = "usage: oxmera ] | train [--device cpu|metal|cuda] [--epochs N] [--tui] [--replay ] | --version | --help>"; +const USAGE: &str = "usage: oxmera ] [--json] | train [--device cpu|metal|cuda] [--epochs N] [--seed N] [--tui] [--replay ] | --version | --help>"; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); diff --git a/crates/oxmera-core/Cargo.toml b/crates/oxmera-core/Cargo.toml index d125796..de28c55 100644 --- a/crates/oxmera-core/Cargo.toml +++ b/crates/oxmera-core/Cargo.toml @@ -16,3 +16,7 @@ thiserror.workspace = true [dev-dependencies] proptest = "1" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-cpu/Cargo.toml b/crates/oxmera-cpu/Cargo.toml index 14f45d3..ab54152 100644 --- a/crates/oxmera-cpu/Cargo.toml +++ b/crates/oxmera-cpu/Cargo.toml @@ -14,3 +14,7 @@ categories.workspace = true [dependencies] oxmera-core.workspace = true oxmera-tensor.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-cuda/Cargo.toml b/crates/oxmera-cuda/Cargo.toml index 52f4a0d..8dae07b 100644 --- a/crates/oxmera-cuda/Cargo.toml +++ b/crates/oxmera-cuda/Cargo.toml @@ -28,3 +28,7 @@ hardware = [] [dev-dependencies] oxmera-nn.workspace = true oxmera-optim.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-metal/Cargo.toml b/crates/oxmera-metal/Cargo.toml index 78894ad..7c7fc91 100644 --- a/crates/oxmera-metal/Cargo.toml +++ b/crates/oxmera-metal/Cargo.toml @@ -21,3 +21,10 @@ metal = "0.33.0" [dev-dependencies] oxmera-nn.workspace = true oxmera-optim.workspace = true + +[package.metadata.docs.rs] +# Built on Linux by default, where this crate is an empty stub — so docs.rs +# showed a one-function page and `oxmera::metal` led nowhere. +default-target = "aarch64-apple-darwin" +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-nn/Cargo.toml b/crates/oxmera-nn/Cargo.toml index 4ac7b93..b3c0506 100644 --- a/crates/oxmera-nn/Cargo.toml +++ b/crates/oxmera-nn/Cargo.toml @@ -16,3 +16,7 @@ oxmera-core.workspace = true oxmera-tensor.workspace = true rand = "0.9" safetensors = "0.6" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-nn/src/functional.rs b/crates/oxmera-nn/src/functional.rs index 096c9dd..361b9aa 100644 --- a/crates/oxmera-nn/src/functional.rs +++ b/crates/oxmera-nn/src/functional.rs @@ -36,6 +36,15 @@ pub fn cat(parts: &[Tensor], dim: usize) -> Result { /// Zero-pad `dim` by `before`/`after` elements. Differentiable. pub fn pad_dim(t: &Tensor, dim: usize, before: usize, after: usize) -> Result { + // Checked before the no-op shortcut: an out-of-range dim used to panic + // with a raw index-out-of-bounds below, and to be accepted silently when + // before == after == 0. Its sibling `cat` has always reported this. + if dim >= t.ndim() { + return Err(Error::InvalidArgument { + op: "pad_dim", + detail: format!("dim {dim} out of range for rank {}", t.ndim()), + }); + } if before == 0 && after == 0 { return Ok(t.clone()); } diff --git a/crates/oxmera-nn/src/init.rs b/crates/oxmera-nn/src/init.rs index bd7125e..38c6495 100644 --- a/crates/oxmera-nn/src/init.rs +++ b/crates/oxmera-nn/src/init.rs @@ -5,6 +5,10 @@ use oxmera_tensor::tensor::Tensor; use rand::{Rng, SeedableRng}; fn uniform(shape: Shape, bound: f32, seed: u64) -> Tensor { + // A degenerate fan (0) makes the bound infinite and `rand` refuses a + // non-finite range by panicking. A layer with no fan has nothing to + // spread, so collapse to a zero-width distribution. + let bound = if bound.is_finite() { bound } else { 0.0 }; let mut rng = rand::rngs::StdRng::seed_from_u64(seed); let numel = shape.numel(); let data: Vec = (0..numel) diff --git a/crates/oxmera-nn/src/lib.rs b/crates/oxmera-nn/src/lib.rs index 2289b03..c0f3352 100644 --- a/crates/oxmera-nn/src/lib.rs +++ b/crates/oxmera-nn/src/lib.rs @@ -48,7 +48,9 @@ use oxmera_tensor::tensor::Tensor; /// /// # Errors /// -/// [`Error::DTypeMismatch`] naming the layer and the cast to make. +/// [`Error::InvalidArgument`] naming the layer and the cast to make. (Not +/// `DTypeMismatch`: that variant carries only the two dtypes, and the point +/// here is the sentence telling the caller which cast to write.) pub fn check_param_dtype(layer: &'static str, input: &Tensor, params: &[Param]) -> Result<()> { let Some(first) = params.first() else { return Ok(()); // a module with no parameters imposes nothing diff --git a/crates/oxmera-nn/tests/layers.rs b/crates/oxmera-nn/tests/layers.rs index d3e0957..465dd06 100644 --- a/crates/oxmera-nn/tests/layers.rs +++ b/crates/oxmera-nn/tests/layers.rs @@ -404,3 +404,22 @@ fn bce_with_logits_gradient_is_exact_at_a_zero_logit() { "(sigmoid(0) - t) / n", ); } + +#[test] +fn pad_dim_reports_an_out_of_range_dim_like_cat_does() { + let t = Tensor::zeros([2usize]); + let e = oxmera_nn::functional::pad_dim(&t, 9, 1, 1) + .unwrap_err() + .to_string(); + assert!(e.contains("pad_dim"), "{e}"); + // Also rejected on the no-op path, which used to accept it silently. + assert!(oxmera_nn::functional::pad_dim(&t, 9, 0, 0).is_err()); + assert!(oxmera_nn::functional::pad_dim(&t, 0, 1, 1).is_ok()); +} + +#[test] +fn a_degenerate_fan_does_not_panic_the_initializer() { + // 6.0 / 0 is infinite and `rand` refuses a non-finite range. + let w = oxmera_nn::init::kaiming_uniform([2usize, 2], 0, 1); + assert!(w.to_vec_f32().unwrap().iter().all(|v| v.is_finite())); +} diff --git a/crates/oxmera-ops/Cargo.toml b/crates/oxmera-ops/Cargo.toml index 6e589bd..947b331 100644 --- a/crates/oxmera-ops/Cargo.toml +++ b/crates/oxmera-ops/Cargo.toml @@ -14,3 +14,7 @@ categories.workspace = true [dependencies] oxmera-core.workspace = true oxmera-tensor.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-optim/Cargo.toml b/crates/oxmera-optim/Cargo.toml index a497b8f..2b98d73 100644 --- a/crates/oxmera-optim/Cargo.toml +++ b/crates/oxmera-optim/Cargo.toml @@ -15,3 +15,7 @@ categories.workspace = true oxmera-core.workspace = true oxmera-tensor.workspace = true oxmera-nn.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-runtime/Cargo.toml b/crates/oxmera-runtime/Cargo.toml index f0ae33c..3f8d822 100644 --- a/crates/oxmera-runtime/Cargo.toml +++ b/crates/oxmera-runtime/Cargo.toml @@ -35,3 +35,7 @@ oxmera-cuda = { workspace = true, optional = true } # comparison — including on macOS, where NVIDIA hardware cannot exist. [target.'cfg(target_os = "macos")'.dependencies] oxmera-metal.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-tensor/Cargo.toml b/crates/oxmera-tensor/Cargo.toml index b127fd3..0fd42c6 100644 --- a/crates/oxmera-tensor/Cargo.toml +++ b/crates/oxmera-tensor/Cargo.toml @@ -22,3 +22,7 @@ metal = "0.33.0" [dev-dependencies] proptest = "1" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/oxmera-tensor/src/autograd.rs b/crates/oxmera-tensor/src/autograd.rs index 393d66d..eb71b21 100644 --- a/crates/oxmera-tensor/src/autograd.rs +++ b/crates/oxmera-tensor/src/autograd.rs @@ -42,6 +42,41 @@ pub struct AutogradMeta { pub(crate) grad_fn: Option, } +impl Drop for AutogradMeta { + /// Take the tape apart iteratively. + /// + /// A tape is a linked structure — each node owns the input tensors it + /// consumed, and each of those owns its own node — so the derived drop + /// recurses once per operation and overflows the stack on a deep graph + /// (an unrolled RNN, or any long chain built before `backward`). At + /// roughly 65k nodes that aborted the process, which no caller can + /// catch. This walks the graph with an explicit worklist instead, so + /// depth costs heap rather than stack. + fn drop(&mut self) { + let mut stack: Vec = Vec::new(); + let shed = |meta: &mut AutogradMeta, stack: &mut Vec| { + if let Some(gf) = meta.grad_fn.take() { + stack.extend(gf.inputs); + } + if let Ok(slot) = meta.grad.get_mut() + && let Some(g) = slot.take() + { + stack.push(g); + } + }; + shed(self, &mut stack); + while let Some(mut tensor) = stack.pop() { + // Only descend where this was the last handle: a node still + // shared with a live tensor must stay intact. + if let Some(node) = tensor.take_autograd() + && let Some(mut owned) = Arc::into_inner(node) + { + shed(&mut owned, &mut stack); + } + } + } +} + thread_local! { static RECORDING: Cell = const { Cell::new(true) }; } diff --git a/crates/oxmera-tensor/src/backend.rs b/crates/oxmera-tensor/src/backend.rs index 194dfcf..6ae1b2e 100644 --- a/crates/oxmera-tensor/src/backend.rs +++ b/crates/oxmera-tensor/src/backend.rs @@ -203,6 +203,11 @@ impl ReduceOp { pub fn combine(self, acc: f32, x: f32) -> f32 { match self { ReduceOp::Sum => acc + x, + // `f32::max`/`min` return the non-NaN operand, which silently + // dropped a NaN that `sum` propagates and `argmax` refuses. + // A NaN reaching a reduction is a fault the caller needs to see. + ReduceOp::Max if acc.is_nan() || x.is_nan() => f32::NAN, + ReduceOp::Min if acc.is_nan() || x.is_nan() => f32::NAN, ReduceOp::Max => acc.max(x), ReduceOp::Min => acc.min(x), } @@ -249,7 +254,7 @@ pub fn plan_matmul(a: &Shape, b: &Shape) -> Result { return Err(Error::InvalidArgument { op: "matmul", detail: format!( - "supported ranks are 2 and 3 (batched); got {}x{}", + "needs rank >= 2 on both operands (batched above that); got {}x{}", ad.len(), bd.len() ), @@ -263,7 +268,7 @@ pub fn plan_matmul(a: &Shape, b: &Shape) -> Result { return Err(Error::InvalidArgument { op: "matmul", detail: format!( - "supported ranks are 2 and 3 (batched); got {}x{}", + "needs rank >= 2 on both operands (batched above that); got {}x{}", ad.len(), bd.len() ), @@ -488,8 +493,10 @@ fn registry() -> &'static Registry { /// Register a backend for its device, replacing any previous registration. /// -/// Backend crates call this from their load-time constructors; linking a -/// backend crate is what makes its device usable. +/// Backend crates expose a `register_default()` that calls this; something +/// must invoke it — `oxmera_runtime::init()` does for every backend on the +/// platform. Linking a backend crate does not register it (the pre-`main` +/// constructor that used to do so was removed in 0.5.0). pub fn register_backend(backend: Arc) { registry() .write() diff --git a/crates/oxmera-tensor/src/cpu.rs b/crates/oxmera-tensor/src/cpu.rs index da3b010..b579e16 100644 --- a/crates/oxmera-tensor/src/cpu.rs +++ b/crates/oxmera-tensor/src/cpu.rs @@ -357,10 +357,20 @@ impl Backend for CpuBackend { detail: format!("dim {dim} out of range for rank {}", dims.len()), }); } - for &i in &idx { - if i < 0 || i as usize >= dims[dim] { + for (pos, &i) in idx.iter().enumerate() { + // A negative index clamped to 0 reported "index [0] out of + // bounds" — an index that is perfectly valid — and sent the + // caller to the wrong element. IndexOutOfBounds carries + // Vec and cannot hold the value that actually failed. + if i < 0 { + return Err(Error::InvalidArgument { + op: "index_select", + detail: format!("index {i} at position {pos} is negative"), + }); + } + if i as usize >= dims[dim] { return Err(Error::IndexOutOfBounds { - index: vec![i.max(0) as usize], + index: vec![i as usize], shape: a.shape().clone(), }); } diff --git a/crates/oxmera-tensor/src/cpu_f64.rs b/crates/oxmera-tensor/src/cpu_f64.rs index fe8258d..4ded0f8 100644 --- a/crates/oxmera-tensor/src/cpu_f64.rs +++ b/crates/oxmera-tensor/src/cpu_f64.rs @@ -78,6 +78,9 @@ impl ReduceOp { pub fn combine_f64(self, acc: f64, x: f64) -> f64 { match self { ReduceOp::Sum => acc + x, + // NaN propagates, matching sum; see ReduceOp::combine. + ReduceOp::Max if acc.is_nan() || x.is_nan() => f64::NAN, + ReduceOp::Min if acc.is_nan() || x.is_nan() => f64::NAN, ReduceOp::Max => acc.max(x), ReduceOp::Min => acc.min(x), } @@ -285,10 +288,17 @@ pub(crate) fn index_select(a: &Tensor, dim: usize, indices: &Tensor) -> Result= dims[dim] { + for (pos, &i) in idx.iter().enumerate() { + // See the f32 path: a negative index is reported as negative. + if i < 0 { + return Err(Error::InvalidArgument { + op: "index_select", + detail: format!("index {i} at position {pos} is negative"), + }); + } + if i as usize >= dims[dim] { return Err(Error::IndexOutOfBounds { - index: vec![i.max(0) as usize], + index: vec![i as usize], shape: a.shape().clone(), }); } diff --git a/crates/oxmera-tensor/src/cpu_matmul.rs b/crates/oxmera-tensor/src/cpu_matmul.rs index afbdccb..4672455 100644 --- a/crates/oxmera-tensor/src/cpu_matmul.rs +++ b/crates/oxmera-tensor/src/cpu_matmul.rs @@ -127,10 +127,11 @@ fn gemm_row(arow: &[f32], b: &[f32], crow: &mut [f32], k: usize, n: usize) { for kb in (0..k).step_by(K_BLOCK) { let kend = (kb + K_BLOCK).min(k); for kk in kb..kend { + // No `av == 0.0` skip: the 4-row block below multiplies + // unconditionally, so skipping here made one call return NaN for + // the blocked rows and 0.0 for the remainder rows of the same + // data (0 * inf is NaN under IEEE-754). Both paths now agree. let av = arow[kk]; - if av == 0.0 { - continue; - } let brow = &b[kk * n..kk * n + n]; for (cv, &bv) in crow.iter_mut().zip(brow) { *cv += av * bv; diff --git a/crates/oxmera-tensor/src/linalg.rs b/crates/oxmera-tensor/src/linalg.rs index dc54c0b..04a0f32 100644 --- a/crates/oxmera-tensor/src/linalg.rs +++ b/crates/oxmera-tensor/src/linalg.rs @@ -50,8 +50,14 @@ fn dispatch_cpu_fallback( impl Tensor { /// The `n × n` identity matrix on the CPU. + /// # Panics + /// Panics if `n * n` overflows `usize`. Unchecked, this wrapped to a + /// short allocation and surfaced as an index-out-of-bounds below. pub fn eye(n: usize) -> Tensor { - let mut v = vec![0.0f32; n * n]; + let cells = n + .checked_mul(n) + .expect("eye: n * n overflows usize — the identity is too large to build"); + let mut v = vec![0.0f32; cells]; for i in 0..n { v[i * n + i] = 1.0; } @@ -95,7 +101,15 @@ impl Tensor { /// The trace of every matrix in a `[.., n, n]` tensor, as `[..]`. /// Differentiable. pub fn trace(&self) -> Result { - let diag = self.diag()?; + // Report `trace`: the caller never wrote `diag`, and an error naming + // it sends them looking for a call that does not exist. + let diag = self.diag().map_err(|e| match e { + Error::InvalidArgument { detail, .. } => Error::InvalidArgument { + op: "trace", + detail, + }, + other => other, + })?; diag.sum(&[diag.ndim() - 1]) } @@ -104,8 +118,24 @@ impl Tensor { /// /// Reads the lower triangle. A matrix that is not positive definite /// is a typed [`Error::InvalidArgument`] naming the batch index and - /// pivot. Differentiable (Murray 2016); the backward pass runs on the - /// host in `f64` and returns to the input's device. + /// pivot. The backward pass runs on the host and returns to the input's + /// device; its intermediates are `f64`, but it takes its inputs as + /// `f32`, so an `f64` gradient carries `f32` precision. + /// + /// # Gradient convention + /// + /// The gradient (Murray 2016) is taken with respect to **symmetric + /// perturbations** of the input: `d logdet/dA = A⁻¹`, which is the + /// standard result and what PyTorch returns. Because the forward reads + /// only the lower triangle, an *elementwise* finite difference — which + /// perturbs one entry and so breaks symmetry — does not agree with it, + /// and `oxmera_autograd::gradcheck` cannot be used on `cholesky`, + /// `logdet` or `det` directly. Check them through a symmetrizer + /// (`(X + Xᵀ)/2`), as `tests/gradcheck.rs` does, or against `A⁻¹`. + /// + /// The practical consequence: feed these ops a symmetric matrix. Given + /// an asymmetric one the forward silently uses the lower triangle while + /// the gradient describes a symmetric matrix, and the two disagree. pub fn cholesky(&self) -> Result { square_matrix_dims(self, "cholesky")?; let out = dispatch_cpu_fallback(self, |be, t| be.cholesky(t), |be, l| be.upload(&l))?; diff --git a/crates/oxmera-tensor/src/tensor.rs b/crates/oxmera-tensor/src/tensor.rs index 38d9d2e..805e69e 100644 --- a/crates/oxmera-tensor/src/tensor.rs +++ b/crates/oxmera-tensor/src/tensor.rs @@ -149,7 +149,7 @@ impl Tensor { pub fn try_zeros(shape: impl Into) -> Result { let shape = shape.into(); let numel = checked_numel(&shape, "Tensor::try_zeros")?; - Self::from_vec_f32(vec![0.0; numel], shape) + Self::from_vec_f32(try_filled(numel, 0.0, "Tensor::try_zeros")?, shape) } /// A CPU tensor of ones. @@ -166,7 +166,7 @@ impl Tensor { pub fn try_ones(shape: impl Into) -> Result { let shape = shape.into(); let numel = checked_numel(&shape, "Tensor::try_ones")?; - Self::from_vec_f32(vec![1.0; numel], shape) + Self::from_vec_f32(try_filled(numel, 1.0, "Tensor::try_ones")?, shape) } /// A CPU tensor filled with `value`. @@ -183,7 +183,7 @@ impl Tensor { pub fn try_full(shape: impl Into, value: f32) -> Result { let shape = shape.into(); let numel = checked_numel(&shape, "Tensor::try_full")?; - Self::from_vec_f32(vec![value; numel], shape) + Self::from_vec_f32(try_filled(numel, value, "Tensor::try_full")?, shape) } /// A rank-0 scalar tensor. @@ -415,8 +415,15 @@ impl Tensor { /// A view of `range` along `dim` — sugar over [`Tensor::narrow`]. pub fn slice(&self, dim: usize, range: std::ops::Range) -> Result { - let len = range.end.saturating_sub(range.start); - self.narrow(dim, range.start, len) + if range.end < range.start { + // Saturating to an empty view turned a caller mistake into a + // silently empty tensor that fails much later. + return Err(Error::InvalidArgument { + op: "slice", + detail: format!("range {}..{} is reversed", range.start, range.end), + }); + } + self.narrow(dim, range.start, range.end - range.start) } /// A zero-copy broadcast view to `shape` (stride 0 on expanded axes). @@ -619,6 +626,13 @@ impl Tensor { } /// Attach a tape node to this tensor (used by the op layer). + /// Detach this tensor's tape node and hand it back, leaving the tensor a + /// leaf. `AutogradMeta::drop` uses this to dismantle a deep tape + /// iteratively rather than recursing once per node. + pub(crate) fn take_autograd(&mut self) -> Option> { + self.autograd.take() + } + pub(crate) fn with_grad_fn(mut self, grad_fn: GradFn) -> Self { self.autograd = Some(Arc::new(AutogradMeta { requires_grad: false, @@ -786,6 +800,21 @@ pub(crate) fn gather_logical(src: &[T], layout: &Layout) -> Vec { /// The element count of a caller-supplied shape, or a typed error when it /// does not fit in `usize` (see [`Shape::checked_numel`]). +/// `numel` copies of `value`, reporting an allocation failure as a typed +/// error. `vec![value; numel]` *aborts the process* when the allocator +/// refuses, which a `try_` constructor must never do — that is the whole +/// reason the caller reached for the fallible form. +fn try_filled(numel: usize, value: f32, op: &'static str) -> Result> { + let mut data: Vec = Vec::new(); + data.try_reserve_exact(numel) + .map_err(|_| Error::InvalidArgument { + op, + detail: format!("cannot allocate {numel} f32 elements"), + })?; + data.resize(numel, value); + Ok(data) +} + fn checked_numel(shape: &Shape, op: &'static str) -> Result { shape.checked_numel().ok_or_else(|| Error::InvalidArgument { op, diff --git a/crates/oxmera-tensor/tests/hardening.rs b/crates/oxmera-tensor/tests/hardening.rs index c8a2df9..6d837de 100644 --- a/crates/oxmera-tensor/tests/hardening.rs +++ b/crates/oxmera-tensor/tests/hardening.rs @@ -176,3 +176,99 @@ fn maximum_and_minimum_split_a_tie_evenly() { r.relu().unwrap().sum(&[]).unwrap().backward().unwrap(); assert_eq!(r.grad().unwrap().to_vec_f32().unwrap(), vec![0.0, 1.0, 0.0]); } + +// ---- 0.5.2: defects found by deep-testing the published 0.5.1 ---- + +#[test] +fn a_deep_tape_drops_without_aborting() { + // The derived drop recursed once per node and aborted the process at + // ~20k nodes in debug. An abort cannot be caught, so if this regresses + // it takes the whole test binary with it — which is the point. + let mut t = Tensor::from_slice(&[1.0], [1]) + .unwrap() + .requires_grad_(true); + for _ in 0..50_000 { + t = t.mul_scalar(1.000_000_1).unwrap(); + } + drop(t); +} + +#[test] +fn matmul_is_consistent_across_its_row_paths() { + // 5 rows = one 4-row block plus a 1-row remainder, which took different + // code paths: the remainder skipped the multiply when a was 0.0, so one + // call returned NaN for some rows and 0.0 for others of the same data. + let a = Tensor::from_vec_f32(vec![0.0; 5 * 4], [5, 4]).unwrap(); + let b = Tensor::from_vec_f32(vec![f32::INFINITY; 4 * 2], [4, 2]).unwrap(); + let out = a.matmul(&b).unwrap().to_vec_f32().unwrap(); + assert!( + out.iter().all(|v| v.is_nan()), + "0 * inf must agree on every row: {out:?}" + ); +} + +#[test] +fn max_and_min_propagate_nan_like_sum() { + let t = Tensor::from_slice(&[1.0, f32::NAN, 2.0], [3]).unwrap(); + assert!( + t.max(&[0]).unwrap().to_vec_f32().unwrap()[0].is_nan(), + "max must not drop NaN" + ); + assert!( + t.min(&[0]).unwrap().to_vec_f32().unwrap()[0].is_nan(), + "min must not drop NaN" + ); + assert!(t.sum(&[0]).unwrap().to_vec_f32().unwrap()[0].is_nan()); + // A clean input is unaffected. + let c = Tensor::from_slice(&[1.0, 3.0, 2.0], [3]).unwrap(); + assert_eq!(c.max(&[0]).unwrap().to_vec_f32().unwrap(), vec![3.0]); + assert_eq!(c.min(&[0]).unwrap().to_vec_f32().unwrap(), vec![1.0]); +} + +#[test] +fn degenerate_inputs_are_typed_errors_not_panics() { + // `try_` must never abort: vec![v; n] aborts when the allocator refuses. + assert!(Tensor::try_zeros([1usize << 45]).is_err()); + assert!(Tensor::try_ones([1usize << 45]).is_err()); + assert!(Tensor::try_full([1usize << 45], 1.0).is_err()); + // A reversed range is a caller mistake, not an empty selection. + // (Built from bindings so clippy does not fold the literal range away.) + let (start, end) = (4usize, 2usize); + let e = Tensor::zeros([5usize]).slice(0, start..end).unwrap_err(); + assert!(e.to_string().contains("reversed"), "{e}"); + assert_eq!(Tensor::zeros([5usize]).slice(0, 1..3).unwrap().dims(), &[2]); +} + +#[test] +#[should_panic(expected = "overflows usize")] +fn eye_refuses_a_size_whose_square_overflows() { + let _ = Tensor::eye(1usize << 33); +} + +#[test] +fn errors_name_the_op_the_caller_called() { + // trace() used to report an error naming `diag`, which the caller never wrote. + let e = Tensor::zeros([2usize, 3]).trace().unwrap_err().to_string(); + assert!(e.contains("trace"), "{e}"); + assert!(!e.contains("diag"), "{e}"); + // A negative index is reported as negative, not as index 0. + let idx = Tensor::from_vec_i64(vec![-1], [1]).unwrap(); + let e = Tensor::zeros([3usize]) + .index_select(0, &idx) + .unwrap_err() + .to_string(); + assert!(e.contains("-1"), "{e}"); + // The rank message must not name a limit matmul does not have. + let e = Tensor::zeros([2usize]) + .matmul(&Tensor::zeros([2usize])) + .unwrap_err() + .to_string(); + assert!(!e.contains("2 and 3"), "rank-4/5 matmul works: {e}"); + assert_eq!( + Tensor::zeros([2usize, 2, 2, 2, 2]) + .matmul(&Tensor::zeros([2usize, 2, 2, 2, 2])) + .unwrap() + .dims(), + &[2, 2, 2, 2, 2] + ); +} diff --git a/crates/oxmera/Cargo.toml b/crates/oxmera/Cargo.toml index afdae0f..97160b4 100644 --- a/crates/oxmera/Cargo.toml +++ b/crates/oxmera/Cargo.toml @@ -29,3 +29,7 @@ oxmera-cuda = { workspace = true, optional = true } [target.'cfg(target_os = "macos")'.dependencies] oxmera-metal.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"]