Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 12 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions crates/oxmera-autograd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion crates/oxmera-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ mod tui;

use std::process::ExitCode;

const USAGE: &str = "usage: oxmera <doctor [--fixture <path>] | train [--device cpu|metal|cuda] [--epochs N] [--tui] [--replay <path>] | --version | --help>";
const USAGE: &str = "usage: oxmera <doctor [--fixture <path>] [--json] | train [--device cpu|metal|cuda] [--epochs N] [--seed N] [--tui] [--replay <path>] | --version | --help>";

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
Expand Down
4 changes: 4 additions & 0 deletions crates/oxmera-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ thiserror.workspace = true

[dev-dependencies]
proptest = "1"

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
4 changes: 4 additions & 0 deletions crates/oxmera-cpu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-cuda/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 7 additions & 0 deletions crates/oxmera-metal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-nn/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
9 changes: 9 additions & 0 deletions crates/oxmera-nn/src/functional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ pub fn cat(parts: &[Tensor], dim: usize) -> Result<Tensor> {

/// Zero-pad `dim` by `before`/`after` elements. Differentiable.
pub fn pad_dim(t: &Tensor, dim: usize, before: usize, after: usize) -> Result<Tensor> {
// 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());
}
Expand Down
4 changes: 4 additions & 0 deletions crates/oxmera-nn/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f32> = (0..numel)
Expand Down
4 changes: 3 additions & 1 deletion crates/oxmera-nn/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions crates/oxmera-nn/tests/layers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
4 changes: 4 additions & 0 deletions crates/oxmera-ops/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-optim/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions crates/oxmera-tensor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ metal = "0.33.0"

[dev-dependencies]
proptest = "1"

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
Loading