diff --git a/.github/workflows/advisories.yml b/.github/workflows/advisories.yml new file mode 100644 index 0000000..37385df --- /dev/null +++ b/.github/workflows/advisories.yml @@ -0,0 +1,26 @@ +# A fresh security advisory filed against a dependency we already ship is +# not caught by push/PR CI, which runs only when the tree changes. This +# runs the advisory database against the committed lockfile on a schedule, +# so a new CVE surfaces as a failed run within the week rather than at the +# next unrelated push. Bans/licenses/sources stay on the push/PR firewall +# in ci.yml; this leg is advisories only. +name: advisories + +on: + schedule: + - cron: "23 6 * * 2" # weekly, Tuesday 06:23 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + advisories: + name: security advisories (cargo-deny) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 + with: + command: check advisories diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf43ff..ad95e58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,25 +81,17 @@ jobs: run: | set -euo pipefail tree=$(cargo tree -p oxmera --no-default-features -e normal --prefix none) - # Only the crates that are CUDA-only on EVERY platform. `ctor` is - # not one of them: oxmera-metal registers at load time the same - # way, so on macOS it stays in the graph and should — the first - # version of this step listed it and failed the macOS leg while - # the code was correct. - for crate in cudarc libloading oxmera-cuda; do + # CUDA-only crates that must never appear with the default + # features off. `ctor` joins them since 0.5.0: the Metal and CUDA + # backends no longer register from a pre-main constructor, so no + # crate in the workspace pulls `ctor` on any platform. + for crate in cudarc libloading oxmera-cuda ctor; do if echo "$tree" | grep -qE "^${crate} "; then echo "::error::${crate} is still in the graph with --no-default-features" exit 1 fi done - echo "cudarc, libloading and oxmera-cuda are all absent" - - # `ctor` on macOS belongs to Metal; anywhere else it could only - # have come from CUDA, so check it where the answer is unambiguous. - if [ "$RUNNER_OS" != "macOS" ] && echo "$tree" | grep -qE "^ctor "; then - echo "::error::ctor is still in the graph, and off macOS only CUDA pulls it" - exit 1 - fi + echo "cudarc, libloading, oxmera-cuda and ctor are all absent" msrv: name: msrv (1.88, lockfile) @@ -135,3 +127,20 @@ jobs: - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 with: command: check bans licenses sources advisories + + # The published API cannot break incompatibly without the version saying + # so. cargo-semver-checks compares the workspace against the last release + # on crates.io; a 0.x minor bump is allowed to break, a patch is not. + # Installed from the published crate (not a prebuilt binary), uncached. + semver: + name: semver-checks (public API) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: stable + - name: Install cargo-semver-checks + run: cargo install cargo-semver-checks --locked + - name: The public API change matches the version bump + run: cargo semver-checks --workspace diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 2dc8a7c..07a7735 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -22,10 +22,17 @@ on: permissions: contents: read +# One gate run per ref: a new push supersedes an in-flight run rather than +# letting both burn a runner installing a rustc-driver from source. +concurrency: + group: gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: reconverge-strict: name: reconverge strict (cuda kernels) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Pinned nightly with driver components @@ -46,6 +53,7 @@ jobs: prune: name: launchbound prune (cc ${{ matrix.cc }}) runs-on: ubuntu-latest + timeout-minutes: 20 strategy: matrix: cc: ["7.5", "8.6"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7422fe4..0892a77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,6 +59,18 @@ jobs: - name: Verify the tree the tag points at run: cargo check --workspace --all-targets --locked + - name: The whole suite must pass before anything is published + run: cargo test --workspace --locked + + - name: CHANGELOG names the version being released + run: | + set -euo pipefail + version="${TAG#v}" + if ! grep -qE "^## \[${version}\]" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## [${version}]' heading for this release" + exit 1 + fi + - name: Mint a short-lived registry token from GitHub OIDC id: auth uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e73cc5..31d7ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +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). -## [Unreleased] +## [0.5.0] — 2026-09-16 + +The production-hardening milestone (#45–#76): the panics, silent wrong +answers and untyped failures a downstream consumer could still hit became +typed errors, the public API was locked down against accidental breakage, +and the release pipeline gained the checks that keep a bad build from +shipping. Breaking changes are collected below with their one-line +migrations; the new `semver-checks` job enforces that a future break +cannot land in a patch. + +### Added + +- **Fallible tensor constructors** `Tensor::try_zeros`, `try_ones` and + `try_full`, returning a typed error when a shape's element count + overflows `usize`. The infallible `zeros`/`ones`/`full` remain and now + document that they panic on such a shape (#51). +- **`Module::to_device`**, backed by `Param::to_device`, moves a model's + parameters to a device once. Layers read their parameters each forward, + so the per-forward transfer becomes a no-op afterwards and the + device-resident fused Adam step — until now unreachable, because + parameters never left the CPU — finally runs (#46). +- **`Sequential` inspection**: `iter`, `get` and `Index` over its + children, and a `Debug` that shows them (#61). +- **Optimizer and layer hyperparameters**: `Adam`/`AdamW::with_betas` and + `with_eps`, `RmsProp::with_alpha` and `with_eps`, `LayerNorm::with_eps`, + and `BatchNorm2d::with_eps`/`with_momentum`. Defaults are unchanged (#54). +- **`oxmera doctor --json`** for scripts, and `--help` on both `doctor` + and `train`, which used to be rejected as an unknown argument (#58). +- **`docs/STABILITY.md`**: the SemVer, MSRV and `#[non_exhaustive]` + contract and the road to 1.0 (#45). +- crates.io **keywords and categories** on every crate; a **`semver-checks` + CI job** against the last release; and a **weekly advisory workflow** + running the RustSec database against the committed lockfile, so a new CVE + surfaces without a push (#59, #75). The release workflow now runs the + whole test suite and verifies the CHANGELOG names the version before + publishing (#66), and the convergence gate got a concurrency group and + per-job timeouts (#67). ### Changed +- **`Module` now requires `Debug`.** Breaking. Every layer in the crate + already derived it; an out-of-tree `Module` adds `#[derive(Debug)]`. + This is what lets `Sequential` show its children (#61). +- **`Dropout::new` returns `Result`.** Breaking. It rejects a drop + probability outside `[0, 1)`, which previously scaled survivors by + `1/(1 - p) <= 0` and produced NaNs; the fix at a call site is `?` (#74). +- **`MatmulPlan`, `AdamStep` and `ParamGroup` are `#[non_exhaustive]`.** + Breaking for out-of-tree code that built them with a struct literal or + matched them exhaustively: construct through the constructors + (`AdamStep::new` is new) and add `..` to a destructure (#59). +- **Metal and CUDA register explicitly, not before `main`.** Breaking + behaviour change. The `#[ctor]` constructor and the `ctor` dependency are + gone, along with CUDA's pre-main `dlopen` of `libcuda`. Call + `oxmera::init()` (or `oxmera_cuda::register_default()`) before a GPU + device; the CLI and examples already do. Only device 0 is registered — + `Device::Cuda`/`Metal` with `index > 0` is a typed `BackendUnavailable`, + now documented (#55, #65). +- **`Tensor`'s `Debug` prints metadata** — shape, dtype, device, + requires_grad — not the entire storage buffer, which a derived `Debug` + dumped into every log line and panic message (#62). + - **`Linear`, `Conv2d` and `Embedding` no longer implement `Clone`.** Breaking. `Param` is a shared handle by design, so deriving `Clone` on a layer produced a copy that trained together with the original: setting a @@ -89,6 +146,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 into the job summary; a new `skill-version` job fails when the vendored agent skill drifts from the dependency. +### Fixed + +- **`Tensor::from_storage` accepted an out-of-bounds layout.** A negative + stride with too small an offset addressed before the storage and + panicked on the first read; the check now accounts for the minimum + addressed index as well as the maximum, and still admits a valid flip + view (#50). +- **`narrow` overflowed `start + len`** in its own bounds check on a huge + range; it now reports a typed error instead (#51). +- **`eigh` rounded an `f64` input to `f32`** before decomposing it, despite + a comment claiming it kept the precision. It now stays in `f64` end to + end (#52). +- **`CrossEntropyLoss` returned `NaN` on an empty batch** (`0 * inf`) and + accepted a target whose length did not match the batch; both are typed + errors now (#47). +- **`MSELoss` and `BCEWithLogitsLoss` accepted a broadcastable target**, + silently computing a wrong loss; the target must match the input shape + exactly (#48). +- **`Conv2d` panicked on an input smaller than its kernel, or a zero + stride** — a `usize` underflow or a divide by zero — instead of + returning a typed error (#49). +- **`BatchNorm2d`'s running variance was biased.** The running estimate now + uses the unbiased sample variance (matching PyTorch) while the current + batch is still normalized with the biased variance (#64). +- **`argmax` hid `NaN`**, skipping it and pointing at an arbitrary element; + a `NaN` input is now a typed error (#74). +- **`Optimizer::step` failed the whole step when one parameter had no + gradient**; it now skips that parameter, as PyTorch does (#53). +- **GPU kernels truncated tensor extents, strides and offsets to `u32`** + with an unchecked cast; an out-of-range value is a typed error before + dispatch, not a wrong-but-plausible launch (#63). +- **`train --tui` panicked (exit 101) when stdout was not a terminal**; it + now fails with a usage-style error and a non-zero exit (#56). +- **Ctrl-C typed into the dashboard was ignored** — only `q` and `Esc` + quit. In raw mode it arrives as a key, which the event loop now treats + like the others (#57). + +### Performance + +- **CPU matmul borrows contiguous operands** rather than copying both on + every call; a strided or broadcast operand is still gathered, which is + the case that needs it (#73). + ## [0.4.0] — 2026-09-06 The audit milestone (#33–#40): what the framework claimed, checked against diff --git a/Cargo.lock b/Cargo.lock index c634b67..9e15264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,22 +309,6 @@ dependencies = [ "phf", ] -[[package]] -name = "ctor" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "cudarc" version = "0.19.9" @@ -428,21 +412,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "either" version = "1.18.0" @@ -986,7 +955,7 @@ dependencies = [ [[package]] name = "oxmera" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-autograd", "oxmera-core", @@ -1002,7 +971,7 @@ dependencies = [ [[package]] name = "oxmera-autograd" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1010,7 +979,7 @@ dependencies = [ [[package]] name = "oxmera-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "crossterm", "oxmera", @@ -1024,7 +993,7 @@ dependencies = [ [[package]] name = "oxmera-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "thiserror 2.0.20", @@ -1032,7 +1001,7 @@ dependencies = [ [[package]] name = "oxmera-cpu" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1040,9 +1009,8 @@ dependencies = [ [[package]] name = "oxmera-cuda" -version = "0.4.0" +version = "0.5.0" dependencies = [ - "ctor", "cudarc", "libloading", "oxmera-core", @@ -1053,9 +1021,8 @@ dependencies = [ [[package]] name = "oxmera-metal" -version = "0.4.0" +version = "0.5.0" dependencies = [ - "ctor", "metal", "oxmera-core", "oxmera-nn", @@ -1065,7 +1032,7 @@ dependencies = [ [[package]] name = "oxmera-nn" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1075,7 +1042,7 @@ dependencies = [ [[package]] name = "oxmera-ops" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-tensor", @@ -1083,7 +1050,7 @@ dependencies = [ [[package]] name = "oxmera-optim" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-nn", @@ -1092,7 +1059,7 @@ dependencies = [ [[package]] name = "oxmera-runtime" -version = "0.4.0" +version = "0.5.0" dependencies = [ "oxmera-core", "oxmera-cpu", @@ -1104,7 +1071,7 @@ dependencies = [ [[package]] name = "oxmera-tensor" -version = "0.4.0" +version = "0.5.0" dependencies = [ "metal", "oxmera-core", diff --git a/Cargo.toml b/Cargo.toml index c62b7c1..501c281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,13 +19,15 @@ members = [ exclude = ["research"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.88" readme = "README.md" license = "MIT OR Apache-2.0" repository = "https://github.com/vyncint/oxmera" authors = ["Vyncint Ng "] +keywords = ["tensor", "deep-learning", "machine-learning", "autograd", "gpu"] +categories = ["science", "mathematics", "algorithms"] [workspace.dependencies] thiserror = "2" @@ -34,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.4.0" } -oxmera-core = { path = "crates/oxmera-core", version = "0.4.0" } -oxmera-tensor = { path = "crates/oxmera-tensor", version = "0.4.0" } -oxmera-ops = { path = "crates/oxmera-ops", version = "0.4.0" } +oxmera = { path = "crates/oxmera", version = "0.5.0" } +oxmera-core = { path = "crates/oxmera-core", version = "0.5.0" } +oxmera-tensor = { path = "crates/oxmera-tensor", version = "0.5.0" } +oxmera-ops = { path = "crates/oxmera-ops", version = "0.5.0" } # 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.4.0", default-features = false } -oxmera-cpu = { path = "crates/oxmera-cpu", version = "0.4.0" } -oxmera-metal = { path = "crates/oxmera-metal", version = "0.4.0" } -oxmera-cuda = { path = "crates/oxmera-cuda", version = "0.4.0" } -oxmera-autograd = { path = "crates/oxmera-autograd", version = "0.4.0" } -oxmera-nn = { path = "crates/oxmera-nn", version = "0.4.0" } -oxmera-optim = { path = "crates/oxmera-optim", version = "0.4.0" } +oxmera-runtime = { path = "crates/oxmera-runtime", version = "0.5.0", default-features = false } +oxmera-cpu = { path = "crates/oxmera-cpu", version = "0.5.0" } +oxmera-metal = { path = "crates/oxmera-metal", version = "0.5.0" } +oxmera-cuda = { path = "crates/oxmera-cuda", version = "0.5.0" } +oxmera-autograd = { path = "crates/oxmera-autograd", version = "0.5.0" } +oxmera-nn = { path = "crates/oxmera-nn", version = "0.5.0" } +oxmera-optim = { path = "crates/oxmera-optim", version = "0.5.0" } diff --git a/crates/oxmera-autograd/Cargo.toml b/crates/oxmera-autograd/Cargo.toml index 1de6179..a7da960 100644 --- a/crates/oxmera-autograd/Cargo.toml +++ b/crates/oxmera-autograd/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-cli/Cargo.toml b/crates/oxmera-cli/Cargo.toml index 7bfbf78..396e665 100644 --- a/crates/oxmera-cli/Cargo.toml +++ b/crates/oxmera-cli/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [[bin]] name = "oxmera" @@ -21,6 +23,7 @@ oxmera.workspace = true crossterm = "0.29" ratatui = "0.30.0" serde.workspace = true +serde_json = "1" toml.workspace = true # Already in the tree via crossterm <- ratatui, at this version. Naming it diff --git a/crates/oxmera-cli/src/doctor.rs b/crates/oxmera-cli/src/doctor.rs index 2d863a2..4f1a2cc 100644 --- a/crates/oxmera-cli/src/doctor.rs +++ b/crates/oxmera-cli/src/doctor.rs @@ -5,17 +5,37 @@ use crate::probe; use crate::report::Report; +const USAGE: &str = "usage: oxmera doctor [--fixture ] [--json]"; + pub fn run(args: &[String]) -> Result<(), String> { - let report = match args { - [] => probe::probe(), - [flag, path] if flag == "--fixture" => { + let mut fixture: Option<&String> = None; + let mut json = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--help" | "-h" => { + println!("{USAGE}"); + return Ok(()); + } + "--json" => json = true, + "--fixture" => fixture = Some(it.next().ok_or("--fixture expects a path")?), + other => return Err(format!("unknown doctor argument {other}\n{USAGE}")), + } + } + let report = match fixture { + Some(path) => { let text = std::fs::read_to_string(path) .map_err(|e| format!("cannot read fixture {path}: {e}"))?; toml::from_str(&text).map_err(|e| format!("cannot parse fixture {path}: {e}"))? } - _ => return Err("usage: oxmera doctor [--fixture ]".into()), + None => probe::probe(), }; - print!("{}", render(&report)); + if json { + let json = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?; + println!("{json}"); + } else { + print!("{}", render(&report)); + } Ok(()) } diff --git a/crates/oxmera-cli/src/report.rs b/crates/oxmera-cli/src/report.rs index 844c142..bca78b0 100644 --- a/crates/oxmera-cli/src/report.rs +++ b/crates/oxmera-cli/src/report.rs @@ -2,10 +2,10 @@ //! sources — probed from the machine, or injected from a fixture so the //! termlens goldens never depend on the machine they were blessed on. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// Everything doctor knows about one machine. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Report { /// Operating system family: "macos", "linux", or other. pub os: String, @@ -20,7 +20,7 @@ pub struct Report { } /// Hardware identity. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Host { /// Chip/SoC name (e.g. "Apple M3 Pro"). pub chip: Option, @@ -33,7 +33,7 @@ pub struct Host { } /// Tool presence and versions. `None` means not found on PATH. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Toolchain { /// `rustc --version`. pub rustc: Option, @@ -42,7 +42,7 @@ pub struct Toolchain { } /// Compute devices. -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct Devices { /// Threads the CPU backend parallelizes across. pub cpu_threads: u32, diff --git a/crates/oxmera-cli/src/train.rs b/crates/oxmera-cli/src/train.rs index a480c33..a1c7277 100644 --- a/crates/oxmera-cli/src/train.rs +++ b/crates/oxmera-cli/src/train.rs @@ -89,7 +89,13 @@ fn parse(args: &[String]) -> Result { Ok(out) } +const USAGE: &str = "usage: oxmera train [--device cpu|metal|cuda] [--epochs N] [--seed N] [--tui] [--replay ]"; + pub fn run(args: &[String]) -> Result<(), String> { + if args.iter().any(|a| a == "--help" || a == "-h") { + println!("{USAGE}"); + return Ok(()); + } let args = parse(args)?; if let Some(path) = &args.replay { @@ -243,6 +249,7 @@ fn memory_label(device: Device) -> String { } /// Parameter-free tanh activation for the demo pipeline. +#[derive(Debug)] struct Activation; impl Module for Activation { diff --git a/crates/oxmera-cli/src/tui.rs b/crates/oxmera-cli/src/tui.rs index 826d90e..2d18c91 100644 --- a/crates/oxmera-cli/src/tui.rs +++ b/crates/oxmera-cli/src/tui.rs @@ -7,7 +7,9 @@ //! by frame with no clocks and no randomness — the mode the termlens //! goldens capture. -use crossterm::event::{self, Event, KeyCode}; +use std::io::IsTerminal; + +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -64,6 +66,9 @@ pub struct Dashboard { /// Enter the alternate screen and draw the initial (empty) dashboard. pub fn start(state: DashState) -> Result { + if !std::io::stdout().is_terminal() { + return Err("the live dashboard needs a terminal on stdout; run without --tui".into()); + } // Before the terminal is borrowed, so the guard is in place for every // instant it is borrowed. `ratatui::init()` installs the panic hook; // this covers the exit it does not (#40). @@ -122,6 +127,9 @@ impl Dashboard { /// Render a recorded run: every epoch frame in order, no clocks, then the /// completed frame until `q`. pub fn run_replay(replay: &Replay) -> Result<(), String> { + if !std::io::stdout().is_terminal() { + return Err("rendering a replay needs a terminal on stdout".into()); + } let mut state = DashState::new( &replay.device, &replay.model, @@ -168,7 +176,9 @@ fn wait_for_quit(terminal: &mut DefaultTerminal, state: &DashState) -> Result<() fn is_quit(ev: &Event) -> bool { matches!( ev, - Event::Key(k) if k.code == KeyCode::Char('q') || k.code == KeyCode::Esc + Event::Key(k) if k.code == KeyCode::Char('q') + || k.code == KeyCode::Esc + || (k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL)) ) } diff --git a/crates/oxmera-cli/tests/cli.rs b/crates/oxmera-cli/tests/cli.rs index 06896bc..db19ac9 100644 --- a/crates/oxmera-cli/tests/cli.rs +++ b/crates/oxmera-cli/tests/cli.rs @@ -142,3 +142,41 @@ fn doctor_names_every_family_this_release_ships() { ); } } + +#[test] +fn subcommand_help_is_stdout_and_exit_zero() { + for sub in ["doctor", "train"] { + let (code, stdout, stderr) = run(&[sub, "--help"]); + assert_eq!(code, 0, "{sub} --help: exit code (was 1 before #58)"); + assert!( + stdout.starts_with("usage: oxmera"), + "{sub} --help: stdout {stdout:?}" + ); + assert!(stderr.is_empty(), "{sub} --help: stderr {stderr:?}"); + } +} + +#[test] +fn doctor_json_is_machine_readable() { + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/no-gpu.toml"); + let (code, stdout, stderr) = + run(&["doctor", "--fixture", &fixture.to_string_lossy(), "--json"]); + assert_eq!(code, 0, "doctor --json failed: {stderr}"); + let v: serde_json::Value = + serde_json::from_str(&stdout).expect("doctor --json emits valid JSON"); + assert!(v["os"].is_string(), "{stdout}"); + assert!(v["devices"].is_object(), "{stdout}"); + assert!(v["toolchain"].is_object(), "{stdout}"); +} + +#[test] +fn train_tui_without_a_terminal_fails_cleanly_not_a_panic() { + // stdout is a pipe here, not a tty: the dashboard must refuse with a + // usage-style error and a non-zero, non-101 exit — never a panic. + let (code, stdout, stderr) = run(&["train", "--tui", "--epochs", "1"]); + assert_ne!(code, 0, "should fail without a terminal"); + assert_ne!(code, 101, "must not panic: {stderr}"); + assert!(stdout.is_empty(), "stdout {stdout:?}"); + assert!(stderr.contains("terminal"), "stderr {stderr:?}"); +} diff --git a/crates/oxmera-cli/tests/tui.rs b/crates/oxmera-cli/tests/tui.rs index 3c6eb91..bed91c9 100644 --- a/crates/oxmera-cli/tests/tui.rs +++ b/crates/oxmera-cli/tests/tui.rs @@ -159,3 +159,22 @@ fn stress_100_iterations_are_identical() -> termlens::Result<()> { } Ok(()) } + +/// Ctrl-C typed into the dashboard is a key (raw mode disables ISIG, so it +/// arrives as Char('c')+CONTROL, not a signal). Before 0.5 only q and Esc +/// quit and it was silently dropped. +#[test] +fn ctrl_c_typed_into_the_dashboard_quits_and_restores() -> termlens::Result<()> { + let (mut t, _screen) = spawn((100, 45))?; + t.send(Key::Ctrl('c'))?; + let status = t.wait_exit()?; + assert!( + status.success(), + "ctrl-c should quit cleanly, got {status:?}" + ); + assert!( + !t.screen().alternate_screen(), + "ctrl-c left the shell inside the alternate screen" + ); + Ok(()) +} diff --git a/crates/oxmera-core/Cargo.toml b/crates/oxmera-core/Cargo.toml index 45d54b8..d125796 100644 --- a/crates/oxmera-core/Cargo.toml +++ b/crates/oxmera-core/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] thiserror.workspace = true diff --git a/crates/oxmera-core/src/device.rs b/crates/oxmera-core/src/device.rs index 2dfb044..a54d339 100644 --- a/crates/oxmera-core/src/device.rs +++ b/crates/oxmera-core/src/device.rs @@ -12,13 +12,17 @@ pub enum Device { Cpu, /// An Apple-Silicon GPU via Metal, by device index. Metal { - /// Zero-based device index. + /// Zero-based device index. Registration adds device 0 only; a + /// higher index resolves to `BackendUnavailable` + /// (see `docs/LIMITATIONS.md`). index: usize, }, - /// An NVIDIA GPU, by device index — served by `oxmera-cuda` when a - /// driver and a device are present at load time. + /// An NVIDIA GPU, by device index — served by `oxmera-cuda` once a + /// driver and device are registered (`oxmera_runtime::init`). Cuda { - /// Zero-based device index. + /// Zero-based device index. Registration adds device 0 only; a + /// higher index resolves to `BackendUnavailable` + /// (see `docs/LIMITATIONS.md`). index: usize, }, } diff --git a/crates/oxmera-cpu/Cargo.toml b/crates/oxmera-cpu/Cargo.toml index 1b3b8c2..14f45d3 100644 --- a/crates/oxmera-cpu/Cargo.toml +++ b/crates/oxmera-cpu/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-cuda/Cargo.toml b/crates/oxmera-cuda/Cargo.toml index 7eda59b..52f4a0d 100644 --- a/crates/oxmera-cuda/Cargo.toml +++ b/crates/oxmera-cuda/Cargo.toml @@ -8,11 +8,12 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true oxmera-tensor.workspace = true -ctor = "0.6.3" # The driver library is resolved at runtime (dlopen), never linked: a # machine without libcuda simply has no CUDA device. The `cuda-12080` # bindings run against any driver from the 12.8 series up, including 13.x. diff --git a/crates/oxmera-cuda/src/backend.rs b/crates/oxmera-cuda/src/backend.rs index 0b50c27..e956597 100644 --- a/crates/oxmera-cuda/src/backend.rs +++ b/crates/oxmera-cuda/src/backend.rs @@ -136,13 +136,22 @@ impl TensorMeta { } let mut meta = TensorMeta { rank: dims.len() as u32, - offset: offset as u32, + offset: u32::try_from(offset).map_err(|_| Error::InvalidArgument { + op, + detail: format!("offset {offset} exceeds the u32 limit of GPU kernels"), + })?, dims: [1; MAX_RANK], strides: [0; MAX_RANK], }; for (i, (&d, &s)) in dims.iter().zip(strides).enumerate() { - meta.dims[i] = d as u32; - meta.strides[i] = s as i32; + meta.dims[i] = u32::try_from(d).map_err(|_| Error::InvalidArgument { + op, + detail: format!("extent {d} exceeds the u32 limit of GPU kernels"), + })?; + meta.strides[i] = i32::try_from(s).map_err(|_| Error::InvalidArgument { + op, + detail: format!("stride {s} does not fit in i32 for a GPU kernel"), + })?; } Ok(meta) } @@ -524,6 +533,7 @@ impl Backend for CudaBackend { a_batch_stride, b_batch_stride, out_shape, + .. } = plan_matmul(a.shape(), b.shape())?; let ab = self.buf_of(&a, "matmul")?; let bb = self.buf_of(&b, "matmul")?; diff --git a/crates/oxmera-cuda/src/lib.rs b/crates/oxmera-cuda/src/lib.rs index 41447f2..06c25aa 100644 --- a/crates/oxmera-cuda/src/lib.rs +++ b/crates/oxmera-cuda/src/lib.rs @@ -17,8 +17,8 @@ //! //! Unsafe policy: every `unsafe` block is FFI-adjacent — a kernel launch //! whose argument list is checked against the kernel signature, a `dlopen` -//! probe, a `#[repr(C)]` argument marker, or life-before-main registration — -//! and carries a `// SAFETY:` justification. +//! probe, or a `#[repr(C)]` argument marker — and carries a `// SAFETY:` +//! justification. #![deny(unsafe_code)] #![warn(missing_docs)] @@ -28,14 +28,3 @@ mod backend; pub use backend::{ CudaBackend, device_summary, is_driver_present, register_default, source_fingerprint, }; - -// SAFETY: runs before main via the platform's initializer section. The body -// probes for libcuda (a dlopen that fails cleanly when the library is -// absent), optionally creates a context, and inserts into the -// std-synchronized backend registry; no thread-locals, no other crate's -// statics. -#[allow(unsafe_code)] -#[ctor::ctor(crate_path = ::ctor)] -unsafe fn auto_register() { - register_default(); -} diff --git a/crates/oxmera-metal/Cargo.toml b/crates/oxmera-metal/Cargo.toml index d0da3f5..78894ad 100644 --- a/crates/oxmera-metal/Cargo.toml +++ b/crates/oxmera-metal/Cargo.toml @@ -8,11 +8,12 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true oxmera-tensor.workspace = true -ctor = "0.6.3" [target.'cfg(target_os = "macos")'.dependencies] metal = "0.33.0" diff --git a/crates/oxmera-metal/src/backend.rs b/crates/oxmera-metal/src/backend.rs index 67ef150..4798c31 100644 --- a/crates/oxmera-metal/src/backend.rs +++ b/crates/oxmera-metal/src/backend.rs @@ -62,13 +62,22 @@ impl TensorMeta { } let mut meta = TensorMeta { rank: dims.len() as u32, - offset: offset as u32, + offset: u32::try_from(offset).map_err(|_| Error::InvalidArgument { + op, + detail: format!("offset {offset} exceeds the u32 limit of GPU kernels"), + })?, dims: [1; MAX_RANK], strides: [0; MAX_RANK], }; for (i, (&d, &s)) in dims.iter().zip(strides).enumerate() { - meta.dims[i] = d as u32; - meta.strides[i] = s as i32; + meta.dims[i] = u32::try_from(d).map_err(|_| Error::InvalidArgument { + op, + detail: format!("extent {d} exceeds the u32 limit of GPU kernels"), + })?; + meta.strides[i] = i32::try_from(s).map_err(|_| Error::InvalidArgument { + op, + detail: format!("stride {s} does not fit in i32 for a GPU kernel"), + })?; } Ok(meta) } @@ -516,6 +525,7 @@ impl Backend for MetalBackend { a_batch_stride, b_batch_stride, out_shape, + .. } = plan_matmul(a.shape(), b.shape())?; let ab = self.buffer_of(&a, "matmul")?; let bb = self.buffer_of(&b, "matmul")?; diff --git a/crates/oxmera-metal/src/lib.rs b/crates/oxmera-metal/src/lib.rs index b2ae07e..82e21a3 100644 --- a/crates/oxmera-metal/src/lib.rs +++ b/crates/oxmera-metal/src/lib.rs @@ -3,12 +3,12 @@ //! multiplication, over unified-memory (`StorageModeShared`) buffers. //! //! On non-macOS targets this crate compiles to an empty stub so the -//! workspace builds everywhere; the backend registers itself at load time -//! on macOS only. +//! workspace builds everywhere. Registration is explicit: call +//! [`register_default`] — or `oxmera_runtime::init()` — on macOS. //! //! Unsafe policy: every `unsafe` block in this crate is FFI-adjacent — -//! reading a Metal buffer's contents pointer or life-before-main -//! registration — and carries a `// SAFETY:` justification. +//! reading a Metal buffer's contents pointer — and carries a `// SAFETY:` +//! justification. #![deny(unsafe_code)] #![warn(missing_docs)] @@ -23,14 +23,3 @@ pub use backend::{MetalBackend, device_summary, register_default}; /// cfg of their own. #[cfg(not(target_os = "macos"))] pub fn register_default() {} - -// SAFETY: runs before main via the platform's initializer section. The -// body only queries the Metal device list and inserts into the -// std-synchronized backend registry; no thread-locals, no other crate's -// statics. -#[cfg(target_os = "macos")] -#[allow(unsafe_code)] -#[ctor::ctor(crate_path = ::ctor)] -unsafe fn auto_register() { - register_default(); -} diff --git a/crates/oxmera-nn/Cargo.toml b/crates/oxmera-nn/Cargo.toml index b2ebeb8..4ac7b93 100644 --- a/crates/oxmera-nn/Cargo.toml +++ b/crates/oxmera-nn/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-nn/src/conv.rs b/crates/oxmera-nn/src/conv.rs index 46f59e5..01101b7 100644 --- a/crates/oxmera-nn/src/conv.rs +++ b/crates/oxmera-nn/src/conv.rs @@ -84,6 +84,18 @@ impl Module for Conv2d { let (n, _c, h, w) = (dims[0], dims[1], dims[2], dims[3]); let (kh, kw) = self.kernel; let (s, p) = (self.stride, self.padding); + if s == 0 { + return Err(Error::InvalidArgument { + op: "Conv2d", + detail: "stride must be >= 1".into(), + }); + } + if h + 2 * p < kh || w + 2 * p < kw { + return Err(Error::InvalidArgument { + op: "Conv2d", + detail: format!("input {h}x{w} padded by {p} is smaller than kernel {kh}x{kw}"), + }); + } let h_out = (h + 2 * p - kh) / s + 1; let w_out = (w + 2 * p - kw) / s + 1; diff --git a/crates/oxmera-nn/src/dropout.rs b/crates/oxmera-nn/src/dropout.rs index cd9e7df..d5a011f 100644 --- a/crates/oxmera-nn/src/dropout.rs +++ b/crates/oxmera-nn/src/dropout.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use oxmera_core::Result; +use oxmera_core::{Error, Result}; use oxmera_tensor::tensor::Tensor; use rand::{Rng, SeedableRng}; @@ -21,12 +21,23 @@ pub struct Dropout { impl Dropout { /// Dropout with drop probability `p` in `[0, 1)`. - pub fn new(p: f32, seed: u64) -> Self { - Self { + /// + /// Returns [`Error::InvalidArgument`] when `p` is outside `[0, 1)`: at + /// `p >= 1` every element is dropped and the survivors would be scaled + /// by `1/(1 - p) <= 0`, and a negative `p` is silently ignored by the + /// forward pass. + pub fn new(p: f32, seed: u64) -> Result { + if !(0.0..1.0).contains(&p) { + return Err(Error::InvalidArgument { + op: "Dropout", + detail: format!("drop probability must be in [0, 1), got {p}"), + }); + } + Ok(Self { p, training: AtomicBool::new(true), seed: AtomicU64::new(seed), - } + }) } } diff --git a/crates/oxmera-nn/src/lib.rs b/crates/oxmera-nn/src/lib.rs index 7f5b1c0..2289b03 100644 --- a/crates/oxmera-nn/src/lib.rs +++ b/crates/oxmera-nn/src/lib.rs @@ -25,7 +25,7 @@ pub use norm::{BatchNorm2d, LayerNorm}; pub use param::Param; pub use sequential::Sequential; -use oxmera_core::{Error, Result}; +use oxmera_core::{Device, Error, Result}; use oxmera_tensor::tensor::Tensor; /// Refuse an input whose dtype the module's parameters cannot meet. @@ -70,7 +70,7 @@ pub fn check_param_dtype(layer: &'static str, input: &Tensor, params: &[Param]) /// A neural-network component: a differentiable function of its input and /// a set of learnable parameters. -pub trait Module: Send + Sync { +pub trait Module: Send + Sync + std::fmt::Debug { /// Apply the module. fn forward(&self, input: &Tensor) -> Result; @@ -94,6 +94,19 @@ pub trait Module: Send + Sync { /// Switch training-mode behaviour (dropout, batch-norm statistics). /// Modules without mode-dependent behaviour ignore this. fn set_training(&self, _training: bool) {} + + /// Move every parameter to `device`. + /// + /// Layers read their parameters on each forward, so once the weights + /// live on `device` the per-forward device transfer is a no-op and + /// device-resident fused kernels (such as the fused Adam step) engage. + /// Containers inherit this through `parameters()`. + fn to_device(&self, device: Device) -> Result<()> { + for p in self.parameters() { + p.to_device(device)?; + } + Ok(()) + } } /// What this crate can do, for `oxmera doctor`. See diff --git a/crates/oxmera-nn/src/loss.rs b/crates/oxmera-nn/src/loss.rs index e48500a..281ce47 100644 --- a/crates/oxmera-nn/src/loss.rs +++ b/crates/oxmera-nn/src/loss.rs @@ -1,7 +1,7 @@ //! Loss functions. Each is a plain struct with a two-argument `forward`; //! all are composites of differentiable primitives. -use oxmera_core::{Error, Result}; +use oxmera_core::{Error, Result, Shape}; use oxmera_tensor::tensor::Tensor; use crate::functional::one_hot; @@ -13,6 +13,13 @@ pub struct MSELoss; impl MSELoss { /// The scalar loss. pub fn forward(&self, input: &Tensor, target: &Tensor) -> Result { + if input.shape() != target.shape() { + return Err(Error::ShapeMismatch { + expected: input.shape().clone(), + got: target.shape().clone(), + op: "MSELoss", + }); + } let diff = input.sub(target)?; diff.mul(&diff)?.mean(&[]) } @@ -33,6 +40,20 @@ impl CrossEntropyLoss { detail: format!("logits must be [n, classes], got {:?}", logits.shape()), }); } + let n = logits.dims()[0]; + if n == 0 { + return Err(Error::InvalidArgument { + op: "CrossEntropyLoss", + detail: "empty batch: the mean over 0 samples is undefined".into(), + }); + } + if target.ndim() != 1 || target.dims()[0] != n { + return Err(Error::ShapeMismatch { + expected: Shape::from([n]), + got: target.shape().clone(), + op: "CrossEntropyLoss", + }); + } let classes = logits.dims()[1]; let log_p = logits.log_softmax(1)?; let onehot = one_hot(&target.to_device(oxmera_core::Device::Cpu)?, classes)? @@ -41,7 +62,7 @@ impl CrossEntropyLoss { .mul(&onehot)? .sum(&[])? .neg()? - .mul_scalar(1.0 / logits.dims()[0] as f32) + .mul_scalar(1.0 / n as f32) } } @@ -53,6 +74,13 @@ pub struct BCEWithLogitsLoss; impl BCEWithLogitsLoss { /// The scalar loss, averaged over every element. pub fn forward(&self, logits: &Tensor, target: &Tensor) -> Result { + if logits.shape() != target.shape() { + return Err(Error::ShapeMismatch { + expected: logits.shape().clone(), + got: target.shape().clone(), + op: "BCEWithLogitsLoss", + }); + } let zero = Tensor::scalar_on(logits, 0.0)?; let relu_x = logits.maximum(&zero)?; let xz = logits.mul(target)?; diff --git a/crates/oxmera-nn/src/norm.rs b/crates/oxmera-nn/src/norm.rs index c8c211b..925cc98 100644 --- a/crates/oxmera-nn/src/norm.rs +++ b/crates/oxmera-nn/src/norm.rs @@ -30,6 +30,12 @@ impl LayerNorm { eps: 1e-5, } } + + /// Set the numerical-stability epsilon (default `1e-5`), builder-style. + pub fn with_eps(mut self, eps: f32) -> Self { + self.eps = eps; + self + } } impl Module for LayerNorm { @@ -96,6 +102,18 @@ impl BatchNorm2d { training: AtomicBool::new(true), } } + + /// Set the running-statistics momentum (default `0.1`), builder-style. + pub fn with_momentum(mut self, momentum: f32) -> Self { + self.momentum = momentum; + self + } + + /// Set the numerical-stability epsilon (default `1e-5`), builder-style. + pub fn with_eps(mut self, eps: f32) -> Self { + self.eps = eps; + self + } } impl Module for BatchNorm2d { @@ -122,6 +140,15 @@ impl Module for BatchNorm2d { .detach() .to_device(oxmera_core::Device::Cpu)? .reshape(Shape::from([self.channels]))?; + // The running estimate uses the unbiased sample variance + // (Bessel's correction), matching PyTorch; the current + // batch is still normalized with the biased `var` below. + let n_elem = (input.dims()[0] * input.dims()[2] * input.dims()[3]) as f32; + let flat_var = if n_elem > 1.0 { + flat_var.mul_scalar(n_elem / (n_elem - 1.0))? + } else { + flat_var + }; let mut rm = self.running_mean.lock().expect("bn lock poisoned"); let mut rv = self.running_var.lock().expect("bn lock poisoned"); *rm = rm diff --git a/crates/oxmera-nn/src/param.rs b/crates/oxmera-nn/src/param.rs index 710b243..2d8782a 100644 --- a/crates/oxmera-nn/src/param.rs +++ b/crates/oxmera-nn/src/param.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, RwLock}; -use oxmera_core::Result; +use oxmera_core::{Device, Result}; use oxmera_tensor::tensor::Tensor; /// A learnable parameter: a shared, replaceable handle to a @@ -44,6 +44,21 @@ impl Param { *self.inner.write().expect("param lock poisoned") = value.detach().requires_grad_(true); } + /// Move the parameter's value to `device` in place. + /// + /// The shared handle means every holder — the owning module and any + /// optimizer state keyed on this `Param` — sees the moved tensor, so a + /// model's weights upload once instead of the forward pass re-uploading + /// them on every call. A no-op when the value is already on `device`. + pub fn to_device(&self, device: Device) -> Result<()> { + let current = self.value(); + if current.device() == device { + return Ok(()); + } + self.set(current.to_device(device)?); + Ok(()) + } + /// The gradient accumulated on the current value, if any. pub fn grad(&self) -> Option { self.inner.read().expect("param lock poisoned").grad() diff --git a/crates/oxmera-nn/src/sequential.rs b/crates/oxmera-nn/src/sequential.rs index 9460753..be52706 100644 --- a/crates/oxmera-nn/src/sequential.rs +++ b/crates/oxmera-nn/src/sequential.rs @@ -7,7 +7,7 @@ use crate::{Module, Param}; /// Modules applied in order. Parameter names are prefixed by child index /// (`0.weight`, `1.bias`, …), matching the common convention. -#[derive(Default)] +#[derive(Debug, Default)] pub struct Sequential { children: Vec>, } @@ -33,6 +33,24 @@ impl Sequential { pub fn is_empty(&self) -> bool { self.children.is_empty() } + + /// A borrowing iterator over the child modules, in order. + pub fn iter(&self) -> impl Iterator { + self.children.iter().map(|c| &**c) + } + + /// The child module at `index`, or `None` if out of range. + pub fn get(&self, index: usize) -> Option<&dyn Module> { + self.children.get(index).map(|c| &**c) + } +} + +impl std::ops::Index for Sequential { + type Output = dyn Module; + + fn index(&self, index: usize) -> &Self::Output { + &*self.children[index] + } } impl Module for Sequential { diff --git a/crates/oxmera-nn/tests/layers.rs b/crates/oxmera-nn/tests/layers.rs index bf1afb1..4d7d68e 100644 --- a/crates/oxmera-nn/tests/layers.rs +++ b/crates/oxmera-nn/tests/layers.rs @@ -115,7 +115,7 @@ fn batchnorm_normalizes_in_train_and_uses_running_stats_in_eval() { #[test] fn dropout_is_identity_in_eval_and_scales_in_train() { - let d = Dropout::new(0.5, 13); + let d = Dropout::new(0.5, 13).unwrap(); let x = Tensor::ones([1000]); let y = d.forward(&x).unwrap(); let kept: Vec = y.to_vec_f32().unwrap(); @@ -292,3 +292,93 @@ fn a_deep_cloned_layer_is_still_trainable() { "the original must not see the copy's gradient" ); } + +// ---- 0.5 hardening: typed refusals, richer Sequential, device moves ---- + +#[test] +fn cross_entropy_rejects_an_empty_batch() { + let logits = Tensor::from_vec_f32(vec![], [0, 3]).unwrap(); + let target = Tensor::from_vec_i64(vec![], [0]).unwrap(); + assert!(CrossEntropyLoss.forward(&logits, &target).is_err()); +} + +#[test] +fn cross_entropy_rejects_a_mismatched_target_length() { + let logits = Tensor::from_slice(&[0.1, 0.2, 0.3, 0.4, 0.5, 0.6], [2, 3]).unwrap(); + let target = Tensor::from_vec_i64(vec![0, 1, 2], [3]).unwrap(); + assert!(CrossEntropyLoss.forward(&logits, &target).is_err()); +} + +#[test] +fn mse_and_bce_reject_a_broadcastable_target() { + let input = Tensor::from_slice(&[1.0, 2.0, 3.0, 4.0], [2, 2]).unwrap(); + let broadcast = Tensor::from_slice(&[1.0, 1.0], [2]).unwrap(); + assert!(MSELoss.forward(&input, &broadcast).is_err()); + assert!(BCEWithLogitsLoss.forward(&input, &broadcast).is_err()); + let exact = Tensor::from_slice(&[0.0, 1.0, 1.0, 0.0], [2, 2]).unwrap(); + assert!(MSELoss.forward(&input, &exact).is_ok()); +} + +#[test] +fn conv2d_rejects_subkernel_input_and_zero_stride() { + let c = Conv2d::new(1, 1, (3, 3), 1, 0, 7); + assert!(c.forward(&Tensor::zeros([1, 1, 2, 2])).is_err()); + let zero = Conv2d::new(1, 1, (2, 2), 0, 0, 7); + assert!(zero.forward(&Tensor::zeros([1, 1, 4, 4])).is_err()); +} + +#[test] +fn dropout_rejects_probability_outside_the_unit_interval() { + assert!(Dropout::new(1.0, 0).is_err()); + assert!(Dropout::new(1.5, 0).is_err()); + assert!(Dropout::new(-0.1, 0).is_err()); + assert!(Dropout::new(0.0, 0).is_ok()); + assert!(Dropout::new(0.999, 0).is_ok()); +} + +#[test] +fn batchnorm_running_var_uses_the_unbiased_variance() { + let bn = BatchNorm2d::new(1); + let x = Tensor::from_slice(&[0.0, 2.0, 4.0, 6.0], [4, 1, 1, 1]).unwrap(); + bn.forward(&x).unwrap(); + bn.set_training(false); + // 1.3 - running_mean(0.3) = 1.0; y = 1/sqrt(running_var + eps). + let probe = Tensor::from_slice(&[1.3], [1, 1, 1, 1]).unwrap(); + let y = bn.forward(&probe).unwrap().to_vec_f32().unwrap()[0]; + // Unbiased running_var 0.9*1 + 0.1*(20/3) = 1.5667 -> 0.7989. + // The pre-0.5 biased value 1.4 would give 0.8452. + assert!( + (y - 0.7989).abs() < 2e-3, + "running_var should be unbiased; y = {y}" + ); +} + +#[test] +fn sequential_is_debug_and_exposes_its_children() { + let net = Sequential::new() + .push(Linear::new(2, 3, 1)) + .push(Linear::new(3, 1, 2)); + assert_eq!(net.len(), 2); + assert!(net.get(0).is_some()); + assert!(net.get(2).is_none()); + assert_eq!(net.iter().count(), 2); + let _first: &dyn Module = &net[0]; + let dbg = format!("{net:?}"); + assert!(dbg.contains("Sequential"), "{dbg}"); + assert!(dbg.contains("Linear"), "children should be shown: {dbg}"); +} + +#[test] +fn module_to_device_cpu_is_a_noop_and_stays_trainable() { + let layer = Linear::new(2, 2, 3); + layer.to_device(oxmera_core::Device::Cpu).unwrap(); + let x = Tensor::from_slice(&[1.0, 1.0], [1, 2]).unwrap(); + layer + .forward(&x) + .unwrap() + .sum(&[]) + .unwrap() + .backward() + .unwrap(); + assert!(layer.weight().grad().is_some()); +} diff --git a/crates/oxmera-ops/Cargo.toml b/crates/oxmera-ops/Cargo.toml index 63d1481..6e589bd 100644 --- a/crates/oxmera-ops/Cargo.toml +++ b/crates/oxmera-ops/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-optim/Cargo.toml b/crates/oxmera-optim/Cargo.toml index b85d628..a497b8f 100644 --- a/crates/oxmera-optim/Cargo.toml +++ b/crates/oxmera-optim/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-optim/src/lib.rs b/crates/oxmera-optim/src/lib.rs index c655f1e..764dc68 100644 --- a/crates/oxmera-optim/src/lib.rs +++ b/crates/oxmera-optim/src/lib.rs @@ -34,6 +34,7 @@ pub trait Optimizer { /// `step()`, each with its own settings, and share the optimizer's global /// state (Adam's bias-correction step count, for instance). #[derive(Debug, Clone)] +#[non_exhaustive] pub struct ParamGroup { /// The parameters in this group. pub params: Vec, @@ -60,13 +61,6 @@ impl ParamGroup { } } -fn grad_of(param: &Param) -> Result { - param.grad().ok_or(Error::InvalidArgument { - op: "Optimizer::step", - detail: "parameter has no gradient; run backward() first".into(), - }) -} - fn zero_all(groups: &[ParamGroup]) { for g in groups { for p in &g.params { @@ -120,8 +114,10 @@ impl Optimizer for Sgd { no_grad(|| { for (gi, group) in self.groups.iter().enumerate() { for (i, param) in group.params.iter().enumerate() { + let Some(mut grad) = param.grad() else { + continue; + }; let value = param.value().detach(); - let mut grad = grad_of(param)?; if group.weight_decay != 0.0 { grad = grad.add(&value.mul_scalar(group.weight_decay)?)?; } @@ -184,25 +180,27 @@ impl AdamCore { let bc2 = 1.0 - self.beta2.powi(self.step); for (gi, group) in self.groups.iter().enumerate() { for (i, param) in group.params.iter().enumerate() { + let Some(mut grad) = param.grad() else { + continue; + }; let mut value = param.value().detach(); - let mut grad = grad_of(param)?; // A GPU backend fuses the whole update into one launch; // the composite path below is the reference it must match. if value.device() != Device::Cpu { - let step = AdamStep { - param: &value, - grad: &grad, - m: self.m[gi][i].as_ref(), - v: self.v[gi][i].as_ref(), - lr: group.lr, - beta1: self.beta1, - beta2: self.beta2, - eps: self.eps, - weight_decay: group.weight_decay, - decoupled: self.decoupled, - bias_correction1: bc1, - bias_correction2: bc2, - }; + let step = AdamStep::new( + &value, + &grad, + self.m[gi][i].as_ref(), + self.v[gi][i].as_ref(), + group.lr, + self.beta1, + self.beta2, + self.eps, + group.weight_decay, + self.decoupled, + bc1, + bc2, + ); match backend_for(value.device())?.adam_step(&step) { Ok((p, m, v)) => { self.m[gi][i] = Some(m); @@ -273,6 +271,21 @@ impl Adam { Self(AdamCore::new(groups, false)) } + /// Override the exponential decay rates (defaults β₁ 0.9, β₂ 0.999), + /// builder-style. + pub fn with_betas(mut self, beta1: f32, beta2: f32) -> Self { + self.0.beta1 = beta1; + self.0.beta2 = beta2; + self + } + + /// Override the numerical-stability epsilon (default 1e-8), + /// builder-style. + pub fn with_eps(mut self, eps: f32) -> Self { + self.0.eps = eps; + self + } + /// The parameter groups, for schedules that adjust `lr` between steps. pub fn groups_mut(&mut self) -> &mut [ParamGroup] { &mut self.0.groups @@ -303,6 +316,21 @@ impl AdamW { Self(AdamCore::new(groups, true)) } + /// Override the exponential decay rates (defaults β₁ 0.9, β₂ 0.999), + /// builder-style. + pub fn with_betas(mut self, beta1: f32, beta2: f32) -> Self { + self.0.beta1 = beta1; + self.0.beta2 = beta2; + self + } + + /// Override the numerical-stability epsilon (default 1e-8), + /// builder-style. + pub fn with_eps(mut self, eps: f32) -> Self { + self.0.eps = eps; + self + } + /// The parameter groups, for schedules that adjust `lr` between steps. pub fn groups_mut(&mut self) -> &mut [ParamGroup] { &mut self.0.groups @@ -345,6 +373,19 @@ impl RmsProp { } } + /// Override the smoothing constant α (default 0.99), builder-style. + pub fn with_alpha(mut self, alpha: f32) -> Self { + self.alpha = alpha; + self + } + + /// Override the numerical-stability epsilon (default 1e-8), + /// builder-style. + pub fn with_eps(mut self, eps: f32) -> Self { + self.eps = eps; + self + } + /// The parameter groups, for schedules that adjust `lr` between steps. pub fn groups_mut(&mut self) -> &mut [ParamGroup] { &mut self.groups @@ -356,8 +397,10 @@ impl Optimizer for RmsProp { no_grad(|| { for (gi, group) in self.groups.iter().enumerate() { for (i, param) in group.params.iter().enumerate() { + let Some(grad) = param.grad() else { + continue; + }; let value = param.value().detach(); - let grad = grad_of(param)?; let g2 = grad.mul(&grad)?; let sq = match &self.sq[gi][i] { Some(s) => s diff --git a/crates/oxmera-optim/tests/convergence.rs b/crates/oxmera-optim/tests/convergence.rs index fb04859..fb7f907 100644 --- a/crates/oxmera-optim/tests/convergence.rs +++ b/crates/oxmera-optim/tests/convergence.rs @@ -103,6 +103,7 @@ fn adam_trains_an_mlp_to_solve_xor() { } /// A parameter-free activation module for test pipelines. +#[derive(Debug)] struct Tanh; impl Module for Tanh { @@ -118,11 +119,11 @@ impl Module for Tanh { } #[test] -fn optimizer_reports_missing_gradients() { +fn optimizer_skips_parameters_without_a_gradient() { + // A parameter that never received a gradient is skipped, not an error + // that fails the whole step — matching PyTorch's grad=None handling. let x = Param::new(Tensor::zeros(Shape::from([2]))); - let mut opt = Sgd::new(vec![x], 0.1); - assert!( - opt.step().is_err(), - "step without backward must be a typed error" - ); + let mut opt = Sgd::new(vec![x.clone()], 0.1); + assert!(opt.step().is_ok(), "a step with no gradients is a no-op"); + assert_eq!(x.value().to_vec_f32().unwrap(), vec![0.0, 0.0], "unchanged"); } diff --git a/crates/oxmera-optim/tests/groups.rs b/crates/oxmera-optim/tests/groups.rs index 7d48101..2722f0e 100644 --- a/crates/oxmera-optim/tests/groups.rs +++ b/crates/oxmera-optim/tests/groups.rs @@ -168,3 +168,38 @@ fn zero_grad_clears_every_group() { opt.zero_grad(); assert!(a.grad().is_none() && b.grad().is_none()); } + +#[test] +fn step_skips_a_parameter_without_a_gradient() { + let a = leaf(&[1.0, 2.0]); + let b = leaf(&[3.0, 4.0]); + // Only `a` receives a gradient. + a.value().sum(&[]).unwrap().backward().unwrap(); + let mut opt = Sgd::new(vec![a.clone(), b.clone()], 0.1); + opt.step().unwrap(); // must not fail the whole step over b + assert_ne!(a.value().to_vec_f32().unwrap(), vec![1.0, 2.0], "a updated"); + assert_eq!( + b.value().to_vec_f32().unwrap(), + vec![3.0, 4.0], + "b untouched" + ); +} + +#[test] +fn optimizer_hyperparameter_builders_apply() { + let p = leaf(&[1.0, 2.0]); + backward_ones(std::slice::from_ref(&p)); + let mut adam = Adam::new(vec![p.clone()], 0.1) + .with_betas(0.5, 0.9) + .with_eps(1e-3); + adam.step().unwrap(); + assert_ne!(p.value().to_vec_f32().unwrap(), vec![1.0, 2.0]); + + let q = leaf(&[1.0]); + backward_ones(std::slice::from_ref(&q)); + let mut rms = RmsProp::new(vec![q.clone()], 0.1) + .with_alpha(0.9) + .with_eps(1e-5); + rms.step().unwrap(); + assert_ne!(q.value().to_vec_f32().unwrap(), vec![1.0]); +} diff --git a/crates/oxmera-runtime/Cargo.toml b/crates/oxmera-runtime/Cargo.toml index 3dda5eb..f0ae33c 100644 --- a/crates/oxmera-runtime/Cargo.toml +++ b/crates/oxmera-runtime/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [features] # On by default, so nothing changes for anyone who does not ask. diff --git a/crates/oxmera-runtime/src/lib.rs b/crates/oxmera-runtime/src/lib.rs index 2a819c6..c9866d4 100644 --- a/crates/oxmera-runtime/src/lib.rs +++ b/crates/oxmera-runtime/src/lib.rs @@ -1,20 +1,18 @@ //! Runtime concerns: backend availability, explicit initialization, and //! the inference (`no_grad`) context. //! -//! Backends self-register at load time when linked; this crate links the -//! CPU backend unconditionally, the Metal backend on macOS, and the CUDA -//! backend when the default `cuda` feature is on — so depending on -//! `oxmera-runtime` (or the `oxmera` umbrella) guarantees a working -//! default device set. [`init`] exists for contexts that want the -//! registration to be explicit and checkable. +//! Backends are registered explicitly, not before `main`: [`init`] +//! registers the CPU backend unconditionally, the Metal backend on macOS, +//! and the CUDA backend when the default `cuda` feature is on, then +//! returns the available device set. Call it once before using a non-CPU +//! device; the CPU backend also registers itself on first use. The `oxmera` +//! umbrella and the CLI call `init` for you. //! -//! Turning `cuda` off removes `cudarc`, `libloading` and the `ctor`/`dtor` -//! pair from the graph, the shipped PTX from the binary, and a pre-`main` -//! constructor that `dlopen`s `libcuda` from the process. What it does not -//! change is the *type*: [`oxmera_core::Device::Cuda`] still exists and -//! still resolves to a typed error at run time, so a caller that names the -//! device compiles either way and finds out the same way it would on a -//! machine with no NVIDIA card. +//! Turning `cuda` off removes `cudarc`, `libloading` and the shipped PTX +//! from the binary. What it does not change is the *type*: +//! [`oxmera_core::Device::Cuda`] still exists and still resolves to a typed +//! error at run time, so a caller that names the device compiles either way +//! and finds out the same way it would on a machine with no NVIDIA card. #![forbid(unsafe_code)] #![warn(missing_docs)] @@ -24,11 +22,12 @@ use oxmera_core::{Device, Result}; pub use oxmera_tensor::autograd::{NoGradGuard, no_grad}; pub use oxmera_tensor::backend::{Backend, backend_for, register_backend, registered_devices}; -/// Ensure the default backends for this platform are registered, and -/// report the devices available. +/// Register the default backends for this platform and report the devices +/// available. /// -/// Load-time constructors normally make this unnecessary; calling it is -/// harmless and returns the registered device list either way. +/// Call this once before using a non-CPU device. It is idempotent, so +/// calling it again — or when only the CPU is needed — is harmless and +/// returns the registered device list either way. pub fn init() -> Vec { oxmera_cpu::register(); #[cfg(target_os = "macos")] diff --git a/crates/oxmera-tensor/Cargo.toml b/crates/oxmera-tensor/Cargo.toml index 05607ea..b127fd3 100644 --- a/crates/oxmera-tensor/Cargo.toml +++ b/crates/oxmera-tensor/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [dependencies] oxmera-core.workspace = true diff --git a/crates/oxmera-tensor/src/backend.rs b/crates/oxmera-tensor/src/backend.rs index b724e6a..194dfcf 100644 --- a/crates/oxmera-tensor/src/backend.rs +++ b/crates/oxmera-tensor/src/backend.rs @@ -220,6 +220,7 @@ impl ReduceOp { /// are 0 for a broadcast operand, so no backend has to materialize the /// broadcast — batch `i` of `a` starts at `i * a_batch_stride`. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct MatmulPlan { /// Output batch count (1 for a rank-2 result). pub batch: usize, @@ -313,6 +314,7 @@ pub fn plan_matmul(a: &Shape, b: &Shape) -> Result { /// ([`Backend::adam_step`]). Tensors are `f32` on the backend's device; /// `m`/`v` are `None` on the first step. #[derive(Debug, Clone, Copy)] +#[non_exhaustive] pub struct AdamStep<'a> { /// Current parameter value. pub param: &'a Tensor, @@ -341,6 +343,43 @@ pub struct AdamStep<'a> { pub bias_correction2: f32, } +impl<'a> AdamStep<'a> { + /// Assemble a fused-step descriptor. Arguments are in declaration + /// order; `m`/`v` are `None` before the first step. A constructor + /// because the struct is `#[non_exhaustive]` and is built in the optim + /// crate. + #[allow(clippy::too_many_arguments)] + pub fn new( + param: &'a Tensor, + grad: &'a Tensor, + m: Option<&'a Tensor>, + v: Option<&'a Tensor>, + lr: f32, + beta1: f32, + beta2: f32, + eps: f32, + weight_decay: f32, + decoupled: bool, + bias_correction1: f32, + bias_correction2: f32, + ) -> Self { + Self { + param, + grad, + m, + v, + lr, + beta1, + beta2, + eps, + weight_decay, + decoupled, + bias_correction1, + bias_correction2, + } + } +} + /// A complete backend: every primitive the tensor method layer dispatches. /// /// Composite operations (mean, softmax, losses, convolution, …) are built diff --git a/crates/oxmera-tensor/src/cpu.rs b/crates/oxmera-tensor/src/cpu.rs index 8a0631a..da3b010 100644 --- a/crates/oxmera-tensor/src/cpu.rs +++ b/crates/oxmera-tensor/src/cpu.rs @@ -315,14 +315,20 @@ impl Backend for CpuBackend { let mut best_i = 0i64; for j in 0..n { let v = src[(offset + j as isize * s) as usize]; + if v.is_nan() { + return Err(Error::InvalidArgument { + op: "argmax", + detail: "input contains NaN; the maximum is undefined".into(), + }); + } if v > best { best = v; best_i = j as i64; } } - best_i + Ok(best_i) }) - .collect(); + .collect::>>()?; Tensor::from_vec_i64(out, Shape::new(out_dims)) } @@ -462,11 +468,11 @@ impl Backend for CpuBackend { // The Jacobi routine works in f64 internally; an f64 input keeps // its result in f64. if a.dtype() == DType::F64 { - let data: Vec = a.to_vec_f64()?.iter().map(|&x| x as f32).collect(); - let (w, v) = crate::cpu_linalg::eigh(&data, batch, n); + let data = a.to_vec_f64()?; + let (w, v) = crate::cpu_linalg::eigh_f64(&data, batch, n); return Ok(( - Tensor::from_vec_f64(w.iter().map(|&x| x as f64).collect(), Shape::new(wshape))?, - Tensor::from_vec_f64(v.iter().map(|&x| x as f64).collect(), a.shape().clone())?, + Tensor::from_vec_f64(w, Shape::new(wshape))?, + Tensor::from_vec_f64(v, a.shape().clone())?, )); } f32_input(a, "eigh")?; diff --git a/crates/oxmera-tensor/src/cpu_f64.rs b/crates/oxmera-tensor/src/cpu_f64.rs index 67e1e1d..fe8258d 100644 --- a/crates/oxmera-tensor/src/cpu_f64.rs +++ b/crates/oxmera-tensor/src/cpu_f64.rs @@ -259,14 +259,20 @@ pub(crate) fn argmax(a: &Tensor, dim: usize, keepdim: bool) -> Result { let mut best_i = 0i64; for j in 0..n { let v = src[(offset + j as isize * s) as usize]; + if v.is_nan() { + return Err(Error::InvalidArgument { + op: "argmax", + detail: "input contains NaN; the maximum is undefined".into(), + }); + } if v > best { best = v; best_i = j as i64; } } - best_i + Ok(best_i) }) - .collect(); + .collect::>>()?; Tensor::from_vec_i64(out, Shape::new(out_dims)) } diff --git a/crates/oxmera-tensor/src/cpu_linalg.rs b/crates/oxmera-tensor/src/cpu_linalg.rs index cdcfb6b..a467c85 100644 --- a/crates/oxmera-tensor/src/cpu_linalg.rs +++ b/crates/oxmera-tensor/src/cpu_linalg.rs @@ -119,17 +119,17 @@ pub fn cholesky_backward(l: &[f32], grad_l: &[f32], batch: usize, n: usize) -> V /// method: eigenvalues ascending (`[batch, n]`) and orthonormal /// eigenvectors as columns (`[batch, n, n]`, `A V = V Λ`). Reads the full /// matrix and symmetrizes it first. -pub fn eigh(a: &[f32], batch: usize, n: usize) -> (Vec, Vec) { +pub fn eigh_f64(a: &[f64], batch: usize, n: usize) -> (Vec, Vec) { let nn = n * n; - let mut values = vec![0.0f32; batch * n]; - let mut vectors = vec![0.0f32; batch * nn]; + let mut values = vec![0.0f64; batch * n]; + let mut vectors = vec![0.0f64; batch * nn]; let mut m = vec![0.0f64; nn]; let mut v = vec![0.0f64; nn]; for b in 0..batch { let src = &a[b * nn..(b + 1) * nn]; for i in 0..n { for j in 0..n { - m[i * n + j] = 0.5 * (src[i * n + j] as f64 + src[j * n + i] as f64); + m[i * n + j] = 0.5 * (src[i * n + j] + src[j * n + i]); v[i * n + j] = if i == j { 1.0 } else { 0.0 }; } } @@ -181,15 +181,28 @@ pub fn eigh(a: &[f32], batch: usize, n: usize) -> (Vec, Vec) { let mut order: Vec = (0..n).collect(); order.sort_by(|&i, &j| m[i * n + i].total_cmp(&m[j * n + j])); for (slot, &i) in order.iter().enumerate() { - values[b * n + slot] = m[i * n + i] as f32; + values[b * n + slot] = m[i * n + i]; for k in 0..n { - vectors[b * nn + k * n + slot] = v[k * n + i] as f32; + vectors[b * nn + k * n + slot] = v[k * n + i]; } } } (values, vectors) } +/// f32 convenience wrapper over [`eigh_f64`]: widens the input to `f64`, +/// runs the same decomposition, then narrows the result. Behaves exactly +/// like the former f32-in/f32-out routine — the Jacobi sweeps were always +/// carried in f64. +pub fn eigh(a: &[f32], batch: usize, n: usize) -> (Vec, Vec) { + let a64: Vec = a.iter().map(|&x| x as f64).collect(); + let (w, v) = eigh_f64(&a64, batch, n); + ( + w.into_iter().map(|x| x as f32).collect(), + v.into_iter().map(|x| x as f32).collect(), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/oxmera-tensor/src/cpu_matmul.rs b/crates/oxmera-tensor/src/cpu_matmul.rs index 0163a64..723f7ef 100644 --- a/crates/oxmera-tensor/src/cpu_matmul.rs +++ b/crates/oxmera-tensor/src/cpu_matmul.rs @@ -22,8 +22,10 @@ pub fn matmul(a: &Tensor, b: &Tensor) -> Result { b_batch_stride, out_shape, } = plan; - let av = a.to_vec_f32()?; - let bv = b.to_vec_f32()?; + let mut a_owned = None; + let mut b_owned = None; + let av = operand_slice(a, &mut a_owned)?; + let bv = operand_slice(b, &mut b_owned)?; let mut out = vec![0.0f32; batch * m * n]; if batch == 1 { gemm(&av[..m * k], &bv[..k * n], &mut out, m, k, n); @@ -39,6 +41,19 @@ pub fn matmul(a: &Tensor, b: &Tensor) -> Result { Tensor::from_vec_f32(out, out_shape) } +/// The operand's elements in logical row-major order: borrowed straight +/// from storage when the tensor is already contiguous (the common case), +/// and only materialized — copied — when a strided or broadcast view has +/// to be gathered first. Pre-0.5 every operand was copied unconditionally. +fn operand_slice<'a>(t: &'a Tensor, owned: &'a mut Option>) -> Result<&'a [f32]> { + if t.numel() > 0 && t.layout().is_contiguous() { + let full = t.storage().cpu()?.f32s()?; + let off = t.layout().offset; + return Ok(&full[off..off + t.numel()]); + } + Ok(owned.insert(t.to_vec_f32()?).as_slice()) +} + fn gemm(a: &[f32], b: &[f32], c: &mut [f32], m: usize, k: usize, n: usize) { if m * n * k >= 32 * 1024 { // Four output rows per task: each loaded b[kk, j] feeds four diff --git a/crates/oxmera-tensor/src/tensor.rs b/crates/oxmera-tensor/src/tensor.rs index 1082f86..f9fac45 100644 --- a/crates/oxmera-tensor/src/tensor.rs +++ b/crates/oxmera-tensor/src/tensor.rs @@ -18,13 +18,28 @@ use crate::storage::{CpuStorage, Storage}; /// refcount, never the data. View operations (`reshape`, `permute`, /// `narrow`, …) produce new tensors over the same storage whenever the /// layout arithmetic allows it. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Tensor { storage: Arc, layout: Layout, autograd: Option>, } +impl std::fmt::Debug for Tensor { + /// Prints only the tensor's metadata — shape, dtype, device and + /// whether it tracks gradients. Never the storage contents: a tensor + /// can hold gigabytes, and a derived `Debug` dumped all of it into + /// every log line and panic message. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Tensor") + .field("shape", self.shape()) + .field("dtype", &self.dtype()) + .field("device", &self.device()) + .field("requires_grad", &self.requires_grad()) + .finish() + } +} + impl Tensor { // ---- construction --------------------------------------------------- @@ -32,12 +47,14 @@ impl Tensor { /// /// Errors when the layout addresses elements outside the storage. pub fn from_storage(storage: Arc, layout: Layout) -> Result { - let needed = max_addressed(&layout); - let available = storage_len(&storage); - if needed > available { + let (lo, hi) = addressed_bounds(&layout); + let available = storage_len(&storage) as isize; + if lo < 0 || hi > available { return Err(Error::InvalidArgument { op: "Tensor::from_storage", - detail: format!("layout addresses {needed} elements, storage holds {available}"), + detail: format!( + "layout addresses [{lo}, {hi}) but storage holds {available} elements" + ), }); } Ok(Self { @@ -111,24 +128,55 @@ impl Tensor { } /// A CPU tensor of zeros. + /// + /// # Panics + /// Panics if the shape's element count overflows `usize`. Build the + /// shape from untrusted input through [`Tensor::try_zeros`] for a + /// typed error instead. pub fn zeros(shape: impl Into) -> Self { + Self::try_zeros(shape).expect("shape element count overflows usize") + } + + /// A CPU tensor of zeros, or [`Error::InvalidArgument`] when the + /// shape's element count overflows `usize`. + pub fn try_zeros(shape: impl Into) -> Result { let shape = shape.into(); - let numel = shape.numel(); - Self::from_vec_f32(vec![0.0; numel], shape).expect("lengths match by construction") + let numel = checked_numel(&shape, "Tensor::try_zeros")?; + Self::from_vec_f32(vec![0.0; numel], shape) } /// A CPU tensor of ones. + /// + /// # Panics + /// Panics if the shape's element count overflows `usize`; see + /// [`Tensor::try_ones`]. pub fn ones(shape: impl Into) -> Self { + Self::try_ones(shape).expect("shape element count overflows usize") + } + + /// A CPU tensor of ones, or [`Error::InvalidArgument`] when the + /// shape's element count overflows `usize`. + pub fn try_ones(shape: impl Into) -> Result { let shape = shape.into(); - let numel = shape.numel(); - Self::from_vec_f32(vec![1.0; numel], shape).expect("lengths match by construction") + let numel = checked_numel(&shape, "Tensor::try_ones")?; + Self::from_vec_f32(vec![1.0; numel], shape) } /// A CPU tensor filled with `value`. + /// + /// # Panics + /// Panics if the shape's element count overflows `usize`; see + /// [`Tensor::try_full`]. pub fn full(shape: impl Into, value: f32) -> Self { + Self::try_full(shape, value).expect("shape element count overflows usize") + } + + /// A CPU tensor filled with `value`, or [`Error::InvalidArgument`] + /// when the shape's element count overflows `usize`. + pub fn try_full(shape: impl Into, value: f32) -> Result { let shape = shape.into(); - let numel = shape.numel(); - Self::from_vec_f32(vec![value; numel], shape).expect("lengths match by construction") + let numel = checked_numel(&shape, "Tensor::try_full")?; + Self::from_vec_f32(vec![value; numel], shape) } /// A rank-0 scalar tensor. @@ -331,12 +379,12 @@ impl Tensor { /// A view of `len` elements of dimension `dim` starting at `start`. pub fn narrow(&self, dim: usize, start: usize, len: usize) -> Result { let dims = self.dims(); - if dim >= dims.len() || start + len > dims[dim] { + let end = start.checked_add(len); + if dim >= dims.len() || end.is_none_or(|e| e > dims[dim]) { return Err(Error::InvalidArgument { op: "narrow", detail: format!( - "dim {dim}, range {start}..{} against shape {:?}", - start + len, + "dim {dim}, start {start} len {len} against shape {:?}", self.shape() ), }); @@ -607,17 +655,29 @@ fn storage_len(storage: &Storage) -> usize { } } -fn max_addressed(layout: &Layout) -> usize { +/// The half-open range of storage indices a layout can address, as +/// `(min, max_exclusive)`. Negative strides lower the minimum below the +/// offset, so a valid layout needs `min >= 0` as well as +/// `max_exclusive <= storage length`; the pre-0.5 check accounted for +/// positive strides only and let an underflowing negative-stride layout +/// through to a panic on the first read. +fn addressed_bounds(layout: &Layout) -> (isize, isize) { if layout.shape.numel() == 0 { - return 0; + return (0, 0); } - let mut max = layout.offset as isize; + let mut lo = layout.offset as isize; + let mut hi = layout.offset as isize; for (&d, &s) in layout.shape.dims().iter().zip(layout.strides.values()) { - if d > 1 && s > 0 { - max += (d as isize - 1) * s; + if d > 1 { + let span = (d as isize - 1) * s; + if s >= 0 { + hi += span; + } else { + lo += span; + } } } - (max + 1) as usize + (lo, hi + 1) } /// Broadcast `layout` to `target`, stride 0 on expanded axes. diff --git a/crates/oxmera-tensor/tests/hardening.rs b/crates/oxmera-tensor/tests/hardening.rs new file mode 100644 index 0000000..0d463a5 --- /dev/null +++ b/crates/oxmera-tensor/tests/hardening.rs @@ -0,0 +1,95 @@ +//! Regression tests for the 0.5 construction/shape hardening: typed errors +//! where the pre-0.5 code panicked or wrapped, and a `Debug` that prints +//! metadata instead of the whole storage buffer. + +use oxmera_core::{Error, Layout, Shape, Strides}; +use oxmera_tensor::Tensor; + +const HUGE: [usize; 2] = [1usize << 32, 1usize << 32]; + +#[test] +fn from_storage_rejects_a_negative_stride_that_underflows() { + // offset 1, stride -1, len 3 addresses indices 1, 0, -1 — the last is + // before the buffer. Pre-0.5 the bounds check saw only the offset and + // let it through to an out-of-bounds read. A valid flip (offset 2, + // covered by views.rs::negative_strides_are_representable) still works. + let t = Tensor::from_vec_f32(vec![1.0, 2.0, 3.0], Shape::from([3])).unwrap(); + let bad = Layout { + shape: Shape::from([3]), + strides: Strides::new(vec![-1]), + offset: 1, + }; + let e = Tensor::from_storage(t.storage().clone(), bad).unwrap_err(); + assert!(matches!(e, Error::InvalidArgument { .. }), "{e}"); + // The valid flip (offset 2) is accepted and reads in reverse. + let ok = Tensor::from_storage( + t.storage().clone(), + Layout { + shape: Shape::from([3]), + strides: Strides::new(vec![-1]), + offset: 2, + }, + ) + .unwrap(); + assert_eq!(ok.to_vec_f32().unwrap(), vec![3.0, 2.0, 1.0]); +} + +#[test] +fn narrow_reports_an_overflowing_range_instead_of_panicking() { + let t = Tensor::from_vec_f32(vec![1.0, 2.0, 3.0], Shape::from([3])).unwrap(); + // start + len overflows usize: pre-0.5 this panicked in the bounds check. + let e = t.narrow(0, 2, usize::MAX).unwrap_err(); + assert!(matches!(e, Error::InvalidArgument { .. }), "{e}"); +} + +#[test] +fn try_constructors_return_a_typed_error_on_an_overflowing_shape() { + assert!(Tensor::try_zeros(HUGE).is_err()); + assert!(Tensor::try_ones(HUGE).is_err()); + assert!(Tensor::try_full(HUGE, 1.0).is_err()); + assert_eq!(Tensor::try_zeros([2, 3]).unwrap().numel(), 6); + assert_eq!( + Tensor::try_full([2, 2], 7.0).unwrap().to_vec_f32().unwrap(), + vec![7.0; 4] + ); +} + +#[test] +fn debug_prints_metadata_not_the_buffer() { + let t = Tensor::zeros([128, 128]); + let s = format!("{t:?}"); + assert!(s.contains("Tensor"), "{s}"); + assert!(s.contains("shape"), "{s}"); + // 16384 elements; the old derived Debug dumped every one of them. + assert!(s.len() < 160, "Debug output is {} bytes: {s}", s.len()); + assert!( + !s.contains("0.0, 0.0, 0.0"), + "must not dump the storage: {s}" + ); +} + +#[test] +fn argmax_reports_nan_instead_of_hiding_it() { + let t = Tensor::from_slice(&[1.0, f32::NAN, 2.0], [3]).unwrap(); + assert!( + t.argmax(0, false).is_err(), + "NaN input must be a typed error" + ); + let ok = Tensor::from_slice(&[1.0, 3.0, 2.0], [3]).unwrap(); + assert_eq!(ok.argmax(0, false).unwrap().to_vec_i64().unwrap(), vec![1]); +} + +#[test] +fn matmul_agrees_for_contiguous_and_strided_operands() { + // b = [[1,0],[0,1],[1,1]] + let b = Tensor::from_slice(&[1.0, 0.0, 0.0, 1.0, 1.0, 1.0], [3, 2]).unwrap(); + // Contiguous a = [[1,2,3],[4,5,6]] (borrow path). + let a = Tensor::from_slice(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0], [2, 3]).unwrap(); + let want = vec![4.0, 5.0, 10.0, 11.0]; + assert_eq!(a.matmul(&b).unwrap().to_vec_f32().unwrap(), want); + // Same logical a as a non-contiguous view: eᵀ where e = [[1,4],[2,5],[3,6]] + // (gather path). Both must give the same product. + let e = Tensor::from_slice(&[1.0, 4.0, 2.0, 5.0, 3.0, 6.0], [3, 2]).unwrap(); + let a_view = e.t().unwrap(); + assert_eq!(a_view.matmul(&b).unwrap().to_vec_f32().unwrap(), want); +} diff --git a/crates/oxmera-tensor/tests/linalg.rs b/crates/oxmera-tensor/tests/linalg.rs index 43d841a..0e71a36 100644 --- a/crates/oxmera-tensor/tests/linalg.rs +++ b/crates/oxmera-tensor/tests/linalg.rs @@ -191,3 +191,25 @@ fn eigh_handles_an_indefinite_matrix_and_a_diagonal_one() { assert!((vv[row * 3 + col].abs() - 1.0).abs() < 1e-6, "{vv:?}"); } } + +#[test] +fn eigh_preserves_f64_precision_instead_of_rounding_to_f32() { + // Two eigenvalues that differ below f32 resolution near 1.0 (f32 eps + // is ~1.2e-7). Pre-0.5 the f64 path rounded the input to f32 first and + // collapsed them onto the same value. + let eps = 1e-10f64; + let a = oxmera_tensor::Tensor::from_vec_f64(vec![1.0 + eps, 0.0, 0.0, 1.0], [2, 2]).unwrap(); + let (w, _v) = a.eigh().unwrap(); + assert_eq!( + w.dtype(), + oxmera_core::DType::F64, + "an f64 input keeps an f64 spectrum" + ); + let wv = w.to_vec_f64().unwrap(); + assert!((wv[0] - 1.0).abs() < 1e-13, "{wv:?}"); + assert!((wv[1] - (1.0 + eps)).abs() < 1e-13, "{wv:?}"); + assert!( + wv[1] - wv[0] > 1e-11, + "the two eigenvalues stay distinct: {wv:?}" + ); +} diff --git a/crates/oxmera/Cargo.toml b/crates/oxmera/Cargo.toml index 5ce5720..afdae0f 100644 --- a/crates/oxmera/Cargo.toml +++ b/crates/oxmera/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true readme.workspace = true repository.workspace = true authors.workspace = true +keywords.workspace = true +categories.workspace = true [features] # Forwarded to oxmera-runtime, which owns the registration. On by default. diff --git a/crates/oxmera/examples/train_mnist.rs b/crates/oxmera/examples/train_mnist.rs index 16d8085..e1a09f7 100644 --- a/crates/oxmera/examples/train_mnist.rs +++ b/crates/oxmera/examples/train_mnist.rs @@ -15,6 +15,7 @@ use oxmera::nn::{CrossEntropyLoss, Linear, Module, Param, Sequential}; use oxmera::optim::{Adam, Optimizer}; use oxmera::{Device, Result, Shape, Tensor}; +#[derive(Debug)] struct Activation; impl Module for Activation { diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 81772e1..bf17951 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -110,9 +110,14 @@ numbers come from re-runnable commands. O(index count) per element; it is meant for the narrow VJP, `cat`/`pad` and embedding-sized index lists, not for scattering millions of rows. - Backend registration is process-global and idempotent: a device that has - a backend keeps it (a backend now carries dispatch state). Metal and - CUDA register at load time; if a linker strips the registration (unusual - link setups), call `oxmera::init()` explicitly. + a backend keeps it (a backend now carries dispatch state). The CPU + registers on first use; Metal and CUDA are registered explicitly by + `oxmera::init()`, which the CLI and examples call — as of 0.5 there is no + pre-main constructor. +- Only device index 0 is registered. `Device::Metal { index }` or + `Device::Cuda { index }` with `index > 0` resolves to a typed + `BackendUnavailable` error rather than a silent fallback; multi-GPU is + not yet supported. - `Dropout` masks are generated on the CPU per forward pass. ## Terminal surfaces diff --git a/docs/STABILITY.md b/docs/STABILITY.md new file mode 100644 index 0000000..7bb10c2 --- /dev/null +++ b/docs/STABILITY.md @@ -0,0 +1,81 @@ +# Stability + +What oxmera 0.5 promises, what may still change before 1.0, and how the +promise is enforced. This is the contract; the running record of what +actually changed is [CHANGELOG.md](../CHANGELOG.md). + +## SemVer, pre-1.0 + +oxmera follows [Semantic Versioning](https://semver.org). While the version +is `0.x`, the pre-1.0 rules apply: + +- A **minor** bump (`0.4 → 0.5`) may contain breaking changes. +- A **patch** bump (`0.5.0 → 0.5.1`) may not: it is bug fixes and additive, + backward-compatible changes only. + +Every crate in the workspace shares one version and is released together, +so "the version" is unambiguous. + +This is enforced, not just stated: the `semver-checks` CI job runs +[`cargo-semver-checks`](https://crates.io/crates/cargo-semver-checks) +against the last release on crates.io on every pull request. A breaking +change in a patch fails CI; a breaking change under a minor bump is +allowed and expected. + +Breaking changes are also called out by hand: each one is a +`**Breaking.**` bullet in the CHANGELOG for the release that ships it, with +the one-line migration. + +## What 0.5 stabilizes + +The surfaces a typical program touches are meant to be stable for the rest +of the `0.5.x` series: + +- `Tensor` and its construction, view, elementwise, reduction, matmul and + linear-algebra methods, and the `oxmera_core::Error` taxonomy. +- The `nn` layers (`Linear`, `Conv2d`, `Embedding`, `LayerNorm`, + `BatchNorm2d`, `Dropout`, `Sequential`), the `Module` trait, losses, and + safetensors save/load by parameter name. +- The optimizers (`Sgd`, `Adam`, `AdamW`, `RmsProp`) and `ParamGroup`. +- The `oxmera` umbrella crate's re-exports and `oxmera::init()`. +- The `oxmera` CLI's `doctor` and `train` surfaces, including + `doctor --json`. + +Several public types are `#[non_exhaustive]`, so adding a variant or field +to them is *not* a breaking change: `Error`, `Device`, the op enums +(`UnaryOp`, `BinaryOp`, `ReduceOp`), `MatmulPlan`, `AdamStep`, and +`ParamGroup`. Match them with a `_ =>` arm and construct them through their +constructors. + +## What may still change before 1.0 + +- **Backends.** The `Backend` trait and the `AdamStep`/`MatmulPlan` + plumbing are how the tensor layer reaches a device. They are public so an + out-of-tree backend is possible, but they are the surface most likely to + grow methods before 1.0. +- **Multi-GPU.** Only device index 0 is registered today; `Device::Cuda`/ + `Device::Metal` with `index > 0` is a typed `BackendUnavailable`. How + multiple devices are selected and registered is not settled + ([docs/LIMITATIONS.md](LIMITATIONS.md)). +- **The seed convention.** Layer constructors take a positional `u64` + seed; whether that becomes an explicit RNG handle is open. + +## MSRV + +The minimum supported Rust version is **1.88**, declared as +`rust-version` and checked in CI against the committed `Cargo.lock`. Raising +the MSRV is a change worth a CHANGELOG note but is not by itself a breaking +change under these pre-1.0 rules; it will not happen in a patch release. + +## Supported versions + +Per [SECURITY.md](../SECURITY.md), only the latest published version of +each crate is supported. Fixes land on `main` and ship in the next release +rather than being backported. + +## Toward 1.0 + +1.0 is reached when the surfaces above have gone a full release cycle with +no breaking change, the backend trait is settled, and the multi-GPU and +seed questions are decided one way or the other. The roadmap tracks +progress: [ROADMAP.md](../ROADMAP.md).