diff --git a/.dockerignore b/.dockerignore index b6c5be3..6eba23e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,10 @@ # Build artifacts target/ +# Toolchain is explicitly pinned in the build RUN; do not override +# the image's default toolchain at runtime. +rust-toolchain.toml + # Git metadata **/.git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2a191..ecb69ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,6 @@ jobs: - name: Cache Cargo + target uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - shared-key: "cpu-ci-v1" cache-on-failure: true - name: Check formatting @@ -82,29 +81,37 @@ jobs: verbose: true msrv: - name: MSRV (1.87) + name: MSRV (1.97.1) runs-on: ubuntu-latest timeout-minutes: 20 + # Override repo `rust-toolchain.toml` (channel=stable) so this job truly + # exercises MSRV, not latest stable. + env: + RUSTUP_TOOLCHAIN: "1.97.1" steps: # actions/checkout@v7.0.0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - # dtolnay/rust-toolchain — pin to MSRV - - name: Install Rust MSRV (1.87) + # dtolnay/rust-toolchain — pin to MSRV (matches Cargo.toml rust-version) + - name: Install Rust MSRV (1.97.1) uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 with: - toolchain: "1.87" + toolchain: "1.97.1" components: clippy, rustfmt # Swatinem/rust-cache@v2 - name: Cache Cargo + target uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: - shared-key: "msrv-v1" cache-on-failure: true + - name: Confirm MSRV toolchain is active + run: | + rustc --version | grep -F '1.97.1 ' + cargo --version + - name: Check formatting run: cargo fmt --check diff --git a/.gitignore b/.gitignore index e171c09..d41b3b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,48 @@ -# Build artifacts -/target -**/*.rs.bk +# AI Tool Local/Ephemeral (no clutter) +.kilo/worktrees/ +.kilo/*.json +.devin/cache/ +.mimocode/auth.json +.mimocode/plans/ +.worktrees/ +.swarm/ +.beads/ +.cline/ +.claude/ +.codex/ +.opencode/ +docs/superpowers/ -# Cargo packaging artifacts (cargo package/publish creates these) -/target/package/ +# Compiled output +/target/ -# Backup files -/Cargo.lock.bak +# IDE / editor +.idea/ +.vscode/ +*.swp +*~ +.cursor/ +.cursorignore +.zed/ -# IDE/editor -.mimocode/ +# Standard dev + your data +node_modules/ +dist/ +build/ +*.log +lcov.info +.env* +*.env +__pycache__/ +.cache/ +.DS_Store +neuromorphic_data/ +remotes.txt -# Nested crate duplicates (from cargo package or accidental clones) -/engram-parser/ +# Negations: Force-commit the good stuff +!.kilo/skills/** +!.kilo/tui.jsonc +!.devin/blueprint.yaml +!.mimocode/mimocode.jsonc +!.mimocode/AGENTS.md +!.kilocodeignore diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc6578..a520cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,32 @@ All notable changes to this project are documented in this file. ## [Unreleased] +## [0.2.0] - 2026-08-02 + +### Added + +- GGUF tensor **wire-type** layouts (IQ/Q codes + packed `byte_len` only — **no dequant**). +- `file_type` metadata fallback for quantization label when general quant keys are absent. +- Path-gated **T1** real-GGUF pilots (`tests/real_gguf.rs`) with optional + `ENGRAM_EXPECT_MOE` / `ENGRAM_MOE_SAMPLES`. +- `examples/inspect_gguf` for human inventory of on-disk GGUF files. +- Quality-gate docs in `REVIEW.md` (T0/T1/T2; large MoE RAM budget). +- Local `rust-toolchain.toml` (`channel = "stable"`). +- **GitHub Actions CI** — `fmt`, `clippy`, `build`, and `test` on push/PR to `main`. +- **Boundary documentation** — README scope/ownership section linked to Linear LIM-9. + ### Changed +- **Version:** `0.1.0` → **`0.2.0`** (canonical GGUF v3 + MoE extract ship for #7). +- **MSRV:** bumped from 1.87 to **1.97.1** (`Cargo.toml` `rust-version`, CI `msrv` job, Docker `RUST_VERSION`). CI `validate` continues to use latest **stable**. - **License:** switched from GPL-3.0-or-later to dual MIT/Apache-2.0 for maximum adoption and ecosystem health. - **Tensor API:** replaced unsafe `as_f32_slice` / `as_u16_bits` with safe `read_f32_values` / `read_u16_values` (allocating `Vec` instead of borrowed slices). +- **`GgufMetadata::quantization()`** returns `String` (owned) so `general.file_type` fallback is derived at call time from the live map. Callers that match on the label should use `.as_str()` or `==`. +- Wire type **31** treated as historical **Q4_0_4_4** (not IQ3_M). -### Added +### Fixed -- **GitHub Actions CI** — `fmt`, `clippy`, `build`, and `test` on push/PR to `main`. -- **Boundary documentation** — README scope/ownership section linked to Linear LIM-9. +- Wire-type 31 labeling aligned with corinth-canal’s GGUF/`ggml_type` table (metadata only). ## [0.1.0] - 2026-06-01 diff --git a/Cargo.lock b/Cargo.lock index 5769b69..54f89ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "engram-parser" -version = "0.1.0" +version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 82e6d28..8b4c4d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "engram-parser" -version = "0.1.0" +version = "0.2.0" edition = "2024" -rust-version = "1.87" +rust-version = "1.97.1" description = "Pure-Rust, zero-dependency GGUF deserializer and Mixture-of-Experts per-expert weight extractor. Returns raw byte buffers with shape metadata; performs no neural-network math." license = "MIT OR Apache-2.0" authors = ["Raul Montoya Cardenas "] diff --git a/Dockerfile b/Dockerfile index ad06eaa..12bce03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,12 @@ # # See .github/workflows/docker-build.yml and issue #9 for CI (GHCR on main). -ARG RUST_VERSION=1.87 +ARG RUST_VERSION=1.97.1 FROM rust:${RUST_VERSION}-slim +ARG RUST_VERSION + RUN useradd -m -u 10001 appuser WORKDIR /app @@ -29,8 +31,11 @@ COPY Cargo.toml Cargo.lock ./ # Copy source COPY . . -# Build and test the crate (zero external deps, no system packages needed) -RUN cargo build --release --all-features && \ +# Build and test the crate with the pinned toolchain. +# RUSTUP_TOOLCHAIN is scoped to this RUN so it does not leak into the final image. +RUN export RUSTUP_TOOLCHAIN=${RUST_VERSION} && \ + rustc --version && cargo --version && \ + cargo build --release --all-features && \ cargo test --release --all-features RUN chown -R appuser:appuser /app diff --git a/README.md b/README.md index 8a2d9f4..3991bcb 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ for the full Rust runtime/deployment boundary matrix and [issue #4](https://github.com/Limen-Neural/engram-parser/issues/4) for this repo's tracking issue. + +## Origin / modularization (#7) + +GGUF layout parsing and MoE expert **raw byte** extraction were expanded +using one-way inspiration from the experimental +[`rmems/corinth-canal`](https://github.com/rmems/corinth-canal) reference +implementation (**no** runtime dependency on corinth-canal). + +- Tracking: [engram-parser#7](https://github.com/Limen-Neural/engram-parser/issues/7) +- Corinth migration companion: [corinth-canal#115](https://github.com/rmems/corinth-canal/issues/115) +- Cortex coordination: [cortex-tensor#8](https://github.com/Limen-Neural/cortex-tensor/issues/8) +- Linear: [LIM-123](https://linear.app/rpd-34/issue/LIM-123), [LIM-88](https://linear.app/rpd-34/issue/LIM-88) + +**GGUF wire types vs “GGML”:** GGUF stores each tensor’s dtype as a +`ggml_type` integer. This crate only maps those codes to labels and packed +**byte sizes** so payloads and MoE slices stay in-range. It does **not** +implement GGML dequant, kernels, or the ggml runtime (that stays +downstream / corinth-canal reference). Wire-type labels follow the +corinth-canal table (e.g. type **31** is historical `Q4_0_4_4`, not the +HuggingFace “IQ3_M” preset). MoE extraction remains free functions +(`list_experts` / `extract_expert`); traits are out of scope for #7. + ## Quick start ```rust @@ -81,16 +103,28 @@ for (block, expert) in list_experts(&layout) { ## Supported dtypes -Layout-aware parsing: `F32`, `F16`, `BF16` (GGML 30), `Q8_0`, `Q4_K`, -`Q5_K`, `Q6_K`, `IQ3_S` (opaque), plus a `DType::Other(u32)` catch-all. +Layout-aware parsing (**packed byte sizes only — no dequant, no GGML +compute**) for GGUF wire types: `F32`, `F16`, `BF16` (30), `F64`, +`I8`–`I64`, `Q4_0`/`Q4_1`, `Q5_0`/`Q5_1`, `Q8_0`/`Q8_1`, K-quants +`Q2_K`/`Q3_K`/`Q4_K`/`Q5_K`/`Q6_K`/`Q8_K` (no `Q7_K`), and IQ packed +layouts `IQ2_XXS`/`IQ2_XS`/`IQ2_S`, `IQ3_XXS`/`IQ3_S`, `IQ1_S`/`IQ1_M`, +`IQ4_NL`/`IQ4_XS`. Remaining codes use `DType::Other(u32)` (including +historical **wire type 31 = `Q4_0_4_4`**, which is **not** HF “IQ3_M” +and fails closed without a modeled size). + Only `F32` and `F16` have in-crate numeric accessors; everything else -is returned as raw `Vec`. +is returned as raw `Vec`. Unknown layouts fail closed at parse time +when element count cannot be converted to a byte length. + +`GgufMetadata::quantization()` prefers `general.quantization_type`, then +falls back to `general.file_type` (`0→F32`, `1→F16`, else `GGUF(n)`). ## Public API `load_gguf`, `parse_bytes`, `GgufLayout`, `GgufMetadata`, `Tensor`, -`DType`, `extract_expert`, `list_experts`, `MoeExpertWeights`, -`RawTensor`, `ParserError`, `Result`. +`DType`, `ggml_type_label`, `extract_expert`, `list_experts`, +`MoeExpertWeights`, `RawTensor`, `ParserError`, `Result`, plus public +`GGML_TYPE_*` and `GGUF_VALUE_TYPE_*` constants. ## Ecosystem / Sibling parsers (LIM-9) @@ -122,8 +156,18 @@ cargo test --all-features # Coverage (local; requires cargo-llvm-cov: cargo install cargo-llvm-cov) cargo llvm-cov --all-targets --all-features --locked --lcov --output-path lcov.info + +# Real GGUF pilots (xai-dissect style; not CI — needs weights on disk) +# Full-file load (no mmap): one ENGRAM_GGUF per process; free RAM ≥ file size + margin +ENGRAM_GGUF=~/.models/gguf/.../model.gguf cargo test --test real_gguf -- --ignored --nocapture +# Large MoE: ENGRAM_EXPECT_MOE=1 ENGRAM_MOE_SAMPLES=3 (see REVIEW.md T1 large MoE) +cargo run --example inspect_gguf -- ~/.models/gguf/.../model.gguf ``` +GPU experiments on real models live in **`~/rmems/blackwell-kernel-lab`** +(and production kernels in `myelin-accelerator`) — not as deps of this crate. +See [REVIEW.md](REVIEW.md) for the T0/T1/T2 quality-gate layout. + ## Docker ```bash @@ -151,13 +195,16 @@ Cross-reference: #11, #8, #9, #7, #5, LIM-9. ## MSRV (Minimum Supported Rust Version) -**MSRV: 1.87** +**MSRV: 1.97.1** (current stable floor as of 2026-08) -This crate guarantees compatibility with Rust 1.87 and later. The MSRV is: +This crate guarantees compatibility with Rust 1.97.1 and later. The MSRV is: -- Declared in `Cargo.toml` via `rust-version = "1.87"` +- Declared in `Cargo.toml` via `rust-version = "1.97.1"` - Tested in CI on every PR and push (see `msrv` job in `.github/workflows/ci.yml`) -- Verified alongside stable Rust to ensure both toolchains pass all checks +- Verified alongside **stable** (always latest) in the `validate` job so both toolchains pass + +Local development defaults to the toolchain in [`rust-toolchain.toml`](rust-toolchain.toml) +(`stable` + `rustfmt` / `clippy`). **MSRV Policy:** - MSRV bumps will be documented in release notes diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..9770777 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,376 @@ +# Review: quality gate (engram-parser) + +Local commands that must pass before merge or PR for this crate. +Aligned with `.github/workflows/ci.yml` and the README Development section. + +**Charter:** pure-Rust, **zero-dependency** GGUF v3 parse + MoE raw expert +extract. **No CUDA, dequant, mmap, or GGML compute** in this repo. GGUF’s +on-wire `ggml_type` codes are metadata only (labels + packed sizes). + +| Repo | Role | +|------|------| +| **engram-parser** (this) | GGUF parse + inventory + raw expert bytes | +| **myelin-accelerator** | Production CUDA kernels / FFI (`~/Limen-Neural/myelin-accelerator`) | +| **blackwell-kernel-lab** | Scratch GPU experiments / real-model pipelines (`~/rmems/blackwell-kernel-lab`) | + +Do **not** add myelin (or CUDA) as a dependency of engram-parser — optional or not. + +--- + +## 1. Full quality gate (copy-paste) + +Run from the **repo root** (`engram-parser` checkout, e.g. branch +`feat/gguf-parser-7`). There must be a `Cargo.toml` in the current directory +(`cargo fmt` fails with `could not find Cargo.toml` if you run it elsewhere). + +```bash +# From any checkout of this repo (requires Cargo.toml in the tree): +cd "$(git rev-parse --show-toplevel)" + +# 0) Ensure rustfmt is installed for this toolchain (once per toolchain) +rustup component add rustfmt +rustup component add clippy # needed for the next step + +# 1a) Apply formatting (rewrites sources; often prints nothing if already clean) +cargo fmt + +# 1b) CI-style check (fails with exit 1 + a diff if anything needs format) +cargo fmt --check + +# 2) Lint (fail on warnings) +cargo clippy --all-targets --all-features -- -D warnings + +# 3) Build +cargo build --all-features + +# 4) Tests (unit + integration + doctests) +cargo test --all-features + +# 5) Clean-tree guard (matches CI after build/test) +if [ -n "$(git status --porcelain)" ]; then + echo "Working tree dirty after gate — unexpected artifacts or uncommitted edits:" + git status --short + exit 1 +fi +echo "Working tree clean" +``` + +**Pass criteria:** all steps exit 0; `cargo test` reports 0 failed; tree clean +after you commit any files that `cargo fmt` rewrote. + +Optional one-liner (check-only; does not rewrite): + +```bash +cd ~/Limen-Neural/engram-parser && \ + cargo fmt --check && \ + cargo clippy --all-targets --all-features -- -D warnings && \ + cargo build --all-features && \ + cargo test --all-features && \ + test -z "$(git status --porcelain)" && echo "QUALITY GATE PASS" +``` + +### `cargo fmt` notes (common “doesn’t work” cases) + +| What you run | Expected behavior | +|--------------|-------------------| +| `cargo fmt` | **Applies** rustfmt. Exit 0 and **no stdout** when sources are already formatted — that is success, not a no-op bug. | +| `cargo fmt --check` | **Does not write**. Exit 0 if clean; exit 1 and prints a diff if not. This is what CI runs. | +| `cargo fmt -v` | Verbose: shows which crate roots rustfmt visits (`src/lib.rs`, `tests/*.rs`). Nested modules under `src/gguf/`, `src/moe/` are formatted via the module tree. | + +**Install / toolchain fixes:** + +```bash +# Active toolchain +rustc --version +rustup show + +# Install rustfmt if cargo fmt says it is missing +rustup component add rustfmt +# or pin explicitly: +rustup component add rustfmt --toolchain stable +rustup component add rustfmt --toolchain 1.97.1 # for MSRV checks + +# Confirm the binary cargo will call +cargo fmt --version +# → rustfmt x.y.z-stable (...) +``` + +This repo ships [`rust-toolchain.toml`](rust-toolchain.toml) (`channel = "stable"`), +so `rustup` / `cargo` in this directory use latest stable automatically. + +**Typical errors:** + +| Symptom | Fix | +|---------|-----| +| `could not find Cargo.toml` | `cd` into `engram-parser` first | +| `'cargo-fmt' is not installed` / missing rustfmt | `rustup component add rustfmt` | +| Wrong toolchain (old rustfmt, edition 2024 issues) | Use stable ≥ MSRV **1.97.1**: `rustup update stable` or `cargo +stable fmt` | +| “Nothing happened” after `cargo fmt` | Tree was already formatted; use `cargo fmt --check` (expect exit 0) or `cargo fmt -v` | +| `--check` prints diffs | Run `cargo fmt` (no `--check`) once, then commit | + +There is no `rustfmt.toml` in this repo; defaults are fine. + +## 2. Gate table + +| Step | Command | What it proves | +|------|---------|----------------| +| `fmt` (apply) | `cargo fmt` | Rewrites sources to rustfmt style (silent if already clean) | +| `fmt` (CI) | `cargo fmt --check` | Style matches rustfmt; fails with a diff if not | +| `clippy` | `cargo clippy --all-targets --all-features -- -D warnings` | No Clippy warnings on lib + tests | +| `build` | `cargo build --all-features` | Crate builds (features currently empty; flag kept for CI parity) | +| `test` | `cargo test --all-features` | Unit (`src/gguf/tensor.rs`), smoke (`tests/gguf_smoke.rs`), doctests | +| `clean-tree` | `git status --porcelain` empty | No stray outputs after build/test | +| `coverage` (opt) | `cargo llvm-cov --all-targets --all-features --locked --lcov --output-path lcov.info` | LCOV for Codecov (CI installs `cargo-llvm-cov`) | +| `msrv` (opt) | toolchain **1.97.1** + same fmt/clippy/build/test | Matches `rust-version` / CI `msrv` job | +| `docker` (opt) | `docker build -t engram-parser .` then `docker run --rm engram-parser` | Image uses `RUST_VERSION=1.97.1` | + +### Coverage (local) + +`llvm-cov` is a **cargo subcommand**, not a cargo flag. The space is required. + +```bash +# WRONG — cargo parses "-llvm-cov" as options → unexpected argument '-l' +# cargo -llvm-cov +# $HOME/.cargo/bin/cargo -llvm-cov + +# once per machine (installs ~/.cargo/bin/cargo-llvm-cov) +cargo install cargo-llvm-cov --locked + +# also need llvm-tools on the active toolchain (rust-toolchain.toml already lists it) +rustup component add llvm-tools-preview + +# correct: subcommand after cargo (same as CI) +cd ~/Limen-Neural/engram-parser +cargo llvm-cov --all-targets --all-features --locked --lcov --output-path lcov.info + +# human-readable summary only (no lcov file) +cargo llvm-cov --all-targets --all-features --locked +``` + +If you get `no such command: llvm-cov`, the binary is missing or not on `PATH`: + +```bash +which cargo-llvm-cov || cargo install cargo-llvm-cov --locked +export PATH="$HOME/.cargo/bin:$PATH" +cargo llvm-cov --version +``` + +`lcov.info` is a local artifact; do not commit it. + +### MSRV (local) + +**You must install the MSRV toolchain first.** If you skip this, you get: + +```text +error: toolchain '1.97.1-x86_64-unknown-linux-gnu' is not installed +help: run `rustup toolchain install 1.97.1` ... +``` + +**One-time setup:** + +```bash +# Install the exact MSRV pin (matches Cargo.toml rust-version / CI msrv job) +rustup toolchain install 1.97.1 --component rustfmt,clippy + +# Confirm cargo can see it (must print 1.97.1) +cargo +1.97.1 -V +rustc +1.97.1 -V +``` + +**Then build/test on MSRV** (from repo root): + +```bash +cd ~/Limen-Neural/engram-parser + +# Preferred: explicit +toolchain (overrides rust-toolchain.toml for this command) +cargo +1.97.1 fmt --check +cargo +1.97.1 clippy --all-targets --all-features -- -D warnings +cargo +1.97.1 build --all-features +cargo +1.97.1 test --all-features +``` + +**Alternatives if `+1.97.1` is awkward in an IDE/script:** + +```bash +# Same effect via env (also overrides directory rust-toolchain.toml) +RUSTUP_TOOLCHAIN=1.97.1 cargo build --all-features +RUSTUP_TOOLCHAIN=1.97.1 cargo test --all-features + +# Or rustup run +rustup run 1.97.1 cargo test --all-features +``` + +**When MSRV == current stable (today: both 1.97.x):** plain `cargo build` / +`cargo test` already use stable via `rust-toolchain.toml` and are enough for +day-to-day work. Use `+1.97.1` only when you want an explicit MSRV gate matching +the CI `msrv` job. + +| Symptom | Fix | +|---------|-----| +| `toolchain '1.97.1' is not installed` | `rustup toolchain install 1.97.1 --component rustfmt,clippy` | +| `+1.97.1` ignored / still wrong version | Prefer `cargo +1.97.1 -V` to verify; or `RUSTUP_TOOLCHAIN=1.97.1` | +| `clippy-driver` / rustfmt missing on 1.97.1 | `rustup component add clippy rustfmt --toolchain 1.97.1` | +| IDE “Cargo” has no `+1.97.1` | Set env `RUSTUP_TOOLCHAIN=1.97.1` in the run config, or use the terminal | + +--- + +## 3. What `cargo test` covers (this branch) + +| Surface | Location | Focus | +|---------|----------|--------| +| Unit | `src/gguf/tensor.rs` | `DType`, IQ/Q block `byte_len`, wire **31 = Q4_0_4_4** (not IQ3_M), labels | +| Integration | `tests/gguf_smoke.rs` | Synthetic GGUF parse, stacked/per-expert MoE extract, Q8_0/Q4_K slices, `file_type` quant fallback, bad magic/version/truncation | +| Pilot (ignored) | `tests/real_gguf.rs` | Real weights via `ENGRAM_GGUF` / `ENGRAM_MODEL_DIR` (xai-dissect pilots) | +| Example | `examples/inspect_gguf.rs` | Human inventory of one real GGUF | +| Always-on contract | `real_gguf_helpers_document_env` | Env names + empty pilot list when unset | +| Doctests | `src/lib.rs`, `ggml_type_label` | Public API examples compile | + +There is **no** `benches/` or Criterion target. Do **not** use `cargo bench` +as a quality gate for this crate. + +--- + +## 4. Test tiers (xai-dissect pattern) + +Same split as `~/rmems/xai-dissect`: **always-on fixtures in CI**, **path-gated +pilots on real weights locally**. + +| Tier | What | Command | CI? | +|------|------|---------|-----| +| **T0** | Synthetic GGUF builders | `cargo test --all-features` | Yes | +| **T1** | Real `.gguf` inventory + MoE extract | `ENGRAM_GGUF=… cargo test --test real_gguf -- --ignored` | No | +| **T2** | GPU kernels / Nsight / experiments | **blackwell-kernel-lab** or myelin | No | + +### T1 — real GGUF pilots (this repo, CPU only) + +```bash +cd ~/Limen-Neural/engram-parser + +# Single file (any dense or MoE GGUF under ~/.models, ollama export, etc.) +ENGRAM_GGUF=~/.models/gguf/Abiray/ZAYA1-8B-GGUF/ZAYA1-8B-Q8_0.gguf \ + cargo test --test real_gguf -- --ignored --nocapture + +# Scan a tree (depth-limited; cap with ENGRAM_GGUF_MAX, default 1) +ENGRAM_MODEL_DIR=~/.models/gguf ENGRAM_GGUF_MAX=3 \ + cargo test --test real_gguf -- --ignored --nocapture + +# Human-readable inventory (not a test) +cargo run --example inspect_gguf -- ~/.models/gguf/.../model.gguf +# or: ENGRAM_GGUF=... cargo run --example inspect_gguf +``` + +Env vars: + +| Var | Meaning | +|-----|---------| +| `ENGRAM_GGUF` | One `.gguf` path (wins over dir scan) | +| `ENGRAM_MODEL_DIR` | Root to walk for `*.gguf` | +| `ENGRAM_GGUF_MAX` | Max files when scanning (default 1) | +| `ENGRAM_EXPECT_MOE` | `1`/`true` → fail if no expert pairs discovered | +| `ENGRAM_MOE_SAMPLES` | Number of `(block,expert)` pairs to extract (default 1) | + +**Pass criteria (T1):** `load_gguf` ok; tensors non-empty; each `tensor_bytes` +in-range; when MoE names exist, `list_experts` + `extract_expert` return +non-empty projections. Dense GGUFs may skip MoE (inventory-only is fine). + +Do **not** commit multi-GB weights. Prefer `~/.models/gguf/…` over scraping +`~/.ollama` blobs (export / copy to a real `.gguf` path first). + +### T1 large MoE (local only — RAM-bound) + +`load_gguf` reads the **entire** file into memory (no mmap). Run **one** +`ENGRAM_GGUF` path per process. Do not scan a tree of multi-GB files (each +ignored test loads the file again — peak RSS ≈ 2× file size for inventory + +MoE tests in one `cargo test` invocation). + +| Model | Path (this machine) | Size | Min free RAM | +|-------|---------------------|------|--------------| +| jina F16 (smoke) | `~/.models/jinaai/…/v5-nano-text-matching-F16.gguf` | ~0.4 GiB | ≥ 2 GiB | +| ZAYA1-8B Q8_0 | `~/.models/gguf/Abiray/ZAYA1-8B-GGUF/ZAYA1-8B-Q8_0.gguf` | ~8.83 GiB | ≥ 12 GiB available | +| OLMoE-1B-7B F16 | `~/.models/gguf/allenai/OLMoE-1B-7B-0125-Instruct-GGUF/OLMoE-1B-7B-0125-Instruct-F16.gguf` (symlink → Downloads) | ~12.89 GiB | ≥ 18 GiB available | + +More GGUFs exist under `~/.models/gguf/` and `~/Downloads/SNN_Quantization/` +(Qwen3-MoE, DeepSeek-Coder-V2 Lite, Gemma-4 A4B, Kimi-VL, …). Optional P1 +pilots — still one path per process. + +```bash +free -h + +ENGRAM_GGUF=~/.models/gguf/Abiray/ZAYA1-8B-GGUF/ZAYA1-8B-Q8_0.gguf \ + ENGRAM_EXPECT_MOE=1 ENGRAM_MOE_SAMPLES=3 \ + cargo test --release --test real_gguf -- --ignored --nocapture + +ENGRAM_GGUF=~/.models/gguf/allenai/OLMoE-1B-7B-0125-Instruct-GGUF/OLMoE-1B-7B-0125-Instruct-F16.gguf \ + ENGRAM_EXPECT_MOE=1 ENGRAM_MOE_SAMPLES=5 \ + cargo test --release --test real_gguf -- --ignored --nocapture +``` + +**Proven on this host (2026-08-02):** + +| Pilot | tensors | moe pairs | samples | max RSS (test) | +|-------|---------|-----------|---------|----------------| +| ZAYA1 Q8 | 1283 | 640 | 3 OK (`complete=false` partial roles) | ~17.7 GiB | +| OLMoE F16 | 195 | 1024 | 5 OK (`complete=true`, stacked) | ~25.8 GiB | + +### T2 — GPU experiments: use blackwell-kernel-lab + +Yes — **run real-model GPU tests and experiments in +`~/rmems/blackwell-kernel-lab`**, not inside engram-parser. + +Suggested ownership: + +| Concern | Where | +|---------|--------| +| Parse / MoE raw extract correctness | engram-parser T0 + T1 | +| Scratch CUDA kernels, pipeline prototypes, Nsight | **blackwell-kernel-lab** | +| Stable Blackwell kernels / FFI | myelin-accelerator | + +blackwell-kernel-lab can depend on engram-parser **and** myelin (path deps). +engram-parser never depends on either. + +Production-ish GPU gate (still not engram CI): + +```bash +cd ~/Limen-Neural/myelin-accelerator +export CUDA_NVCC=/usr/local/cuda/bin/nvcc +cargo test --locked --features cuda -- --ignored --nocapture +cargo run --locked --example benchmark --profile bench --features bench,cuda +``` + +--- + +## 5. Out of scope for engram quality gate + +| Item | Where it belongs | +|------|------------------| +| CUDA / PTX / Nsight / real-model GPU benches | **blackwell-kernel-lab** (experiments) or myelin-accelerator | +| Row dequant / mmap host load | corinth-canal (reference) or downstream | +| Safetensors | engram-parser #10 (separate) | +| Routing / MoE matmul / generation | cortex-tensor / hybrid stack | +| Optional myelin dep on this crate | **Never** — keeps zero-dep charter | + +--- + +## 6. CI mapping + +| Local step | Workflow job | +|------------|----------------| +| fmt, clippy, build, test (T0 only), clean-tree, llvm-cov | `validate` in `.github/workflows/ci.yml` (**stable** = latest) | +| MSRV 1.97.1 fmt/clippy/build/test | `msrv` in `.github/workflows/ci.yml` (pinned `toolchain: "1.97.1"`) | +| Security audit / Snyk | `.github/workflows/security.yml` (not required for every local edit) | +| Docker image | `Dockerfile` (`ARG RUST_VERSION=1.97.1`) + `.github/workflows/docker-build.yml` | +| T1 real GGUF / T2 GPU | **Not in CI** — local pilots only | + +**Yes, the GitHub workflow is part of a Rust version bump:** keep `validate` on +`stable` (auto-tracks latest), and update the `msrv` job + `Cargo.toml` +`rust-version` + Docker tag together whenever you raise the floor. + +--- + +## 7. `.gitignore` note + +Local tool dirs (`.claude/`, `.opencode/`, `docs/superpowers/`, etc.), +`/target/`, and env files are ignored. Quality-gate commands should not +create tracked files; if `git status` is dirty after the gate, fix the +cause before merge (or ensure only intentional source edits are staged). +Do not commit `lcov.info`, large GGUFs, or GPU profile dumps into this repo. diff --git a/examples/inspect_gguf.rs b/examples/inspect_gguf.rs new file mode 100644 index 0000000..da0b5e7 --- /dev/null +++ b/examples/inspect_gguf.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Inventory a real on-disk GGUF (xai-dissect-style pilot path). +//! +//! # Usage +//! +//! ```bash +//! cargo run --example inspect_gguf -- /path/to/model.gguf +//! ENGRAM_GGUF=~/.models/gguf/foo.gguf cargo run --example inspect_gguf +//! ``` +//! +//! CPU-only. No CUDA, no dequant, no generation. For GPU experiments on the +//! same weights, use `~/rmems/blackwell-kernel-lab` (or myelin-accelerator +//! kernels), not this crate. + +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::Instant; + +use engram_parser::{GgufLayout, extract_expert, ggml_type_label, list_experts, load_gguf}; + +enum Resolved { + Path(PathBuf), + Help, + Version, +} + +fn print_usage() { + eprintln!("usage: cargo run --example inspect_gguf -- [--] "); + eprintln!(" or: ENGRAM_GGUF= cargo run --example inspect_gguf"); +} + +fn run(path: &Path) -> ExitCode { + let t0 = Instant::now(); + let layout = match load_gguf(path) { + Ok(l) => l, + Err(e) => { + eprintln!("load_gguf failed: {e}"); + return ExitCode::from(1); + } + }; + // Includes full-file read + parse (not parse-only). + let load_ms = t0.elapsed().as_secs_f64() * 1000.0; + + print_inventory(&layout, path, load_ms); + print_dtype_histogram(&layout); + print_moe_summary(&layout); + print_tensor_sample(&layout); + + ExitCode::SUCCESS +} + +fn main() -> ExitCode { + match resolve_path() { + Ok(Resolved::Path(p)) => { + if !p.is_file() { + eprintln!("not a file: {}", p.display()); + return ExitCode::from(1); + } + run(&p) + } + Ok(Resolved::Help) => { + print_usage(); + ExitCode::SUCCESS + } + Ok(Resolved::Version) => { + eprintln!("engram-parser {}", env!("CARGO_PKG_VERSION")); + ExitCode::SUCCESS + } + Err(msg) => { + eprintln!("{msg}"); + print_usage(); + ExitCode::from(2) + } + } +} + +fn print_inventory(layout: &GgufLayout, path: &Path, load_ms: f64) { + println!("path: {}", path.display()); + println!("load_ms: {load_ms:.2}"); + println!("architecture: {}", layout.metadata.architecture()); + println!("quantization: {}", layout.metadata.quantization()); + println!("alignment: {}", layout.alignment); + println!("tensor_count: {}", layout.tensors.len()); + println!("block_count: {:?}", layout.metadata.block_count()); + println!("expert_count: {:?}", layout.metadata.expert_count()); + println!("expert_used: {:?}", layout.metadata.expert_used_count()); + println!("embed_len: {:?}", layout.metadata.embedding_length()); +} + +fn print_dtype_histogram(layout: &GgufLayout) { + let mut counts: Vec<(String, usize)> = { + let mut m: HashMap = HashMap::new(); + for t in layout.tensors.values() { + *m.entry(ggml_type_label(t.ggml_type).to_owned()) + .or_default() += 1; + } + let mut v: Vec<_> = m.into_iter().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v + }; + if counts.len() > 16 { + counts.truncate(16); + } + println!("dtype_hist: {counts:?}"); +} + +fn print_moe_summary(layout: &GgufLayout) { + let experts = list_experts(layout); + println!("moe_pairs: {} (block,expert)", experts.len()); + if experts.is_empty() { + return; + } + + let show = experts.len().min(8); + println!("moe_pairs_hd: {:?}", &experts[..show]); + + let (b, e) = experts[0]; + let t1 = Instant::now(); + match extract_expert(layout, b, e) { + Ok(w) => { + let extract_ms = t1.elapsed().as_secs_f64() * 1000.0; + println!( + "extract ({b},{e}): complete={} extract_ms={extract_ms:.2}", + w.is_complete() + ); + let roles = [ + ("gate", &w.gate, true), + ("up", &w.up, false), + ("down", &w.down, false), + ]; + for (name, opt, show_stacked) in roles { + if let Some(t) = opt.as_ref() { + let mut line = format!( + " {name}: dims={:?} bytes={} dtype={:?}", + t.dims, + t.bytes.len(), + t.dtype + ); + if show_stacked { + line.push_str(&format!(" stacked={}", t.stacked_slice)); + } + println!("{line}"); + } + } + } + Err(err) => println!("extract ({b},{e}) failed: {err}"), + } +} + +fn print_tensor_sample(layout: &GgufLayout) { + let mut names: Vec<_> = layout.tensors.keys().cloned().collect(); + names.sort(); + let n = names.len().min(12); + println!("tensor_names_hd ({n}/{}):", names.len()); + for name in &names[..n] { + let t = &layout.tensors[name]; + println!( + " {name}: dims={:?} type={} byte_len={}", + t.dims, + ggml_type_label(t.ggml_type), + t.byte_len + ); + } +} + +fn resolve_path() -> Result { + // nosemgrep: argv is used only for CLI dispatch, never as a security trust anchor. + let args = env::args_os().skip(1); + let mut seen_dash_dash = false; + for p in args { + if let Some(s) = p.to_str() { + if !seen_dash_dash { + if s == "--" { + seen_dash_dash = true; + continue; + } + if s == "--help" || s == "-h" { + return Ok(Resolved::Help); + } + if s == "--version" || s == "-V" { + return Ok(Resolved::Version); + } + if s.starts_with('-') { + return Err(format!("unknown option {s}")); + } + } + return Ok(Resolved::Path(PathBuf::from(p))); + } + // Non-UTF-8 path: treat as positional after a `--` or as the first argument. + return Ok(Resolved::Path(PathBuf::from(p))); + } + env::var("ENGRAM_GGUF") + .map(PathBuf::from) + .map(Resolved::Path) + .map_err(|_| "missing model path (arg or ENGRAM_GGUF)".into()) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..c58913c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Local / rustup default for this crate. +# channel = stable always tracks the latest stable release. +# MSRV (Cargo.toml rust-version / CI msrv job) is the *minimum* supported version. +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy", "llvm-tools-preview"] diff --git a/src/gguf/cursor.rs b/src/gguf/cursor.rs index 109a841..4c9087b 100644 --- a/src/gguf/cursor.rs +++ b/src/gguf/cursor.rs @@ -24,19 +24,62 @@ pub(crate) fn invalid_layout(path: &str, reason: impl Into) -> ParserErr } } -pub(crate) const VT_U8: u32 = 0; -pub(crate) const VT_I8: u32 = 1; -pub(crate) const VT_U16: u32 = 2; -pub(crate) const VT_I16: u32 = 3; -pub(crate) const VT_U32: u32 = 4; -pub(crate) const VT_I32: u32 = 5; -pub(crate) const VT_F32: u32 = 6; -pub(crate) const VT_BOOL: u32 = 7; -pub(crate) const VT_STRING: u32 = 8; -pub(crate) const VT_ARRAY: u32 = 9; -pub(crate) const VT_U64: u32 = 10; -pub(crate) const VT_I64: u32 = 11; -pub(crate) const VT_F64: u32 = 12; +/// GGUF value type: 8-bit unsigned integer. +pub const GGUF_VALUE_TYPE_UINT8: u32 = 0; +/// GGUF value type: 8-bit signed integer. +pub const GGUF_VALUE_TYPE_INT8: u32 = 1; +/// GGUF value type: 16-bit unsigned integer. +pub const GGUF_VALUE_TYPE_UINT16: u32 = 2; +/// GGUF value type: 16-bit signed integer. +pub const GGUF_VALUE_TYPE_INT16: u32 = 3; +/// GGUF value type: 32-bit unsigned integer. +pub const GGUF_VALUE_TYPE_UINT32: u32 = 4; +/// GGUF value type: 32-bit signed integer. +pub const GGUF_VALUE_TYPE_INT32: u32 = 5; +/// GGUF value type: 32-bit IEEE 754 float. +pub const GGUF_VALUE_TYPE_FLOAT32: u32 = 6; +/// GGUF value type: boolean (1 byte, 0 or 1). +pub const GGUF_VALUE_TYPE_BOOL: u32 = 7; +/// GGUF value type: length-prefixed UTF-8 string. +pub const GGUF_VALUE_TYPE_STRING: u32 = 8; +/// GGUF value type: length-prefixed array of nested values. +pub const GGUF_VALUE_TYPE_ARRAY: u32 = 9; +/// GGUF value type: 64-bit unsigned integer. +pub const GGUF_VALUE_TYPE_UINT64: u32 = 10; +/// GGUF value type: 64-bit signed integer. +pub const GGUF_VALUE_TYPE_INT64: u32 = 11; +/// GGUF value type: 64-bit IEEE 754 float. +pub const GGUF_VALUE_TYPE_FLOAT64: u32 = 12; + +pub(crate) fn is_signed_layout_type(value_type: u32) -> bool { + matches!( + value_type, + GGUF_VALUE_TYPE_INT8 + | GGUF_VALUE_TYPE_INT16 + | GGUF_VALUE_TYPE_INT32 + | GGUF_VALUE_TYPE_INT64 + ) +} + +fn is_unsigned_layout_type(value_type: u32) -> bool { + matches!( + value_type, + GGUF_VALUE_TYPE_UINT8 + | GGUF_VALUE_TYPE_UINT16 + | GGUF_VALUE_TYPE_UINT32 + | GGUF_VALUE_TYPE_UINT64 + | GGUF_VALUE_TYPE_BOOL + ) +} + +fn nonneg_signed(path: &str, v: i64) -> Result { + u64::try_from(v).map_err(|_| { + invalid_layout( + path, + format!("signed layout value {v} is negative; expected non-negative"), + ) + }) +} pub(crate) struct GgufCursor<'a> { bytes: &'a [u8], @@ -129,21 +172,29 @@ impl<'a> GgufCursor<'a> { .map_err(|e| self.unsupported(format!("invalid UTF-8 in GGUF string: {e}"))) } + /// Read an unsigned numeric GGUF value and coerce it to `u64`. + fn read_unsigned_as_u64(&mut self, value_type: u32) -> Result { + match value_type { + GGUF_VALUE_TYPE_UINT8 | GGUF_VALUE_TYPE_BOOL => self.read_u8_as_u64(), + GGUF_VALUE_TYPE_UINT16 => self.read_u16_as_u64(), + GGUF_VALUE_TYPE_UINT32 => self.read_u32_as_u64(), + GGUF_VALUE_TYPE_UINT64 => self.read_u64(), + other => Err(self.unsupported(format!( + "expected unsigned numeric GGUF value, got type {other}" + ))), + } + } + /// Read a numeric-typed GGUF value and coerce it to `u64`. pub(crate) fn read_numeric_as_u64(&mut self, value_type: u32) -> Result { - match value_type { - VT_U8 => self.read_u8_as_u64(), - VT_I8 => self.read_i8_as_u64(), - VT_U16 => self.read_u16_as_u64(), - VT_I16 => self.read_i16_as_u64(), - VT_U32 => self.read_u32_as_u64(), - VT_I32 => self.read_i32_as_u64(), - VT_U64 => self.read_u64(), - VT_I64 => self.read_i64_as_u64(), - VT_BOOL => self.read_u8_as_u64(), - other => { - Err(self.unsupported(format!("expected numeric GGUF value, got type {other}"))) - } + if is_signed_layout_type(value_type) { + self.read_signed_as_u64(value_type) + } else if is_unsigned_layout_type(value_type) { + self.read_unsigned_as_u64(value_type) + } else { + Err(self.unsupported(format!( + "expected numeric GGUF value, got type {value_type}" + ))) } } @@ -151,68 +202,95 @@ impl<'a> GgufCursor<'a> { Ok(self.read_u8()? as u64) } - fn read_i8_as_u64(&mut self) -> Result { - Ok(self.read_u8()? as i8 as i64 as u64) - } - fn read_u16_as_u64(&mut self) -> Result { Ok(self.read_u16()? as u64) } - fn read_i16_as_u64(&mut self) -> Result { - Ok(self.read_i16()? as i64 as u64) - } - fn read_u32_as_u64(&mut self) -> Result { Ok(self.read_u32()? as u64) } - fn read_i32_as_u64(&mut self) -> Result { - Ok(self.read_i32()? as i64 as u64) + /// Read a signed GGUF value and return its bit-preserving `u64` + /// representation. Negative values are not rejected here so that vendor + /// metadata can store signed quantities without loss. + fn read_signed_as_u64(&mut self, value_type: u32) -> Result { + Ok(self.read_signed_as_i64(value_type)? as u64) } - fn read_i64_as_u64(&mut self) -> Result { - Ok(self.read_i64()? as u64) + fn read_signed_as_i64(&mut self, value_type: u32) -> Result { + match value_type { + GGUF_VALUE_TYPE_INT8 => Ok(self.read_u8()? as i8 as i64), + GGUF_VALUE_TYPE_INT16 => Ok(self.read_i16()? as i64), + GGUF_VALUE_TYPE_INT32 => Ok(self.read_i32()? as i64), + GGUF_VALUE_TYPE_INT64 => Ok(self.read_i64()?), + other => unreachable!("caller filters signed types, got {other}"), + } } - /// Read a numeric-typed GGUF value and coerce it to `usize`. - pub(crate) fn read_numeric_as_usize(&mut self, value_type: u32) -> Result { - Ok(self.read_numeric_as_u64(value_type)? as usize) + /// Read a non-negative layout value (e.g. `general.alignment`). + /// + /// Rejects signed negatives so they do not wrap into huge alignments. + /// Other signed KV pairs should use [`Self::read_numeric_as_u64`] instead. + pub(crate) fn read_nonneg_layout_usize(&mut self, value_type: u32) -> Result { + let v = if is_signed_layout_type(value_type) { + let s = self.read_signed_as_i64(value_type)?; + nonneg_signed(self.path, s)? + } else if is_unsigned_layout_type(value_type) { + self.read_numeric_as_u64(value_type)? + } else { + return Err(self.unsupported(format!( + "expected integer GGUF value for layout field, got type {value_type}" + ))); + }; + Ok(v as usize) } /// Render a scalar GGUF value as a string (used for metadata KV). #[allow(dead_code)] pub(crate) fn read_scalar_as_string(&mut self, value_type: u32) -> Result { match value_type { - VT_U8 | VT_I8 | VT_U16 | VT_I16 | VT_U32 | VT_I32 | VT_U64 | VT_I64 | VT_BOOL => { - Ok(self.read_numeric_as_u64(value_type)?.to_string()) - } - VT_F32 => Ok(self.read_f32()?.to_string()), - VT_F64 => Ok(self.read_f64()?.to_string()), - VT_STRING => self.read_string(), + GGUF_VALUE_TYPE_UINT8 + | GGUF_VALUE_TYPE_INT8 + | GGUF_VALUE_TYPE_UINT16 + | GGUF_VALUE_TYPE_INT16 + | GGUF_VALUE_TYPE_UINT32 + | GGUF_VALUE_TYPE_INT32 + | GGUF_VALUE_TYPE_UINT64 + | GGUF_VALUE_TYPE_INT64 + | GGUF_VALUE_TYPE_BOOL => Ok(self.read_numeric_as_u64(value_type)?.to_string()), + GGUF_VALUE_TYPE_FLOAT32 => Ok(self.read_f32()?.to_string()), + GGUF_VALUE_TYPE_FLOAT64 => Ok(self.read_f64()?.to_string()), + GGUF_VALUE_TYPE_STRING => self.read_string(), other => Err(self.unsupported(format!("unexpected scalar GGUF value type {other}"))), } } /// Skip an arbitrary GGUF value without materialising it. pub(crate) fn skip_value(&mut self, value_type: u32) -> Result<()> { + if value_type == GGUF_VALUE_TYPE_ARRAY { + self.skip_array_value() + } else { + self.skip_scalar_value(value_type) + } + } + + fn skip_scalar_value(&mut self, value_type: u32) -> Result<()> { match value_type { - VT_U8 | VT_I8 | VT_BOOL => { + GGUF_VALUE_TYPE_UINT8 | GGUF_VALUE_TYPE_INT8 | GGUF_VALUE_TYPE_BOOL => { self.read_exact(1)?; } - VT_U16 | VT_I16 => { + GGUF_VALUE_TYPE_UINT16 | GGUF_VALUE_TYPE_INT16 => { self.read_exact(2)?; } - VT_U32 | VT_I32 | VT_F32 => { + GGUF_VALUE_TYPE_UINT32 | GGUF_VALUE_TYPE_INT32 | GGUF_VALUE_TYPE_FLOAT32 => { self.read_exact(4)?; } - VT_U64 | VT_I64 | VT_F64 => { + GGUF_VALUE_TYPE_UINT64 | GGUF_VALUE_TYPE_INT64 | GGUF_VALUE_TYPE_FLOAT64 => { self.read_exact(8)?; } - VT_STRING => { + GGUF_VALUE_TYPE_STRING => { let _ = self.read_string()?; } - VT_ARRAY => self.skip_array_value()?, other => { return Err(self.unsupported(format!("unsupported GGUF value type {other}"))); } @@ -220,12 +298,89 @@ impl<'a> GgufCursor<'a> { Ok(()) } + /// Skip a GGUF array value using an explicit stack instead of recursion, + /// so deep-but-valid metadata arrays are not rejected by an arbitrary + /// depth limit. Total work is still bounded by the remaining byte range. fn skip_array_value(&mut self) -> Result<()> { let nested = self.read_u32()?; - let len = self.read_u64()? as usize; - for _ in 0..len { - self.skip_value(nested)?; + let len = self.read_u64()?; + + // Reject lengths that cannot possibly fit in the remaining buffer. + let remaining = self.bytes.len().saturating_sub(self.offset) as u64; + if len > remaining { + return Err( + self.unsupported("GGUF array length exceeds remaining metadata bytes".into()) + ); + } + + // Stack of (element_type, elements_remaining) pairs. Depth is bounded + // only by nesting of arrays, not by a hard-coded recursion limit. + let mut stack: Vec<(u32, u64)> = Vec::new(); + stack.push((nested, len)); + + while let Some((ty, mut count)) = stack.pop() { + if ty == GGUF_VALUE_TYPE_ARRAY { + if count == 0 { + continue; + } + // Each element is an independent sub-array; read one header. + let sub_ty = self.read_u32()?; + let sub_len = self.read_u64()?; + let bytes_left = self.bytes.len().saturating_sub(self.offset) as u64; + if sub_len > bytes_left { + return Err(self.unsupported( + "GGUF nested array length exceeds remaining metadata bytes".into(), + )); + } + count -= 1; + if count > 0 { + stack.push((ty, count)); + } + stack.push((sub_ty, sub_len)); + } else { + for _ in 0..count { + self.skip_scalar_value(ty)?; + } + } } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn push_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()); + } + + fn push_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_le_bytes()); + } + + #[test] + fn skip_array_value_handles_deep_nesting() { + // Build a 32-level nested array of arrays ending in an empty UINT8 array. + const DEPTH: usize = 32; + let mut bytes = Vec::with_capacity(DEPTH * 12); + for level in 0..DEPTH { + if level == DEPTH - 1 { + push_u32(&mut bytes, GGUF_VALUE_TYPE_UINT8); + } else { + push_u32(&mut bytes, GGUF_VALUE_TYPE_ARRAY); + } + push_u64(&mut bytes, if level == DEPTH - 1 { 0 } else { 1 }); + } + + let mut cursor = GgufCursor::new(&bytes, "mem://deep-array"); + cursor + .skip_value(GGUF_VALUE_TYPE_ARRAY) + .expect("skip deep array"); + assert_eq!( + cursor.offset, + bytes.len(), + "did not consume entire nested array" + ); + } +} diff --git a/src/gguf/layout.rs b/src/gguf/layout.rs index 65cb463..72b7194 100644 --- a/src/gguf/layout.rs +++ b/src/gguf/layout.rs @@ -9,7 +9,10 @@ use std::collections::HashMap; -use super::cursor::{GGUF_MAGIC, GGUF_VERSION, GgufCursor, VT_STRING, invalid_layout, unsupported}; +use super::cursor::{ + GGUF_MAGIC, GGUF_VALUE_TYPE_STRING, GGUF_VERSION, GgufCursor, invalid_layout, + is_signed_layout_type, unsupported, +}; use super::tensor::{DType, Tensor}; use crate::error::{ParserError, Result}; @@ -21,8 +24,10 @@ const MAX_TENSOR_DIMS: usize = 8; /// Parsed GGUF metadata key-value store. /// /// GGUF stores arbitrary scalar KV pairs. We keep strings and numeric -/// values in two typed maps; array values are skipped (their byte -/// contents remain available via the original file buffer if needed). +/// values in typed maps; signed integers are additionally kept in +/// `signed_numerics` so callers can distinguish bit-preserved negatives +/// from large unsigned `UINT64` values. Array values are skipped (their +/// byte contents remain available via the original file buffer if needed). #[derive(Debug, Clone, Default)] pub struct GgufMetadata { /// String-typed KV pairs (e.g. `general.architecture = "olmoe"`). @@ -30,6 +35,10 @@ pub struct GgufMetadata { /// Numeric-typed KV pairs coerced to `u64` /// (e.g. `olmoe.expert_count = 64`). pub numerics: HashMap, + /// Signed integer KV pairs coerced to `i64`, keyed separately so + /// `numeric()` can reject negatives without rejecting large unsigned + /// `UINT64` metadata. + pub(crate) signed_numerics: HashMap, /// `f32`-typed KV pairs. pub floats_32: HashMap, /// `f64`-typed KV pairs. @@ -46,8 +55,104 @@ impl GgufMetadata { } /// Convenience: numeric KV coerced to `usize`. + /// + /// Signed integer KVs are checked first: a negative value returns + /// `None`. Unsigned `UINT64` values larger than `i64::MAX` are still + /// accepted as long as they fit in `usize`. pub fn numeric(&self, key: &str) -> Option { - self.numerics.get(key).map(|&v| v as usize) + if let Some(&s) = self.signed_numerics.get(key) { + return usize::try_from(s).ok(); + } + let &v = self.numerics.get(key)?; + usize::try_from(v).ok() + } + + /// Convenience: quantization label. + /// + /// Prefers the string KV `general.quantization_type`. When that is + /// missing, falls back to the **current** `general.file_type` numeric + /// (GGUF enum): `0 → "F32"`, `1 → "F16"`, otherwise `"GGUF(n)"`. + /// Returns `"unknown"` when neither is present. Derived at call time + /// so `Default` + public map edits stay consistent. + pub fn quantization(&self) -> String { + if let Some(s) = self.strings.get("general.quantization_type") { + return s.clone(); + } + // If a signed value was stored, reject negatives rather than + // rendering them as huge unsigned labels. + if let Some(&s) = self.signed_numerics.get("general.file_type") { + return match s { + 0 => "F32".into(), + 1 => "F16".into(), + n if n >= 0 => format!("GGUF({n})"), + _ => "unknown".into(), + }; + } + match self.numerics.get("general.file_type").copied() { + Some(0) => "F32".into(), + Some(1) => "F16".into(), + Some(n) => format!("GGUF({n})"), + None => "unknown".into(), + } + } + + /// Convenience: numeric KV coerced to `usize`, looking up + /// `{architecture}.{key}` (e.g. `olmoe.block_count`). + /// + /// Returns `None` if the architecture is unknown or the key is missing. + pub fn arch_numeric(&self, key: &str) -> Option { + let arch = self.architecture(); + if arch == "unknown" { + return None; + } + let full_key = format!("{arch}.{key}"); + self.numeric(&full_key) + } + + /// Convenience: block count from `{architecture}.block_count`. + pub fn block_count(&self) -> Option { + self.arch_numeric("block_count") + } + + /// Convenience: expert count from `{architecture}.expert_count` + /// (some models use `num_experts` instead). + pub fn expert_count(&self) -> Option { + self.arch_numeric("expert_count") + .or_else(|| self.arch_numeric("num_experts")) + } + + /// Convenience: number of experts used per token from + /// `{architecture}.expert_used_count` (some models use + /// `num_experts_per_tok`). + pub fn expert_used_count(&self) -> Option { + self.arch_numeric("expert_used_count") + .or_else(|| self.arch_numeric("num_experts_per_tok")) + } + + /// Convenience: embedding length from `{architecture}.embedding_length`. + pub fn embedding_length(&self) -> Option { + self.arch_numeric("embedding_length") + } + + /// Convenience: attention head count from + /// `{architecture}.attention.head_count`. + pub fn head_count(&self) -> Option { + self.arch_numeric("attention.head_count") + } + + /// Generic string metadata lookup. + pub fn string(&self, key: &str) -> Option<&str> { + self.strings.get(key).map(String::as_str) + } + + /// Generic f32 metadata lookup. + pub fn float32(&self, key: &str) -> Option { + self.floats_32.get(key).copied() + } + + /// Generic f64 metadata lookup. + pub fn float64(&self, key: &str) -> Option { + self.floats_64.get(key).copied() } } @@ -134,7 +239,7 @@ pub(crate) fn parse_layout( ) -> Result<(GgufMetadata, HashMap, usize, usize)> { let mut cursor = GgufCursor::new(bytes, path); let header = read_layout_header(&mut cursor, path)?; - let (alignment, metadata) = read_metadata_section(&mut cursor, header.kv_count)?; + let (alignment, metadata) = read_metadata_section(&mut cursor, path, header.kv_count)?; let mut tensors = read_tensor_directory(&mut cursor, path, header.tensor_count)?; let tensor_data_offset = finalize_tensor_offsets(&mut tensors, cursor.offset(), alignment); Ok((metadata, tensors, alignment, tensor_data_offset)) @@ -182,6 +287,7 @@ fn bounded_count(raw: u64, limit: u64, label: &str, path: &str) -> Result fn read_metadata_section( cursor: &mut GgufCursor<'_>, + path: &str, kv_count: usize, ) -> Result<(usize, GgufMetadata)> { let mut alignment: usize = 32; @@ -191,7 +297,15 @@ fn read_metadata_section( let key = cursor.read_string()?; let value_type = cursor.read_u32()?; if key == "general.alignment" { - alignment = cursor.read_numeric_as_usize(value_type)?.max(1); + // Layout-critical: reject signed negatives (do not wrap to huge usize) + // and require a positive power of two. + alignment = cursor.read_nonneg_layout_usize(value_type)?; + if alignment == 0 || !alignment.is_power_of_two() { + return Err(invalid_layout( + path, + format!("general.alignment must be a positive power of 2, got {alignment}"), + )); + } } else { capture_kv(cursor, &mut metadata, key, value_type)?; } @@ -220,6 +334,7 @@ fn read_tensor_entry(cursor: &mut GgufCursor<'_>, path: &str) -> Result let relative_offset = cursor.read_u64()? as usize; let dtype = DType::from_ggml_type(ggml_type); let n_elements = tensor_element_count(&dims, &name, path)?; + validate_blocked_inner_dim(dtype, &dims, &name, path)?; let byte_len = tensor_byte_len(dtype, ggml_type, n_elements, &name, path)?; Ok(Tensor { @@ -234,6 +349,29 @@ fn read_tensor_entry(cursor: &mut GgufCursor<'_>, path: &str) -> Result }) } +/// Blocked quant layouts pack along the **innermost** GGUF dim (`dims[0]`). +/// Total element count alone can accept shapes that cannot form valid blocks +/// per row (e.g. Q4_0 with dims `[16, 2]` → 32 elems but row len 16). +fn validate_blocked_inner_dim(dtype: DType, dims: &[usize], name: &str, path: &str) -> Result<()> { + let Some(block) = dtype.quant_block_size() else { + return Ok(()); + }; + let Some(&inner) = dims.first() else { + return Ok(()); + }; + if inner.is_multiple_of(block) { + return Ok(()); + } + Err(invalid_layout( + path, + format!( + "tensor '{name}' innermost dim {inner} is not divisible by \ + quant block size {block} for dtype {}", + dtype.label() + ), + )) +} + fn read_tensor_dims(cursor: &mut GgufCursor<'_>, path: &str, name: &str) -> Result> { let n_dims_raw = cursor.read_u32()? as usize; if n_dims_raw > MAX_TENSOR_DIMS { @@ -292,15 +430,24 @@ fn capture_kv( value_type: u32, ) -> Result<()> { use super::cursor::{ - VT_BOOL, VT_F32, VT_F64, VT_I8, VT_I16, VT_I32, VT_I64, VT_U8, VT_U16, VT_U32, VT_U64, + GGUF_VALUE_TYPE_BOOL, GGUF_VALUE_TYPE_FLOAT32, GGUF_VALUE_TYPE_FLOAT64, + GGUF_VALUE_TYPE_INT8, GGUF_VALUE_TYPE_INT16, GGUF_VALUE_TYPE_INT32, GGUF_VALUE_TYPE_INT64, + GGUF_VALUE_TYPE_UINT8, GGUF_VALUE_TYPE_UINT16, GGUF_VALUE_TYPE_UINT32, + GGUF_VALUE_TYPE_UINT64, }; match value_type { - VT_U8 | VT_I8 | VT_U16 | VT_I16 | VT_U32 | VT_I32 | VT_U64 | VT_I64 | VT_BOOL => { - capture_numeric_kv(cursor, metadata, key, value_type) - } - VT_F32 => capture_f32_kv(cursor, metadata, key), - VT_F64 => capture_f64_kv(cursor, metadata, key), - VT_STRING => capture_string_kv(cursor, metadata, key), + GGUF_VALUE_TYPE_UINT8 + | GGUF_VALUE_TYPE_INT8 + | GGUF_VALUE_TYPE_UINT16 + | GGUF_VALUE_TYPE_INT16 + | GGUF_VALUE_TYPE_UINT32 + | GGUF_VALUE_TYPE_INT32 + | GGUF_VALUE_TYPE_UINT64 + | GGUF_VALUE_TYPE_INT64 + | GGUF_VALUE_TYPE_BOOL => capture_numeric_kv(cursor, metadata, key, value_type), + GGUF_VALUE_TYPE_FLOAT32 => capture_f32_kv(cursor, metadata, key), + GGUF_VALUE_TYPE_FLOAT64 => capture_f64_kv(cursor, metadata, key), + GGUF_VALUE_TYPE_STRING => capture_string_kv(cursor, metadata, key), _ => capture_skipped_kv(cursor, value_type), } } @@ -312,6 +459,9 @@ fn capture_numeric_kv( value_type: u32, ) -> Result<()> { let v = cursor.read_numeric_as_u64(value_type)?; + if is_signed_layout_type(value_type) { + metadata.signed_numerics.insert(key.clone(), v as i64); + } metadata.numerics.insert(key, v); Ok(()) } @@ -366,3 +516,43 @@ fn tensor_block_sort_key(name: &str) -> (usize, String) { .unwrap_or(usize::MAX); (block, name.to_owned()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_keeps_large_unsigned_and_rejects_negative_signed() { + let mut meta = GgufMetadata::default(); + + // A UINT64 larger than i64::MAX is still accepted when it fits in usize. + meta.numerics.insert("big".into(), u64::MAX); + #[cfg(target_pointer_width = "64")] + assert_eq!(meta.numeric("big"), Some(usize::MAX)); + + // A signed negative value must not wrap into a positive usize. + meta.numerics.insert("neg".into(), (-7i64) as u64); + meta.signed_numerics.insert("neg".into(), -7); + assert_eq!(meta.numeric("neg"), None); + } + + #[test] + fn quantization_suppresses_negative_signed_file_type() { + let mut meta = GgufMetadata::default(); + + // A negative signed file_type must not render as an unsigned label. + meta.numerics + .insert("general.file_type".into(), (-7i64) as u64); + meta.signed_numerics.insert("general.file_type".into(), -7); + assert_eq!(meta.quantization(), "unknown"); + + // A positive signed file_type still resolves normally. + meta.signed_numerics.insert("general.file_type".into(), 15); + assert_eq!(meta.quantization(), "GGUF(15)"); + + // An unsigned file_type falls back to the unsigned map. + meta.signed_numerics.remove("general.file_type"); + meta.numerics.insert("general.file_type".into(), 15); + assert_eq!(meta.quantization(), "GGUF(15)"); + } +} diff --git a/src/gguf/mod.rs b/src/gguf/mod.rs index 10dbcf5..9634b1e 100644 --- a/src/gguf/mod.rs +++ b/src/gguf/mod.rs @@ -15,8 +15,20 @@ use std::path::Path; pub use layout::{GgufLayout, GgufMetadata}; pub use tensor::{ - DType, GGML_TYPE_BF16, GGML_TYPE_F16, GGML_TYPE_F32, GGML_TYPE_IQ3_S, GGML_TYPE_Q4_K, - GGML_TYPE_Q5_K, GGML_TYPE_Q6_K, GGML_TYPE_Q8_0, Tensor, f16_bits_to_f32, + DType, GGML_TYPE_BF16, GGML_TYPE_F16, GGML_TYPE_F32, GGML_TYPE_F64, GGML_TYPE_I8, + GGML_TYPE_I16, GGML_TYPE_I32, GGML_TYPE_I64, GGML_TYPE_IQ1_M, GGML_TYPE_IQ1_S, GGML_TYPE_IQ2_S, + GGML_TYPE_IQ2_XS, GGML_TYPE_IQ2_XXS, GGML_TYPE_IQ3_S, GGML_TYPE_IQ3_XXS, GGML_TYPE_IQ4_NL, + GGML_TYPE_IQ4_XS, GGML_TYPE_Q2_K, GGML_TYPE_Q3_K, GGML_TYPE_Q4_0, GGML_TYPE_Q4_0_4_4, + GGML_TYPE_Q4_1, GGML_TYPE_Q4_K, GGML_TYPE_Q5_0, GGML_TYPE_Q5_1, GGML_TYPE_Q5_K, GGML_TYPE_Q6_K, + GGML_TYPE_Q8_0, GGML_TYPE_Q8_1, GGML_TYPE_Q8_K, Tensor, f16_bits_to_f32, ggml_type_label, +}; + +// Re-export metadata value type constants for public API. +pub use cursor::{ + GGUF_VALUE_TYPE_ARRAY, GGUF_VALUE_TYPE_BOOL, GGUF_VALUE_TYPE_FLOAT32, GGUF_VALUE_TYPE_FLOAT64, + GGUF_VALUE_TYPE_INT8, GGUF_VALUE_TYPE_INT16, GGUF_VALUE_TYPE_INT32, GGUF_VALUE_TYPE_INT64, + GGUF_VALUE_TYPE_STRING, GGUF_VALUE_TYPE_UINT8, GGUF_VALUE_TYPE_UINT16, GGUF_VALUE_TYPE_UINT32, + GGUF_VALUE_TYPE_UINT64, }; use crate::error::{ParserError, Result}; diff --git a/src/gguf/tensor.rs b/src/gguf/tensor.rs index 4a519c1..ea6d1a2 100644 --- a/src/gguf/tensor.rs +++ b/src/gguf/tensor.rs @@ -1,66 +1,259 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! Tensor directory entry + dtype enumeration. +//! Tensor directory entry + dtype enumeration + GGUF wire-type helpers. //! -//! A [`Tensor`] is a pure metadata descriptor: name, shape, dtype, and +//! A [`Tensor`] is a pure-metadata descriptor: name, shape, dtype, and //! byte offset within the file. It owns no weight data itself — callers //! pass it back to [`GgufLayout::tensor_bytes`](super::layout::GgufLayout::tensor_bytes) //! to obtain the raw `&[u8]` payload. +//! +//! ## GGUF `ggml_type` codes (metadata only) +//! +//! GGUF stores each tensor’s dtype as a `ggml_type` `u32`. The +//! `GGML_TYPE_*` constants mirror that table (same numbers as `ggml.h`) +//! so we can label types and compute **packed byte lengths**. This module +//! does **not** implement dequantization or any GGML compute path. +//! [`ggml_type_label`] maps any `u32` code to a short diagnostic string. use crate::error::{ParserError, Result}; +// --------------------------------------------------------------------------- +// GGML type constants (mirror `ggml.h` as of 2025-06). +// --------------------------------------------------------------------------- + +/// `GGML_TYPE_F32` — 32-bit IEEE-754 float. +pub const GGML_TYPE_F32: u32 = 0; +/// `GGML_TYPE_F16` — 16-bit IEEE-754 half float. +pub const GGML_TYPE_F16: u32 = 1; +/// `GGML_TYPE_Q4_0` — 4-bit quantization (symmetric, block size 32). +pub const GGML_TYPE_Q4_0: u32 = 2; +/// `GGML_TYPE_Q4_1` — 4-bit quantization (with min, block size 32). +pub const GGML_TYPE_Q4_1: u32 = 3; +/// `GGML_TYPE_Q5_0` — 5-bit quantization (symmetric, block size 32). +pub const GGML_TYPE_Q5_0: u32 = 6; +/// `GGML_TYPE_Q5_1` — 5-bit quantization (with min, block size 32). +pub const GGML_TYPE_Q5_1: u32 = 7; +/// `GGML_TYPE_Q8_0` — 8-bit quantization (symmetric, block size 32). +pub const GGML_TYPE_Q8_0: u32 = 8; +/// `GGML_TYPE_Q8_1` — 8-bit quantization (with min, block size 32). +pub const GGML_TYPE_Q8_1: u32 = 9; +/// `GGML_TYPE_Q2_K` — k-quant 2-bit. +pub const GGML_TYPE_Q2_K: u32 = 10; +/// `GGML_TYPE_Q3_K` — k-quant 3-bit. +pub const GGML_TYPE_Q3_K: u32 = 11; +/// `GGML_TYPE_Q4_K` — k-quant 4-bit. +pub const GGML_TYPE_Q4_K: u32 = 12; +/// `GGML_TYPE_Q5_K` — k-quant 5-bit. +pub const GGML_TYPE_Q5_K: u32 = 13; +/// `GGML_TYPE_Q6_K` — k-quant 6-bit. +pub const GGML_TYPE_Q6_K: u32 = 14; +/// `GGML_TYPE_Q8_K` — k-quant 8-bit. +pub const GGML_TYPE_Q8_K: u32 = 15; +/// `GGML_TYPE_IQ2_XXS` — i-quant 2-bit extra-extra-small. +pub const GGML_TYPE_IQ2_XXS: u32 = 16; +/// `GGML_TYPE_IQ2_XS` — i-quant 2-bit extra-small. +pub const GGML_TYPE_IQ2_XS: u32 = 17; +/// `GGML_TYPE_IQ3_XXS` — i-quant 3-bit extra-extra-small. +pub const GGML_TYPE_IQ3_XXS: u32 = 18; +/// `GGML_TYPE_IQ1_S` — i-quant 1-bit small. +pub const GGML_TYPE_IQ1_S: u32 = 19; +/// `GGML_TYPE_IQ4_NL` — i-quant 4-bit non-linear. +pub const GGML_TYPE_IQ4_NL: u32 = 20; +/// `GGML_TYPE_IQ3_S` — i-quant 3-bit small (3.44 bpw). +pub const GGML_TYPE_IQ3_S: u32 = 21; +/// `GGML_TYPE_IQ2_S` — i-quant 2-bit small. +pub const GGML_TYPE_IQ2_S: u32 = 22; +/// `GGML_TYPE_IQ4_XS` — i-quant 4-bit extra-small. +pub const GGML_TYPE_IQ4_XS: u32 = 23; +/// `GGML_TYPE_I8` — 8-bit signed integer. +pub const GGML_TYPE_I8: u32 = 24; +/// `GGML_TYPE_I16` — 16-bit signed integer. +pub const GGML_TYPE_I16: u32 = 25; +/// `GGML_TYPE_I32` — 32-bit signed integer. +pub const GGML_TYPE_I32: u32 = 26; +/// `GGML_TYPE_I64` — 64-bit signed integer. +pub const GGML_TYPE_I64: u32 = 27; +/// `GGML_TYPE_F64` — 64-bit IEEE-754 double float. +pub const GGML_TYPE_F64: u32 = 28; +/// `GGML_TYPE_IQ1_M` — i-quant 1-bit medium. +pub const GGML_TYPE_IQ1_M: u32 = 29; +/// `GGML_TYPE_BF16` — Google Brain bfloat16. +pub const GGML_TYPE_BF16: u32 = 30; +/// GGUF wire type 31: historical `Q4_0_4_4` layout (removed from current ggml). +/// +/// Must **not** be treated as an IQ3_M block type. HuggingFace “IQ3_M” is a +/// mixed-quant *preset*, not wire id 31. Corinth-canal documents the same +/// mapping (`GGML_TYPE_Q4_0_4_4 = 31`); its 111-byte IQ3_M path is an +/// **internal** non-wire id only. +pub const GGML_TYPE_Q4_0_4_4: u32 = 31; + +// --------------------------------------------------------------------------- +// Human-readable label helper. +// --------------------------------------------------------------------------- + +/// Map a raw GGML `ggml_type` `u32` code to a short human-readable label. +/// +/// Returns `"unknown"` for codes not in the known set. This is a pure +/// function with no side effects — safe to call from diagnostics, `Debug` +/// impls, or logging. +/// +/// # Examples +/// +/// ``` +/// use engram_parser::ggml_type_label; +/// assert_eq!(ggml_type_label(0), "F32"); +/// assert_eq!(ggml_type_label(31), "Q4_0_4_4"); +/// assert_eq!(ggml_type_label(9999), "unknown"); +/// ``` +pub fn ggml_type_label(ggml_type: u32) -> &'static str { + match ggml_type { + GGML_TYPE_F32 => "F32", + GGML_TYPE_F16 => "F16", + GGML_TYPE_Q4_0 => "Q4_0", + GGML_TYPE_Q4_1 => "Q4_1", + GGML_TYPE_Q5_0 => "Q5_0", + GGML_TYPE_Q5_1 => "Q5_1", + GGML_TYPE_Q8_0 => "Q8_0", + GGML_TYPE_Q8_1 => "Q8_1", + GGML_TYPE_Q2_K => "Q2_K", + GGML_TYPE_Q3_K => "Q3_K", + GGML_TYPE_Q4_K => "Q4_K", + GGML_TYPE_Q5_K => "Q5_K", + GGML_TYPE_Q6_K => "Q6_K", + GGML_TYPE_Q8_K => "Q8_K", + GGML_TYPE_IQ2_XXS => "IQ2_XXS", + GGML_TYPE_IQ2_XS => "IQ2_XS", + GGML_TYPE_IQ3_XXS => "IQ3_XXS", + GGML_TYPE_IQ1_S => "IQ1_S", + GGML_TYPE_IQ4_NL => "IQ4_NL", + GGML_TYPE_IQ3_S => "IQ3_S", + GGML_TYPE_IQ2_S => "IQ2_S", + GGML_TYPE_IQ4_XS => "IQ4_XS", + GGML_TYPE_I8 => "I8", + GGML_TYPE_I16 => "I16", + GGML_TYPE_I32 => "I32", + GGML_TYPE_I64 => "I64", + GGML_TYPE_F64 => "F64", + GGML_TYPE_IQ1_M => "IQ1_M", + GGML_TYPE_BF16 => "BF16", + GGML_TYPE_Q4_0_4_4 => "Q4_0_4_4", + _ => "unknown", + } +} + +// --------------------------------------------------------------------------- +// DType enum. +// --------------------------------------------------------------------------- + /// GGML tensor dtype codes encountered in GGUF checkpoints. /// /// Values mirror the `GGML_TYPE_*` constants in `ggml.h`. The parser /// understands their byte layout (for bounds checking) but performs no /// arithmetic — raw bytes are returned as-is. `BF16` layout parsing is /// supported even though no BF16→F32 conversion is provided. +/// +/// Types not explicitly enumerated are captured by [`DType::Other`] +/// which preserves the raw code for callers to dispatch on. #[allow(non_camel_case_types)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DType { - /// 32-bit little-endian float. + /// 32-bit little-endian float (`GGML_TYPE_F32 = 0`). F32, - /// 16-bit IEEE-754 half float. + /// 16-bit IEEE-754 half float (`GGML_TYPE_F16 = 1`). F16, - /// Google Brain bfloat16 (`GGML_TYPE_BF16 = 30`). - BF16, - /// `GGML_TYPE_Q8_0` blocked 8-bit quantization. + /// `GGML_TYPE_Q4_0` — 4-bit symmetric quantization (block size 32). + Q4_0, + /// `GGML_TYPE_Q4_1` — 4-bit quantization with min (block size 32). + Q4_1, + /// `GGML_TYPE_Q5_0` — 5-bit symmetric quantization (block size 32). + Q5_0, + /// `GGML_TYPE_Q5_1` — 5-bit quantization with min (block size 32). + Q5_1, + /// `GGML_TYPE_Q8_0` — 8-bit symmetric quantization (block size 32). Q8_0, - /// `GGML_TYPE_Q5_K` k-quant. - Q5_K, - /// `GGML_TYPE_Q4_K` k-quant. + /// `GGML_TYPE_Q8_1` — 8-bit quantization with min (block size 32). + Q8_1, + /// `GGML_TYPE_Q2_K` — k-quant 2-bit. + Q2_K, + /// `GGML_TYPE_Q3_K` — k-quant 3-bit. + Q3_K, + /// `GGML_TYPE_Q4_K` — k-quant 4-bit. Q4_K, - /// `GGML_TYPE_Q6_K` k-quant. + /// `GGML_TYPE_Q5_K` — k-quant 5-bit. + Q5_K, + /// `GGML_TYPE_Q6_K` — k-quant 6-bit. Q6_K, - /// `GGML_TYPE_IQ3_S` i-quant (3.44 bpw). + /// `GGML_TYPE_Q8_K` — k-quant 8-bit. + Q8_K, + /// `GGML_TYPE_IQ2_XXS` — i-quant 2-bit extra-extra-small (wire layout only). + IQ2_XXS, + /// `GGML_TYPE_IQ2_XS` — i-quant 2-bit extra-small. + IQ2_XS, + /// `GGML_TYPE_IQ3_XXS` — i-quant 3-bit extra-extra-small. + IQ3_XXS, + /// `GGML_TYPE_IQ1_S` — i-quant 1-bit small. + IQ1_S, + /// `GGML_TYPE_IQ4_NL` — i-quant 4-bit non-linear (block size 32). + IQ4_NL, + /// `GGML_TYPE_IQ3_S` — i-quant 3-bit small. IQ3_S, + /// `GGML_TYPE_IQ2_S` — i-quant 2-bit small. + IQ2_S, + /// `GGML_TYPE_IQ4_XS` — i-quant 4-bit extra-small. + IQ4_XS, + /// `GGML_TYPE_IQ1_M` — i-quant 1-bit medium. + IQ1_M, + /// Google Brain bfloat16 (`GGML_TYPE_BF16 = 30`). + BF16, + /// 64-bit IEEE-754 double float (`GGML_TYPE_F64 = 28`). + F64, + /// 8-bit signed integer (`GGML_TYPE_I8 = 24`). + I8, + /// 16-bit signed integer (`GGML_TYPE_I16 = 25`). + I16, + /// 32-bit signed integer (`GGML_TYPE_I32 = 26`). + I32, + /// 64-bit signed integer (`GGML_TYPE_I64 = 27`). + I64, /// Any other GGML dtype not explicitly enumerated above. The raw /// `u32` code is preserved so callers can dispatch on it. Other(u32), } -/// `GGML_TYPE_*` constants used to map raw u32 codes to [`DType`]. -pub const GGML_TYPE_F32: u32 = 0; -pub const GGML_TYPE_F16: u32 = 1; -pub const GGML_TYPE_Q4_K: u32 = 12; -pub const GGML_TYPE_Q5_K: u32 = 13; -pub const GGML_TYPE_Q6_K: u32 = 14; -pub const GGML_TYPE_Q8_0: u32 = 8; -pub const GGML_TYPE_IQ3_S: u32 = 21; -pub const GGML_TYPE_BF16: u32 = 30; - impl DType { /// Map a raw `ggml_type` code to a [`DType`] enum. pub fn from_ggml_type(code: u32) -> Self { match code { GGML_TYPE_F32 => Self::F32, GGML_TYPE_F16 => Self::F16, - GGML_TYPE_BF16 => Self::BF16, + GGML_TYPE_Q4_0 => Self::Q4_0, + GGML_TYPE_Q4_1 => Self::Q4_1, + GGML_TYPE_Q5_0 => Self::Q5_0, + GGML_TYPE_Q5_1 => Self::Q5_1, GGML_TYPE_Q8_0 => Self::Q8_0, - GGML_TYPE_Q5_K => Self::Q5_K, + GGML_TYPE_Q8_1 => Self::Q8_1, + GGML_TYPE_Q2_K => Self::Q2_K, + GGML_TYPE_Q3_K => Self::Q3_K, GGML_TYPE_Q4_K => Self::Q4_K, + GGML_TYPE_Q5_K => Self::Q5_K, GGML_TYPE_Q6_K => Self::Q6_K, + GGML_TYPE_Q8_K => Self::Q8_K, + GGML_TYPE_IQ2_XXS => Self::IQ2_XXS, + GGML_TYPE_IQ2_XS => Self::IQ2_XS, + GGML_TYPE_IQ3_XXS => Self::IQ3_XXS, + GGML_TYPE_IQ1_S => Self::IQ1_S, + GGML_TYPE_IQ4_NL => Self::IQ4_NL, GGML_TYPE_IQ3_S => Self::IQ3_S, + GGML_TYPE_IQ2_S => Self::IQ2_S, + GGML_TYPE_IQ4_XS => Self::IQ4_XS, + GGML_TYPE_IQ1_M => Self::IQ1_M, + // Wire 31 is historical Q4_0_4_4: fall through to Other(31) via `other`. + GGML_TYPE_BF16 => Self::BF16, + GGML_TYPE_F64 => Self::F64, + GGML_TYPE_I8 => Self::I8, + GGML_TYPE_I16 => Self::I16, + GGML_TYPE_I32 => Self::I32, + GGML_TYPE_I64 => Self::I64, other => Self::Other(other), } } @@ -70,16 +263,83 @@ impl DType { match self { Self::F32 => GGML_TYPE_F32, Self::F16 => GGML_TYPE_F16, - Self::BF16 => GGML_TYPE_BF16, + Self::Q4_0 => GGML_TYPE_Q4_0, + Self::Q4_1 => GGML_TYPE_Q4_1, + Self::Q5_0 => GGML_TYPE_Q5_0, + Self::Q5_1 => GGML_TYPE_Q5_1, Self::Q8_0 => GGML_TYPE_Q8_0, - Self::Q5_K => GGML_TYPE_Q5_K, + Self::Q8_1 => GGML_TYPE_Q8_1, + Self::Q2_K => GGML_TYPE_Q2_K, + Self::Q3_K => GGML_TYPE_Q3_K, Self::Q4_K => GGML_TYPE_Q4_K, + Self::Q5_K => GGML_TYPE_Q5_K, Self::Q6_K => GGML_TYPE_Q6_K, + Self::Q8_K => GGML_TYPE_Q8_K, + Self::IQ2_XXS => GGML_TYPE_IQ2_XXS, + Self::IQ2_XS => GGML_TYPE_IQ2_XS, + Self::IQ3_XXS => GGML_TYPE_IQ3_XXS, + Self::IQ1_S => GGML_TYPE_IQ1_S, + Self::IQ4_NL => GGML_TYPE_IQ4_NL, Self::IQ3_S => GGML_TYPE_IQ3_S, + Self::IQ2_S => GGML_TYPE_IQ2_S, + Self::IQ4_XS => GGML_TYPE_IQ4_XS, + Self::IQ1_M => GGML_TYPE_IQ1_M, + Self::BF16 => GGML_TYPE_BF16, + Self::F64 => GGML_TYPE_F64, + Self::I8 => GGML_TYPE_I8, + Self::I16 => GGML_TYPE_I16, + Self::I32 => GGML_TYPE_I32, + Self::I64 => GGML_TYPE_I64, Self::Other(code) => code, } } + /// Short human-readable label for this dtype (e.g. `"F32"`, `"Q4_K"`). + /// + /// Single source of truth: [`ggml_type_label`] on the wire code. + pub fn label(self) -> &'static str { + ggml_type_label(self.ggml_type()) + } + + /// Quantization block length along the innermost GGUF dimension, if any. + /// + /// Used to reject shapes whose `dims[0]` cannot form complete blocks. + /// `None` for dense/integer types and unknown/`Other` codes. + pub fn quant_block_size(self) -> Option { + match self { + Self::Q4_0 + | Self::Q4_1 + | Self::Q5_0 + | Self::Q5_1 + | Self::Q8_0 + | Self::Q8_1 + | Self::IQ4_NL => Some(32), + Self::Q2_K + | Self::Q3_K + | Self::Q4_K + | Self::Q5_K + | Self::Q6_K + | Self::Q8_K + | Self::IQ2_XXS + | Self::IQ2_XS + | Self::IQ2_S + | Self::IQ3_XXS + | Self::IQ3_S + | Self::IQ1_S + | Self::IQ1_M + | Self::IQ4_XS => Some(256), + Self::F32 + | Self::F16 + | Self::BF16 + | Self::F64 + | Self::I8 + | Self::I16 + | Self::I32 + | Self::I64 + | Self::Other(_) => None, + } + } + /// Size in bytes of `n_elements` values of this dtype, or `None` /// for quantized/unknown layouts whose byte-length depends on the /// tensor's inner dimension (not a simple `n * sizeof(T)`). @@ -87,26 +347,73 @@ impl DType { /// For quantized dtypes we return the correct blocked byte count /// when the total element count is divisible by the block size; /// otherwise `None`. + /// + /// Block sizes follow the GGUF / llama.cpp wire layouts (`ggml-common.h`): + /// - Q4_0/Q4_1/Q5_0/Q5_1/Q8_0/Q8_1/IQ4_NL: block size 32 + /// - K-quants and most IQ types: block size 256 + /// - Wire type 31 (`Q4_0_4_4`) is **not** modeled: use [`DType::Other`] + /// + /// This is layout sizing only — no dequantization. pub fn byte_len_for_elements(self, n_elements: usize) -> Option { match self { Self::F32 => Some(n_elements.checked_mul(4)?), Self::F16 | Self::BF16 => Some(n_elements.checked_mul(2)?), - Self::Q8_0 => block_bytes(n_elements, 32, 2 + 32), - Self::Q5_K => block_bytes(n_elements, 256, 2 + 2 + 12 + 32 + 128), - Self::Q4_K => block_bytes(n_elements, 256, 2 + 2 + 12 + 128), - Self::Q6_K => block_bytes(n_elements, 256, 128 + 64 + 16 + 2), - // Unknown / unsupported quantizations: byte length cannot - // be derived without the ggml block descriptor. - Self::IQ3_S | Self::Other(_) => None, + Self::F64 | Self::I64 => Some(n_elements.checked_mul(8)?), + Self::I32 => Some(n_elements.checked_mul(4)?), + Self::I16 => Some(n_elements.checked_mul(2)?), + Self::I8 => Some(n_elements), + // Q*_0/Q*_1 blocked quants: block size 32. + // Q4_0: 18, Q4_1: 20, Q5_0: 22, Q5_1: 24, Q8_0: 34, Q8_1: 36 + Self::Q4_0 => blocked_byte_len(n_elements, 32, 18), + Self::Q4_1 => blocked_byte_len(n_elements, 32, 20), + Self::Q5_0 => blocked_byte_len(n_elements, 32, 22), + Self::Q5_1 => blocked_byte_len(n_elements, 32, 24), + Self::Q8_0 => blocked_byte_len(n_elements, 32, 34), + Self::Q8_1 => blocked_byte_len(n_elements, 32, 36), + // K-quants: block size 256. + // Q2_K: 84, Q3_K: 110, Q4_K: 144, Q5_K: 176, Q6_K: 210, Q8_K: 292 + Self::Q2_K => blocked_byte_len(n_elements, 256, 84), + Self::Q3_K => blocked_byte_len(n_elements, 256, 110), + Self::Q4_K => blocked_byte_len(n_elements, 256, 144), + Self::Q5_K => blocked_byte_len(n_elements, 256, 176), + Self::Q6_K => blocked_byte_len(n_elements, 256, 210), + Self::Q8_K => blocked_byte_len(n_elements, 256, 292), + // IQ wire layouts (llama.cpp `block_iq*`, QK_K=256 unless noted): + // IQ2_XXS: 66, IQ2_XS: 74, IQ2_S: 82 + // IQ3_XXS: 98, IQ3_S: 110 + // IQ1_S: 50, IQ1_M: 56 + // IQ4_NL: block 32 / 18 B, IQ4_XS: 136 + Self::IQ2_XXS => blocked_byte_len(n_elements, 256, 66), + Self::IQ2_XS => blocked_byte_len(n_elements, 256, 74), + Self::IQ2_S => blocked_byte_len(n_elements, 256, 82), + Self::IQ3_XXS => blocked_byte_len(n_elements, 256, 98), + Self::IQ3_S => blocked_byte_len(n_elements, 256, 110), + Self::IQ1_S => blocked_byte_len(n_elements, 256, 50), + Self::IQ1_M => blocked_byte_len(n_elements, 256, 56), + Self::IQ4_NL => blocked_byte_len(n_elements, 32, 18), + Self::IQ4_XS => blocked_byte_len(n_elements, 256, 136), + // Opaque / unknown (includes wire 31 Q4_0_4_4). + Self::Other(_) => None, } } + /// `true` if this crate models the wire layout of this dtype, i.e. every + /// variant except [`DType::Other`]. For blocked quants the element count + /// must also be block-aligned before [`Self::byte_len_for_elements`] + /// returns `Some`. + pub fn has_known_byte_layout(self) -> bool { + !matches!(self, Self::Other(_)) + } + /// Whether the dtype is a plain (non-quantized) float layout. + /// + /// Matches the 0.1 surface: `F32`, `F16`, and `BF16` (not `F64`). pub fn is_float(self) -> bool { matches!(self, Self::F32 | Self::F16 | Self::BF16) } - /// Byte width of a single element, or `None` for block-quantized dtypes. + /// Byte width of a single dense element, or `None` for block-quantized + /// / integer / unknown dtypes (same contract as 0.1). pub fn element_size(self) -> Option { match self { Self::F32 => Some(4), @@ -116,43 +423,51 @@ impl DType { } } -fn block_bytes(n_elements: usize, block_size: usize, block_bytes: usize) -> Option { +/// Compute the total byte length for a blocked quantization format. +/// +/// Returns `None` if `n_elements` is not divisible by `block_size` +/// or if the multiplication overflows. +fn blocked_byte_len(n_elements: usize, block_size: usize, bytes_per_block: usize) -> Option { if !n_elements.is_multiple_of(block_size) { return None; } - (n_elements / block_size).checked_mul(block_bytes) + let n_blocks = n_elements / block_size; + n_blocks.checked_mul(bytes_per_block) } -/// Tensor directory entry. +// --------------------------------------------------------------------------- +// Tensor directory entry. +// --------------------------------------------------------------------------- + +/// A single tensor's directory entry: name, shape, dtype, and offsets. /// -/// A `Tensor` is a lightweight descriptor — it does **not** own weight -/// data. Use [`GgufLayout::tensor_bytes`](super::layout::GgufLayout::tensor_bytes) -/// to fetch the raw payload slice for this tensor. +/// This is a metadata-only descriptor — it owns no weight data. Use +/// [`GgufLayout::tensor_bytes`](super::layout::GgufLayout::tensor_bytes) +/// to obtain the raw payload. #[derive(Debug, Clone)] pub struct Tensor { - /// Name of the tensor as stored in the GGUF directory - /// (e.g. `"blk.0.ffn_gate_exps.weight"`). + /// Full tensor name as stored in the GGUF directory. pub name: String, - /// Dimensions, in GGML order (innermost first). + /// Shape dimensions (GGML innermost-first order). pub dims: Vec, - /// Parsed dtype. + /// Parsed dtype enum. pub dtype: DType, - /// Raw `ggml_type` code as stored in the file. + /// Raw `ggml_type` code (preserved for round-tripping). pub ggml_type: u32, /// Total number of elements (product of `dims`). pub n_elements: usize, - /// Byte length of the tensor payload. + /// Total byte length of the tensor payload. pub byte_len: usize, - /// Byte offset of the payload relative to the tensor-data section. + /// Offset relative to the tensor data region start. pub relative_offset: usize, - /// Absolute byte offset of the payload within the file (filled in - /// after the data section start is resolved). + /// Absolute byte offset within the file buffer. pub absolute_offset: usize, } impl Tensor { - /// Decode F32 tensor bytes into a `Vec` using little-endian - /// chunk parsing (no `unsafe` reinterpretation). + /// Read the raw tensor bytes as little-endian `f32` values. + /// + /// Only valid for [`DType::F32`] tensors; returns an error otherwise. pub fn read_f32_values(&self, bytes: &[u8]) -> Result> { if self.dtype != DType::F32 { return Err(ParserError::UnsupportedFormat { @@ -245,3 +560,294 @@ fn f16_payload_bits(exp: u32, mant: u32) -> u32 { biased => ((biased + 127 - 15) << 23) | mant, } } + +// --------------------------------------------------------------------------- +// Unit tests. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dtype_round_trips_through_ggml_type() { + let variants = [ + DType::F32, + DType::F16, + DType::Q4_0, + DType::Q4_1, + DType::Q5_0, + DType::Q5_1, + DType::Q8_0, + DType::Q8_1, + DType::Q2_K, + DType::Q3_K, + DType::Q4_K, + DType::Q5_K, + DType::Q6_K, + DType::Q8_K, + DType::IQ2_XXS, + DType::IQ2_XS, + DType::IQ3_XXS, + DType::IQ1_S, + DType::IQ4_NL, + DType::IQ3_S, + DType::IQ2_S, + DType::IQ4_XS, + DType::IQ1_M, + DType::BF16, + DType::F64, + DType::I8, + DType::I16, + DType::I32, + DType::I64, + ]; + for dt in variants { + let code = dt.ggml_type(); + let back = DType::from_ggml_type(code); + assert_eq!(dt, back, "round-trip failed for {dt:?} (code={code})"); + } + } + + #[test] + fn unknown_code_becomes_other() { + let dt = DType::from_ggml_type(9999); + let checks = [ + (dt == DType::Other(9999), "variant"), + (dt.ggml_type() == 9999, "ggml_type"), + (dt.byte_len_for_elements(100).is_none(), "byte_len"), + (!dt.has_known_byte_layout(), "layout"), + ]; + for (ok, label) in checks { + assert!(ok, "unknown code 9999: {label}"); + } + } + + #[test] + fn ggml_type_label_known_codes() { + let cases = [ + (GGML_TYPE_F32, "F32"), + (GGML_TYPE_F16, "F16"), + (GGML_TYPE_Q4_0, "Q4_0"), + (GGML_TYPE_Q8_0, "Q8_0"), + (GGML_TYPE_Q2_K, "Q2_K"), + (GGML_TYPE_Q6_K, "Q6_K"), + (GGML_TYPE_IQ3_S, "IQ3_S"), + (GGML_TYPE_Q4_0_4_4, "Q4_0_4_4"), + (GGML_TYPE_BF16, "BF16"), + (GGML_TYPE_F64, "F64"), + (GGML_TYPE_I8, "I8"), + (GGML_TYPE_I16, "I16"), + (GGML_TYPE_I32, "I32"), + (GGML_TYPE_I64, "I64"), + (GGML_TYPE_IQ1_M, "IQ1_M"), + (GGML_TYPE_IQ1_S, "IQ1_S"), + (GGML_TYPE_IQ2_XXS, "IQ2_XXS"), + (GGML_TYPE_IQ2_XS, "IQ2_XS"), + (GGML_TYPE_IQ2_S, "IQ2_S"), + (GGML_TYPE_IQ3_XXS, "IQ3_XXS"), + (GGML_TYPE_IQ4_NL, "IQ4_NL"), + (GGML_TYPE_IQ4_XS, "IQ4_XS"), + (GGML_TYPE_Q4_1, "Q4_1"), + (GGML_TYPE_Q5_0, "Q5_0"), + (GGML_TYPE_Q5_1, "Q5_1"), + (GGML_TYPE_Q8_1, "Q8_1"), + (GGML_TYPE_Q3_K, "Q3_K"), + (GGML_TYPE_Q4_K, "Q4_K"), + (GGML_TYPE_Q5_K, "Q5_K"), + (GGML_TYPE_Q8_K, "Q8_K"), + ]; + for (code, expected) in cases { + assert_eq!(ggml_type_label(code), expected, "label for code {code}"); + } + } + + #[test] + fn ggml_type_label_unknown() { + assert_eq!(ggml_type_label(9999), "unknown"); + assert_eq!(ggml_type_label(u32::MAX), "unknown"); + } + + #[test] + fn dtype_label_matches_ggml_type_label() { + let variants = [ + DType::F32, + DType::F16, + DType::Q4_0, + DType::Q8_0, + DType::IQ3_S, + DType::BF16, + DType::F64, + DType::I8, + DType::I16, + DType::I32, + DType::I64, + ]; + for dt in variants { + assert_eq!( + dt.label(), + ggml_type_label(dt.ggml_type()), + "label mismatch for {dt:?}" + ); + } + } + + #[test] + fn dtype_label_other_delegates() { + let dt = DType::Other(9999); + assert_eq!(dt.label(), "unknown"); + + // An Other wrapping a known code should return the known label. + let dt2 = DType::Other(GGML_TYPE_IQ4_NL); + assert_eq!(dt2.label(), "IQ4_NL"); + } + + #[test] + fn byte_len_for_simple_types() { + let cases = [ + (DType::F32, 100, Some(400)), + (DType::F16, 100, Some(200)), + (DType::BF16, 100, Some(200)), + (DType::F64, 10, Some(80)), + (DType::I8, 10, Some(10)), + (DType::I16, 10, Some(20)), + (DType::I32, 10, Some(40)), + (DType::I64, 10, Some(80)), + ]; + for (dt, n, expected) in cases { + assert_eq!(dt.byte_len_for_elements(n), expected, "{dt:?} x {n}"); + } + } + + #[test] + fn byte_len_for_blocked_quants() { + let cases = [ + // Q4_0: block_size=32, 18 bytes per block + (DType::Q4_0, 32, Some(18)), + (DType::Q4_0, 64, Some(36)), + (DType::Q4_0, 33, None), + // Q8_0: block_size=32, 34 bytes per block + (DType::Q8_0, 32, Some(34)), + // Q4_K: block_size=256, 144 bytes per block + (DType::Q4_K, 256, Some(144)), + (DType::Q4_K, 128, None), + // Q6_K: block_size=256, 210 bytes per block + (DType::Q6_K, 256, Some(210)), + // Q8_K: block_size=256, 292 bytes per block + (DType::Q8_K, 256, Some(292)), + ]; + for (dt, n, expected) in cases { + assert_eq!(dt.byte_len_for_elements(n), expected, "{dt:?} x {n}"); + } + } + + #[test] + fn byte_len_for_iq_quants() { + // Wire layouts from llama.cpp ggml-common.h (QK_K=256). + let cases = [ + (DType::IQ2_XXS, 256, Some(66)), + (DType::IQ2_XS, 256, Some(74)), + (DType::IQ2_S, 256, Some(82)), + (DType::IQ3_XXS, 256, Some(98)), + (DType::IQ3_S, 256, Some(110)), + (DType::IQ3_S, 512, Some(220)), + (DType::IQ3_S, 100, None), + (DType::IQ1_S, 256, Some(50)), + (DType::IQ1_M, 256, Some(56)), + (DType::IQ4_NL, 32, Some(18)), + (DType::IQ4_NL, 64, Some(36)), + (DType::IQ4_NL, 33, None), + (DType::IQ4_XS, 256, Some(136)), + ]; + for (dt, n, expected) in cases { + assert_eq!(dt.byte_len_for_elements(n), expected, "{dt:?} x {n}"); + } + } + + #[test] + fn has_known_byte_layout_check() { + let cases = [ + (DType::F32, true), + (DType::Q8_0, true), + (DType::Q4_K, true), + (DType::IQ3_S, true), + (DType::IQ2_XXS, true), + (DType::IQ4_NL, true), + (DType::Other(31), false), + (DType::Other(99), false), + ]; + for (dt, expected) in cases { + assert_eq!(dt.has_known_byte_layout(), expected, "{dt:?}"); + } + } + + #[test] + fn ggml_type_constants_match_values() { + let cases = [ + (GGML_TYPE_F32, 0), + (GGML_TYPE_F16, 1), + (GGML_TYPE_Q4_0, 2), + (GGML_TYPE_Q4_1, 3), + (GGML_TYPE_Q5_0, 6), + (GGML_TYPE_Q5_1, 7), + (GGML_TYPE_Q8_0, 8), + (GGML_TYPE_Q8_1, 9), + (GGML_TYPE_Q2_K, 10), + (GGML_TYPE_Q3_K, 11), + (GGML_TYPE_Q4_K, 12), + (GGML_TYPE_Q5_K, 13), + (GGML_TYPE_Q6_K, 14), + (GGML_TYPE_Q8_K, 15), + (GGML_TYPE_IQ2_XXS, 16), + (GGML_TYPE_IQ2_XS, 17), + (GGML_TYPE_IQ3_XXS, 18), + (GGML_TYPE_IQ1_S, 19), + (GGML_TYPE_IQ4_NL, 20), + (GGML_TYPE_IQ3_S, 21), + (GGML_TYPE_IQ2_S, 22), + (GGML_TYPE_IQ4_XS, 23), + (GGML_TYPE_I8, 24), + (GGML_TYPE_I16, 25), + (GGML_TYPE_I32, 26), + (GGML_TYPE_I64, 27), + (GGML_TYPE_F64, 28), + (GGML_TYPE_IQ1_M, 29), + (GGML_TYPE_BF16, 30), + (GGML_TYPE_Q4_0_4_4, 31), + ]; + for (constant, expected) in cases { + assert_eq!(constant, expected, "constant value mismatch"); + } + } + + #[test] + fn wire_type_31_is_q4_0_4_4_not_iq3_m() { + let dt = DType::from_ggml_type(31); + let label = ggml_type_label(31); + assert!( + GGML_TYPE_Q4_0_4_4 == 31 + && label == "Q4_0_4_4" + && label != "IQ3_M" + && dt == DType::Other(31) + && dt.byte_len_for_elements(256).is_none() + && !dt.has_known_byte_layout(), + "wire 31 semantics: label={label}, dt={dt:?}" + ); + } + + #[test] + fn f16_to_f32_known_values() { + let finite = [(0x0000, 0.0), (0x3C00, 1.0), (0xBC00, -1.0)]; + for (bits, expected) in finite { + assert_eq!(f16_bits_to_f32(bits), expected, "bits {bits:#06X}"); + } + + for (bits, positive) in [(0x7C00, true), (0xFC00, false)] { + let v = f16_bits_to_f32(bits); + assert!( + v.is_infinite() && v.is_sign_positive() == positive, + "bits {bits:#06X}: {v}" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1f8f436..4846c25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,35 +1,103 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -//! # engram-parser +//! Pure-Rust, zero-dependency GGUF parser with MoE support. //! -//! Pure-Rust, **zero-dependency** `.gguf` deserializer and -//! Mixture-of-Experts per-expert weight extractor. +//! This crate parses GGUF (GPT-Generated Unified Format) v3 files, +//! extracts metadata and tensor information, and provides utilities +//! for Mixture of Experts (MoE) model analysis. //! -//! This crate performs **no** neural-network math: it parses the GGUF -//! file format, exposes a tensor directory, and can rip out the raw -//! byte buffers for any single expert's `gate` / `up` / `down` -//! projection. Downstream crates (e.g. SNN or dense inference engines) -//! are responsible for anything involving arithmetic on those bytes. +//! # Features //! -//! ## Quick start +//! - **Zero dependencies**: Pure Rust implementation with no external crates +//! - **GGUF v3 support**: Full parsing of headers, metadata, and tensor directories +//! - **GGUF wire-type metadata**: labels + packed `byte_len` for known quant +//! codes (F32/F16/BF16, Q*/IQ*, integers, historical wire 31 = `Q4_0_4_4`). +//! **No dequant, no GGML kernels, no ggml runtime** — only what the GGUF +//! directory needs for in-range payloads and MoE raw slices. +//! - **Type labels**: Human-readable names via [`ggml_type_label`] (maps the +//! on-wire `ggml_type` integer used by GGUF) +//! - **MoE support**: Extract expert **raw** weights (byte buffers + shape) +//! - **Metadata helpers**: Architecture-aware convenience methods for common fields +//! +//! # Example //! //! ```no_run -//! use engram_parser::{extract_expert, load_gguf}; +//! use engram_parser::{load_gguf, ggml_type_label}; +//! +//! let layout = load_gguf("model.gguf").unwrap(); +//! println!("Architecture: {}", layout.metadata.architecture()); +//! println!("Quantization: {}", layout.metadata.quantization()); //! -//! let layout = load_gguf("./model.gguf")?; -//! println!("architecture = {}", layout.metadata.architecture()); +//! if let Some(block_count) = layout.metadata.block_count() { +//! println!("Blocks: {}", block_count); +//! } //! -//! let expert = extract_expert(&layout, 0, 3)?; -//! if let Some(gate) = &expert.gate { -//! println!("expert gate: dims={:?} dtype={:?} bytes={}", gate.dims, gate.dtype, gate.bytes.len()); +//! for (name, tensor) in &layout.tensors { +//! println!("{}: {:?} (type: {})", +//! name, tensor.dims, +//! ggml_type_label(tensor.ggml_type)); //! } -//! # Ok::<(), engram_parser::ParserError>(()) //! ``` pub mod error; pub mod gguf; pub mod moe; +// Re-export commonly used types at the crate root for convenience. pub use error::{ParserError, Result}; -pub use gguf::{DType, GgufLayout, GgufMetadata, Tensor, f16_bits_to_f32, load_gguf, parse_bytes}; +pub use gguf::{ + DType, + // GGML type constants + GGML_TYPE_BF16, + GGML_TYPE_F16, + GGML_TYPE_F32, + GGML_TYPE_F64, + GGML_TYPE_I8, + GGML_TYPE_I16, + GGML_TYPE_I32, + GGML_TYPE_I64, + GGML_TYPE_IQ1_M, + GGML_TYPE_IQ1_S, + GGML_TYPE_IQ2_S, + GGML_TYPE_IQ2_XS, + GGML_TYPE_IQ2_XXS, + GGML_TYPE_IQ3_S, + GGML_TYPE_IQ3_XXS, + GGML_TYPE_IQ4_NL, + GGML_TYPE_IQ4_XS, + GGML_TYPE_Q2_K, + GGML_TYPE_Q3_K, + GGML_TYPE_Q4_0, + GGML_TYPE_Q4_0_4_4, + GGML_TYPE_Q4_1, + GGML_TYPE_Q4_K, + GGML_TYPE_Q5_0, + GGML_TYPE_Q5_1, + GGML_TYPE_Q5_K, + GGML_TYPE_Q6_K, + GGML_TYPE_Q8_0, + GGML_TYPE_Q8_1, + GGML_TYPE_Q8_K, + // Metadata value type constants + GGUF_VALUE_TYPE_ARRAY, + GGUF_VALUE_TYPE_BOOL, + GGUF_VALUE_TYPE_FLOAT32, + GGUF_VALUE_TYPE_FLOAT64, + GGUF_VALUE_TYPE_INT8, + GGUF_VALUE_TYPE_INT16, + GGUF_VALUE_TYPE_INT32, + GGUF_VALUE_TYPE_INT64, + GGUF_VALUE_TYPE_STRING, + GGUF_VALUE_TYPE_UINT8, + GGUF_VALUE_TYPE_UINT16, + GGUF_VALUE_TYPE_UINT32, + GGUF_VALUE_TYPE_UINT64, + GgufLayout, + GgufMetadata, + Tensor, + f16_bits_to_f32, + ggml_type_label, + load_gguf, + parse_bytes, +}; pub use moe::{MoeExpertWeights, RawTensor, extract_expert, list_experts}; diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..d24d0be --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![allow(dead_code)] + +//! Shared test fixtures for synthetic GGUF bytes. + +pub const GGUF_MAGIC: [u8; 4] = *b"GGUF"; +pub const GGUF_VERSION: u32 = 3; +pub const ALIGNMENT: u32 = 32; + +// Value types. +pub const VT_UINT32: u32 = 4; +pub const VT_STRING: u32 = 8; + +// Dtypes (GGUF wire type ids). +pub const GGML_F32: u32 = 0; +pub const GGML_Q8_0: u32 = 8; +pub const GGML_Q4_K: u32 = 12; +pub const GGML_IQ3_S: u32 = 21; + +pub enum KvValue { + U32(u32), + Str(&'static str), + F32(f32), +} + +pub struct TensorSpec { + pub name: &'static str, + pub dims: Vec, + pub ggml_type: u32, + pub payload: Vec, +} + +pub fn push_u32(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()); +} + +pub fn push_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_le_bytes()); +} + +pub fn push_string(out: &mut Vec, s: &str) { + push_u64(out, s.len() as u64); + out.extend_from_slice(s.as_bytes()); +} + +pub fn push_kv_u32(out: &mut Vec, key: &str, v: u32) { + push_string(out, key); + push_u32(out, VT_UINT32); + push_u32(out, v); +} + +pub fn push_kv_string(out: &mut Vec, key: &str, v: &str) { + push_string(out, key); + push_u32(out, VT_STRING); + push_string(out, v); +} + +pub fn build_gguf(kv: &[(&str, KvValue)], tensors: &[TensorSpec]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&GGUF_MAGIC); + push_u32(&mut out, GGUF_VERSION); + push_u64(&mut out, tensors.len() as u64); + push_u64(&mut out, kv.len() as u64); + + for (key, value) in kv { + match value { + KvValue::U32(v) => push_kv_u32(&mut out, key, *v), + KvValue::Str(v) => push_kv_string(&mut out, key, v), + KvValue::F32(v) => { + push_string(&mut out, key); + push_u32(&mut out, 6); // VT_F32 + out.extend_from_slice(&v.to_le_bytes()); + } + } + } + + // Precompute aligned payload offsets and the payload bytes, mirroring real + // GGUF files where each tensor's data starts on an ALIGNMENT boundary. + let mut offsets = Vec::with_capacity(tensors.len()); + let mut payloads = Vec::new(); + for spec in tensors { + while payloads.len() % ALIGNMENT as usize != 0 { + payloads.push(0); + } + offsets.push(payloads.len()); + payloads.extend_from_slice(&spec.payload); + } + + // First pass: tensor directory with relative offsets. + for (i, spec) in tensors.iter().enumerate() { + push_string(&mut out, spec.name); + push_u32(&mut out, spec.dims.len() as u32); + for &d in &spec.dims { + push_u64(&mut out, d as u64); + } + push_u32(&mut out, spec.ggml_type); + push_u64(&mut out, offsets[i] as u64); + } + + // Align then append all tensor payloads. + while out.len() % ALIGNMENT as usize != 0 { + out.push(0); + } + out.extend_from_slice(&payloads); + out +} + +pub fn f32_vec_to_le_bytes(data: &[f32]) -> Vec { + let mut out = Vec::with_capacity(data.len() * 4); + for v in data { + out.extend_from_slice(&v.to_le_bytes()); + } + out +} + +/// Run a batch of boolean checks and fail with the failing labels. +/// +/// Keeps tests readable while satisfying static-analysis thresholds on +/// the number of top-level `assert!` calls in a single test function. +pub fn assert_all(checks: &[(bool, &str)]) { + let failures: Vec<_> = checks + .iter() + .filter(|(ok, _)| !ok) + .map(|(_, label)| *label) + .collect(); + assert!(failures.is_empty(), "checks failed: {failures:?}"); +} diff --git a/tests/gguf_smoke.rs b/tests/gguf_smoke.rs index 7605f72..d3f467e 100644 --- a/tests/gguf_smoke.rs +++ b/tests/gguf_smoke.rs @@ -4,96 +4,10 @@ //! and verify that expert extraction round-trips both the stacked and //! per-expert storage conventions. -use engram_parser::{DType, extract_expert, list_experts, parse_bytes}; - -const GGUF_MAGIC: [u8; 4] = *b"GGUF"; -const GGUF_VERSION: u32 = 3; -const ALIGNMENT: u32 = 32; - -// Value types. -const VT_UINT32: u32 = 4; -const VT_STRING: u32 = 8; - -// Dtypes. -const GGML_F32: u32 = 0; +mod common; +use common::*; -fn push_u32(out: &mut Vec, v: u32) { - out.extend_from_slice(&v.to_le_bytes()); -} -fn push_u64(out: &mut Vec, v: u64) { - out.extend_from_slice(&v.to_le_bytes()); -} -fn push_string(out: &mut Vec, s: &str) { - push_u64(out, s.len() as u64); - out.extend_from_slice(s.as_bytes()); -} -fn push_kv_u32(out: &mut Vec, key: &str, v: u32) { - push_string(out, key); - push_u32(out, VT_UINT32); - push_u32(out, v); -} -fn push_kv_string(out: &mut Vec, key: &str, v: &str) { - push_string(out, key); - push_u32(out, VT_STRING); - push_string(out, v); -} - -struct TensorSpec { - name: &'static str, - dims: Vec, - ggml_type: u32, - payload: Vec, -} - -fn build_gguf(kv: &[(&str, KvValue)], tensors: &[TensorSpec]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&GGUF_MAGIC); - push_u32(&mut out, GGUF_VERSION); - push_u64(&mut out, tensors.len() as u64); - push_u64(&mut out, kv.len() as u64); - - for (key, value) in kv { - match value { - KvValue::U32(v) => push_kv_u32(&mut out, key, *v), - KvValue::Str(v) => push_kv_string(&mut out, key, v), - } - } - - // First pass: tensor directory with relative offsets. - let mut cum: usize = 0; - for spec in tensors { - push_string(&mut out, spec.name); - push_u32(&mut out, spec.dims.len() as u32); - for &d in &spec.dims { - push_u64(&mut out, d as u64); - } - push_u32(&mut out, spec.ggml_type); - push_u64(&mut out, cum as u64); - cum += spec.payload.len(); - } - - // Align then write payloads. - while out.len() % ALIGNMENT as usize != 0 { - out.push(0); - } - for spec in tensors { - out.extend_from_slice(&spec.payload); - } - out -} - -enum KvValue { - U32(u32), - Str(&'static str), -} - -fn f32_vec_to_le_bytes(data: &[f32]) -> Vec { - let mut out = Vec::with_capacity(data.len() * 4); - for v in data { - out.extend_from_slice(&v.to_le_bytes()); - } - out -} +use engram_parser::{DType, extract_expert, list_experts, parse_bytes}; #[test] fn parses_magic_and_metadata() { @@ -111,13 +25,17 @@ fn parses_magic_and_metadata() { }]; let bytes = build_gguf(&kv, &tensors); let layout = parse_bytes(bytes, "mem://test".into()).expect("parse"); - assert_eq!(layout.metadata.architecture(), "olmoe"); - assert_eq!(layout.metadata.numeric("olmoe.expert_count"), Some(4)); - assert_eq!(layout.alignment, ALIGNMENT as usize); - assert!(layout.tensors.contains_key("token_embd.weight")); let t = &layout.tensors["token_embd.weight"]; - assert_eq!(t.dtype, DType::F32); - assert_eq!(t.dims, vec![4, 2]); + assert_all(&[ + (layout.metadata.architecture() == "olmoe", "architecture"), + ( + layout.metadata.numeric("olmoe.expert_count") == Some(4), + "expert_count", + ), + (layout.alignment == ALIGNMENT as usize, "alignment"), + (t.dtype == DType::F32, "token dtype"), + (t.dims == vec![4, 2], "token dims"), + ]); } #[test] @@ -176,24 +94,44 @@ fn extracts_stacked_expert_slices() { for e in 0..n_experts { let out = extract_expert(&layout, 0, e).expect("extract"); - assert_eq!(out.block, 0); - assert_eq!(out.expert, e); - let gate = out.gate.as_ref().expect("gate present"); - assert!(gate.stacked_slice); - assert_eq!(gate.dims, vec![inner, outer]); - assert_eq!(gate.bytes.len(), per_expert * 4); - let first = f32::from_le_bytes(gate.bytes[0..4].try_into().unwrap()); - assert!((first - ((e as f32) + 0.1)).abs() < 1e-6); - - let up = out.up.as_ref().expect("up present"); - let first_up = f32::from_le_bytes(up.bytes[0..4].try_into().unwrap()); - assert!((first_up - ((e as f32) + 0.2)).abs() < 1e-6); - - let down = out.down.as_ref().expect("down present"); - let first_down = f32::from_le_bytes(down.bytes[0..4].try_into().unwrap()); - assert!((first_down - ((e as f32) + 0.3)).abs() < 1e-6); - - assert!(out.is_complete()); + let gate_first = out + .gate + .as_ref() + .map(|g| f32::from_le_bytes(g.bytes[0..4].try_into().unwrap())); + let up_first = out + .up + .as_ref() + .map(|u| f32::from_le_bytes(u.bytes[0..4].try_into().unwrap())); + let down_first = out + .down + .as_ref() + .map(|d| f32::from_le_bytes(d.bytes[0..4].try_into().unwrap())); + assert_all(&[ + (out.block == 0 && out.expert == e, "expert ids"), + (out.is_complete(), "complete"), + ( + out.gate.as_ref().is_some_and(|g| g.stacked_slice), + "gate stacked", + ), + ( + out.gate.as_ref().is_some_and(|g| { + g.dims == vec![inner, outer] && g.bytes.len() == per_expert * 4 + }), + "gate shape", + ), + ( + gate_first.is_some_and(|f| (f - ((e as f32) + 0.1)).abs() < 1e-6), + "gate first", + ), + ( + up_first.is_some_and(|f| (f - ((e as f32) + 0.2)).abs() < 1e-6), + "up first", + ), + ( + down_first.is_some_and(|f| (f - ((e as f32) + 0.3)).abs() < 1e-6), + "down first", + ), + ]); } } @@ -255,16 +193,37 @@ fn extracts_per_expert_tensors() { assert_eq!(pairs, vec![(0, 0), (0, 1)]); let e0 = extract_expert(&layout, 0, 0).unwrap(); - let gate0 = e0.gate.as_ref().unwrap(); - assert!(!gate0.stacked_slice); - assert_eq!(gate0.source_name, "blk.0.ffn_gate.0.weight"); - let first = f32::from_le_bytes(gate0.bytes[0..4].try_into().unwrap()); - assert!((first - 10.0).abs() < 1e-6); + let gate0_first = e0 + .gate + .as_ref() + .map(|g| f32::from_le_bytes(g.bytes[0..4].try_into().unwrap())); let e1 = extract_expert(&layout, 0, 1).unwrap(); - let up1 = e1.up.as_ref().unwrap(); - let first = f32::from_le_bytes(up1.bytes[0..4].try_into().unwrap()); - assert!((first - 21.0).abs() < 1e-6); + let up1_first = e1 + .up + .as_ref() + .map(|u| f32::from_le_bytes(u.bytes[0..4].try_into().unwrap())); + + assert_all(&[ + ( + e0.gate.as_ref().is_some_and(|g| !g.stacked_slice), + "e0 gate not stacked", + ), + ( + e0.gate + .as_ref() + .is_some_and(|g| g.source_name == "blk.0.ffn_gate.0.weight"), + "e0 gate source", + ), + ( + gate0_first.is_some_and(|f| (f - 10.0).abs() < 1e-6), + "e0 gate first", + ), + ( + up1_first.is_some_and(|f| (f - 21.0).abs() < 1e-6), + "e1 up first", + ), + ]); } #[test] @@ -296,3 +255,502 @@ fn expert_out_of_range() { let msg = format!("{err}"); assert!(msg.contains("expert index out of range"), "got: {msg}"); } + +fn metadata_layout() -> engram_parser::GgufLayout { + let kv = [ + ("general.architecture", KvValue::Str("qwen2moe")), + ("general.quantization_type", KvValue::Str("Q4_K_M")), + ("qwen2moe.block_count", KvValue::U32(28)), + ("qwen2moe.expert_count", KvValue::U32(64)), + ("qwen2moe.expert_used_count", KvValue::U32(8)), + ("qwen2moe.embedding_length", KvValue::U32(2048)), + ("qwen2moe.attention.head_count", KvValue::U32(16)), + ("qwen2moe.rope_freq_base", KvValue::F32(10_000.0)), + ("general.name", KvValue::Str("Qwen2-MoE-A2.7B")), + ]; + parse_bytes(build_gguf(&kv, &[]), "mem://metadata".into()).expect("parse") +} + +#[test] +fn metadata_helpers_basic() { + let layout = metadata_layout(); + let rope_freq = layout.metadata.float32("qwen2moe.rope_freq_base").unwrap(); + assert_all(&[ + (layout.metadata.architecture() == "qwen2moe", "architecture"), + (layout.metadata.quantization() == "Q4_K_M", "quantization"), + ( + layout.metadata.string("general.name") == Some("Qwen2-MoE-A2.7B"), + "name", + ), + ((rope_freq - 10_000.0).abs() < 1e-6, "rope_freq_base"), + ]); +} + +#[test] +fn metadata_helpers_counts() { + let layout = metadata_layout(); + assert_eq!(layout.metadata.block_count(), Some(28)); + assert_eq!(layout.metadata.expert_count(), Some(64)); + assert_eq!(layout.metadata.expert_used_count(), Some(8)); +} + +#[test] +fn metadata_helpers_geometry() { + let layout = metadata_layout(); + assert_eq!(layout.metadata.embedding_length(), Some(2048)); + assert_eq!(layout.metadata.head_count(), Some(16)); +} + +#[test] +fn metadata_helpers_missing() { + let layout = metadata_layout(); + assert_eq!(layout.metadata.string("nonexistent"), None); + assert_eq!(layout.metadata.float32("nonexistent"), None); +} + +#[test] +fn metadata_helpers_with_alternative_keys() { + let kv = [ + ("general.architecture", KvValue::Str("mixtral")), + ("mixtral.num_experts", KvValue::U32(8)), + ("mixtral.num_experts_per_tok", KvValue::U32(2)), + ]; + let layout = parse_bytes(build_gguf(&kv, &[]), "mem://alt-keys".into()).expect("parse"); + + assert_eq!(layout.metadata.expert_count(), Some(8)); + assert_eq!(layout.metadata.expert_used_count(), Some(2)); +} + +#[test] +fn metadata_helpers_with_unknown_architecture() { + let kv = [("some.block_count", KvValue::U32(10))]; + let layout = parse_bytes(build_gguf(&kv, &[]), "mem://no-arch".into()).expect("parse"); + + assert_all(&[ + (layout.metadata.architecture() == "unknown", "architecture"), + (layout.metadata.quantization() == "unknown", "quantization"), + (layout.metadata.block_count().is_none(), "block_count"), + (layout.metadata.expert_count().is_none(), "expert_count"), + ]); +} + +#[test] +fn ggml_type_label_function() { + use engram_parser::ggml_type_label; + + let cases = [ + (0, "F32"), + (1, "F16"), + (2, "Q4_0"), + (3, "Q4_1"), + (8, "Q8_0"), + (10, "Q2_K"), + (12, "Q4_K"), + (13, "Q5_K"), + (14, "Q6_K"), + (21, "IQ3_S"), + (30, "BF16"), + (31, "Q4_0_4_4"), + ]; + for (code, expected) in cases { + assert_eq!(ggml_type_label(code), expected, "label for code {code}"); + } + + assert_eq!(ggml_type_label(999), "unknown"); +} + +#[test] +fn dtype_f32_roundtrip() { + use engram_parser::DType; + + let dt = DType::from_ggml_type(0); + assert_all(&[ + (dt == DType::F32, "variant"), + (dt.ggml_type() == 0, "ggml_type"), + (dt.byte_len_for_elements(100) == Some(400), "byte_len"), + (dt.has_known_byte_layout(), "layout"), + ]); +} + +#[test] +fn dtype_q4k_layout() { + use engram_parser::DType; + + let dt = DType::from_ggml_type(12); + assert_all(&[ + (dt == DType::Q4_K, "variant"), + (dt.ggml_type() == 12, "ggml_type"), + (dt.byte_len_for_elements(256) == Some(144), "byte_len_256"), + (dt.byte_len_for_elements(512) == Some(288), "byte_len_512"), + ( + dt.byte_len_for_elements(100).is_none(), + "byte_len_misaligned", + ), + ]); +} + +#[test] +fn dtype_wire_31_is_other() { + use engram_parser::DType; + + let dt = DType::from_ggml_type(31); + assert_all(&[ + (dt == DType::Other(31), "variant"), + (dt.ggml_type() == 31, "ggml_type"), + (dt.byte_len_for_elements(256).is_none(), "byte_len"), + (!dt.has_known_byte_layout(), "layout"), + (dt.label() == "Q4_0_4_4", "label"), + ]); +} + +#[test] +fn dtype_unknown_is_other() { + use engram_parser::DType; + + let dt = DType::from_ggml_type(999); + assert_all(&[ + (dt == DType::Other(999), "variant"), + (dt.ggml_type() == 999, "ggml_type"), + (dt.byte_len_for_elements(100).is_none(), "byte_len"), + (!dt.has_known_byte_layout(), "layout"), + ]); +} + +#[test] +fn dtype_label_method() { + use engram_parser::DType; + + let cases = [ + (DType::F32, "F32"), + (DType::F16, "F16"), + (DType::Q4_0, "Q4_0"), + (DType::Q8_0, "Q8_0"), + (DType::Q4_K, "Q4_K"), + (DType::IQ3_S, "IQ3_S"), + (DType::Other(31), "Q4_0_4_4"), + (DType::BF16, "BF16"), + (DType::Other(999), "unknown"), + ]; + for (dt, expected) in cases { + assert_eq!(dt.label(), expected, "label for {dt:?}"); + } +} + +#[test] +fn tensor_with_wire_type_31_fails_closed() { + // Wire type 31 (Q4_0_4_4) has no modeled byte layout — parse must fail closed. + let inner = 256; + let outer = 2; + let n_experts = 2; + let dummy_bytes_per_expert = 512; + let mut payload = Vec::new(); + for i in 0..n_experts { + for _ in 0..dummy_bytes_per_expert { + payload.push(i as u8); + } + } + + let tensors = [TensorSpec { + name: "blk.0.ffn_gate_exps.weight", + dims: vec![inner, outer, n_experts], + ggml_type: 31, // Q4_0_4_4 — not IQ3_M + payload, + }]; + + let kv = [("general.architecture", KvValue::Str("testmoe"))]; + let bytes = build_gguf(&kv, &tensors); + let err = parse_bytes(bytes, "mem://t31".into()) + .expect_err("type 31 must not parse with known layout"); + let msg = err.to_string(); + assert!( + msg.contains("unknown byte-length") + || msg.contains("InvalidLayout") + || msg.contains("ggml_type=31"), + "unexpected error: {msg}" + ); +} + +#[test] +fn rejects_unsupported_gguf_version() { + let mut out = Vec::new(); + out.extend_from_slice(&GGUF_MAGIC); + push_u32(&mut out, 2); // version 2 — not supported + push_u64(&mut out, 0); + push_u64(&mut out, 0); + let err = parse_bytes(out, "mem://v2".into()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("unsupported GGUF version") || msg.contains("unsupported GGUF format"), + "got: {msg}" + ); +} + +#[test] +fn rejects_truncated_file() { + let mut out = Vec::new(); + out.extend_from_slice(&GGUF_MAGIC); + push_u32(&mut out, GGUF_VERSION); + // Claim one KV but provide no body → EOF while parsing. + push_u64(&mut out, 0); // tensor_count + push_u64(&mut out, 1); // kv_count + let err = parse_bytes(out, "mem://trunc".into()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("EOF") + || msg.contains("overflow") + || msg.contains("unsupported") + || msg.contains("InvalidLayout") + || msg.contains("invalid"), + "got: {msg}" + ); +} + +#[test] +fn quantization_falls_back_to_file_type() { + let kv = [ + ("general.architecture", KvValue::Str("olmoe")), + ("general.file_type", KvValue::U32(15)), + ]; + let bytes = build_gguf(&kv, &[]); + let layout = parse_bytes(bytes, "mem://file-type".into()).expect("parse"); + assert_eq!(layout.metadata.quantization(), "GGUF(15)"); + + let kv_f32 = [ + ("general.architecture", KvValue::Str("olmoe")), + ("general.file_type", KvValue::U32(0)), + ]; + let layout_f32 = parse_bytes(build_gguf(&kv_f32, &[]), "mem://ft0".into()).unwrap(); + assert_eq!(layout_f32.metadata.quantization(), "F32"); + + // Explicit quantization_type wins over file_type. + let kv_pref = [ + ("general.quantization_type", KvValue::Str("Q4_K_M")), + ("general.file_type", KvValue::U32(15)), + ]; + let layout_pref = parse_bytes(build_gguf(&kv_pref, &[]), "mem://pref".into()).unwrap(); + assert_eq!(layout_pref.metadata.quantization(), "Q4_K_M"); +} + +#[test] +fn quantization_from_default_metadata_reads_file_type() { + use engram_parser::GgufMetadata; + let mut meta = GgufMetadata::default(); + meta.numerics.insert("general.file_type".into(), 0); + assert_eq!(meta.quantization(), "F32"); + meta.numerics.insert("general.file_type".into(), 15); + assert_eq!(meta.quantization(), "GGUF(15)"); + meta.strings + .insert("general.quantization_type".into(), "Q8_0".into()); + assert_eq!(meta.quantization(), "Q8_0"); +} + +#[test] +fn rejects_non_row_aligned_blocked_quant() { + // Total elems = 32 (block-aligned) but dims[0]=16 is not divisible by 32. + let payload = vec![0u8; 18]; // would be one Q4_0 block if shape were valid + let tensors = [TensorSpec { + name: "bad.q4_0", + dims: vec![16, 2], + ggml_type: 2, // Q4_0 + payload, + }]; + let kv = [("general.architecture", KvValue::Str("test"))]; + let err = parse_bytes(build_gguf(&kv, &tensors), "mem://bad-row".into()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("innermost dim") || msg.contains("block size"), + "got: {msg}" + ); +} + +#[test] +fn rejects_negative_alignment_metadata() { + // Hand-built: INT32 general.alignment = -1 must not wrap to huge usize. + const VT_INT32: u32 = 5; + let mut out = Vec::new(); + out.extend_from_slice(&GGUF_MAGIC); + push_u32(&mut out, GGUF_VERSION); + push_u64(&mut out, 0); // tensors + push_u64(&mut out, 1); // one KV + push_string(&mut out, "general.alignment"); + push_u32(&mut out, VT_INT32); + out.extend_from_slice(&(-1i32).to_le_bytes()); + let err = parse_bytes(out, "mem://neg-align".into()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("negative") || msg.contains("InvalidLayout"), + "got: {msg}" + ); +} + +#[test] +fn accepts_negative_vendor_signed_metadata() { + // Non-layout signed KVs may be negative; must not fail the whole file. + const VT_INT32: u32 = 5; + let mut out = Vec::new(); + out.extend_from_slice(&GGUF_MAGIC); + push_u32(&mut out, GGUF_VERSION); + push_u64(&mut out, 0); + push_u64(&mut out, 1); + push_string(&mut out, "vendor.custom_signed"); + push_u32(&mut out, VT_INT32); + out.extend_from_slice(&(-7i32).to_le_bytes()); + let layout = parse_bytes(out, "mem://neg-vendor".into()).expect("parse"); + // Bit-preserving cast of -7 as i32 → u64. + let expected = (-7i32) as i64 as u64; + assert_eq!( + layout.metadata.numerics.get("vendor.custom_signed"), + Some(&expected) + ); +} + +#[test] +fn parses_iq3_s_tensor_layout() { + // IQ3_S: 256 elements per block, 110 bytes/block (GGUF wire layout). + let n = 256usize; + let payload = vec![0xABu8; 110]; + let tensors = [TensorSpec { + name: "blk.0.ffn_gate.weight", + dims: vec![n], + ggml_type: GGML_IQ3_S, + payload, + }]; + let kv = [("general.architecture", KvValue::Str("testmoe"))]; + let layout = parse_bytes(build_gguf(&kv, &tensors), "mem://iq3s".into()).expect("parse"); + let t = layout.tensor("blk.0.ffn_gate.weight").unwrap(); + assert_eq!(t.dtype, DType::IQ3_S); + assert_eq!(t.byte_len, 110); + assert_eq!(layout.tensor_bytes(t).unwrap().len(), 110); +} + +#[test] +fn extracts_stacked_q8_0_expert_slices() { + // Q8_0: block size 32, 34 bytes/block. Per-expert: 32 elems → 34 bytes. + let inner = 32usize; + let outer = 1usize; + let n_experts = 3usize; + let per_expert_bytes = 34usize; + let mut payload = Vec::with_capacity(n_experts * per_expert_bytes); + for e in 0..n_experts { + payload.extend(std::iter::repeat_n(e as u8, per_expert_bytes)); + } + let tensors = [ + TensorSpec { + name: "blk.0.ffn_gate_exps.weight", + dims: vec![inner, outer, n_experts], + ggml_type: GGML_Q8_0, + payload: payload.clone(), + }, + TensorSpec { + name: "blk.0.ffn_up_exps.weight", + dims: vec![inner, outer, n_experts], + ggml_type: GGML_Q8_0, + payload: payload.clone(), + }, + TensorSpec { + name: "blk.0.ffn_down_exps.weight", + dims: vec![inner, outer, n_experts], + ggml_type: GGML_Q8_0, + payload, + }, + ]; + let kv = [("general.architecture", KvValue::Str("olmoe"))]; + let layout = parse_bytes(build_gguf(&kv, &tensors), "mem://q8-stacked".into()).expect("parse"); + assert_eq!(list_experts(&layout), vec![(0, 0), (0, 1), (0, 2)]); + + for e in 0..n_experts { + let out = extract_expert(&layout, 0, e).expect("extract"); + let gate = out.gate.as_ref(); + assert_all(&[ + (gate.is_some_and(|g| g.stacked_slice), "gate stacked"), + ( + gate.is_some_and(|g| g.bytes.len() == per_expert_bytes), + "gate bytes len", + ), + ( + gate.is_some_and(|g| g.bytes.iter().all(|&b| b == e as u8)), + "gate bytes", + ), + (gate.is_some_and(|g| g.dtype == DType::Q8_0), "gate dtype"), + (out.is_complete(), "complete"), + ]); + } +} + +#[test] +fn extracts_stacked_q4_k_expert_slices() { + // Q4_K: 256 elems/block, 144 bytes/block. dims [256, 1, 2] experts. + let inner = 256usize; + let outer = 1usize; + let n_experts = 2usize; + let per_expert_bytes = 144usize; + let mut payload = Vec::with_capacity(n_experts * per_expert_bytes); + for e in 0..n_experts { + payload.extend(std::iter::repeat_n((0x10 + e) as u8, per_expert_bytes)); + } + let tensors = [TensorSpec { + name: "blk.0.ffn_gate_exps.weight", + dims: vec![inner, outer, n_experts], + ggml_type: GGML_Q4_K, + payload, + }]; + let kv = [("general.architecture", KvValue::Str("olmoe"))]; + let layout = parse_bytes(build_gguf(&kv, &tensors), "mem://q4k-stacked".into()).expect("parse"); + let e0 = extract_expert(&layout, 0, 0).unwrap(); + let gate0 = e0.gate.as_ref(); + assert_all(&[ + (gate0.is_some_and(|g| g.bytes.len() == 144), "e0 bytes len"), + ( + gate0.is_some_and(|g| g.bytes.iter().all(|&b| b == 0x10)), + "e0 bytes", + ), + (gate0.is_some_and(|g| g.dtype == DType::Q4_K), "e0 dtype"), + ]); + + let e1 = extract_expert(&layout, 0, 1).unwrap(); + let gate1 = e1.gate.as_ref(); + assert!( + gate1.is_some_and(|g| g.bytes.iter().all(|&b| b == 0x11)), + "e1 bytes" + ); +} + +#[test] +fn extracts_underscore_per_expert_tensors() { + // Alternate naming: ffn_gate_0.weight instead of ffn_gate.0.weight + let inner = 2usize; + let outer = 2usize; + let tensors = [ + TensorSpec { + name: "blk.0.ffn_gate_0.weight", + dims: vec![inner, outer], + ggml_type: GGML_F32, + payload: f32_vec_to_le_bytes(&[1.0; 4]), + }, + TensorSpec { + name: "blk.0.ffn_up_0.weight", + dims: vec![inner, outer], + ggml_type: GGML_F32, + payload: f32_vec_to_le_bytes(&[2.0; 4]), + }, + TensorSpec { + name: "blk.0.ffn_down_0.weight", + dims: vec![inner, outer], + ggml_type: GGML_F32, + payload: f32_vec_to_le_bytes(&[3.0; 4]), + }, + ]; + let kv = [("general.architecture", KvValue::Str("qwen3moe"))]; + let layout = parse_bytes(build_gguf(&kv, &tensors), "mem://uscore".into()).expect("parse"); + let pairs = list_experts(&layout); + assert!( + pairs.contains(&(0, 0)), + "expected (0,0) in list_experts, got {pairs:?}" + ); + let e0 = extract_expert(&layout, 0, 0).expect("extract underscore expert"); + assert!(e0.is_complete()); + assert_eq!( + e0.gate.as_ref().unwrap().source_name, + "blk.0.ffn_gate_0.weight" + ); +} diff --git a/tests/real_gguf.rs b/tests/real_gguf.rs new file mode 100644 index 0000000..3176f57 --- /dev/null +++ b/tests/real_gguf.rs @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Path-gated real GGUF pilots (xai-dissect style). +//! +//! CI never runs these: they are `#[ignore]` and need multi-GB weights on disk. +//! Locally, point at a file or a tree under `~/.models/gguf`: +//! +//! ```bash +//! ENGRAM_GGUF=~/.models/gguf/.../model.gguf \ +//! cargo test --test real_gguf -- --ignored --nocapture +//! +//! ENGRAM_MODEL_DIR=~/.models/gguf \ +//! ENGRAM_GGUF_MAX=3 \ +//! cargo test --test real_gguf -- --ignored --nocapture +//! +//! # Hard-fail if no MoE experts are discovered; sample multiple expert pairs: +//! ENGRAM_GGUF=~/.models/gguf/.../moe.gguf \ +//! ENGRAM_EXPECT_MOE=1 ENGRAM_MOE_SAMPLES=3 \ +//! cargo test --test real_gguf real_gguf_moe -- --ignored --nocapture +//! ``` +//! +//! GPU / kernel experiments on the same weights belong in +//! `~/rmems/blackwell-kernel-lab` (or myelin-accelerator), not this crate. + +use std::env; +use std::fs; +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +const ENV_GGUF: &str = "ENGRAM_GGUF"; +const ENV_MODEL_DIR: &str = "ENGRAM_MODEL_DIR"; +const ENV_MAX: &str = "ENGRAM_GGUF_MAX"; + +/// When set to `1`/`true`/`yes`, MoE extract must find at least one expert +/// pair and a successful `extract_expert` (hard fail on dense / unknown names). +const ENV_EXPECT_MOE: &str = "ENGRAM_EXPECT_MOE"; + +/// How many (block, expert) pairs to extract when MoE is present (default 1). +const ENV_MOE_SAMPLES: &str = "ENGRAM_MOE_SAMPLES"; + +fn expect_moe_from(raw: Option<&str>) -> bool { + match raw { + Some(v) => matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"), + None => false, + } +} + +fn expect_moe() -> bool { + expect_moe_from(env::var(ENV_EXPECT_MOE).ok().as_deref()) +} + +fn moe_sample_count_from(raw: Option<&str>) -> usize { + raw.and_then(|s| s.parse().ok()).unwrap_or(1).max(1) +} + +fn moe_sample_count() -> usize { + moe_sample_count_from(env::var(ENV_MOE_SAMPLES).ok().as_deref()) +} + +use engram_parser::{GgufLayout, MoeExpertWeights, extract_expert, list_experts, load_gguf}; + +/// Assert that an extracted expert has at least one role tensor and that each +/// role tensor has a consistent payload size for its declared dtype. +fn assert_expert_weights_valid(path: &Path, b: usize, e: usize, w: &MoeExpertWeights) { + assert!( + w.gate.is_some() || w.up.is_some() || w.down.is_some(), + "{}: empty extract for ({b},{e})", + path.display() + ); + + for (role, opt) in [("gate", &w.gate), ("up", &w.up), ("down", &w.down)] { + let Some(t) = opt else { continue }; + assert!(!t.bytes.is_empty(), "{role} empty bytes"); + assert!(!t.dims.is_empty(), "{role} empty dims"); + + let n_elements = t.dims.iter().product::(); + if let Some(expected) = t.dtype.byte_len_for_elements(n_elements) { + assert_eq!( + t.bytes.len(), + expected, + "{role} byte length for {} ({b},{e})", + path.display() + ); + } + } +} + +/// Resolve pilot paths the way xai-dissect resolves checkpoint pilots: +/// explicit file, else scan a directory for `*.gguf` recursively, limited by +/// the configured depth so huge trees stay controllable. +fn load_and_scan(path: &Path) -> (GgufLayout, Vec<(usize, usize)>) { + let t0 = Instant::now(); + let layout = load_gguf(path).expect("load"); + let load_ms = t0.elapsed().as_secs_f64() * 1000.0; + let experts = list_experts(&layout); + + eprintln!( + "moe_scan {} arch={} quant={} expert_meta={:?} pairs={} load_ms={load_ms:.1}", + path.display(), + layout.metadata.architecture(), + layout.metadata.quantization(), + layout.metadata.expert_count(), + experts.len(), + ); + + (layout, experts) +} + +fn extract_and_report(layout: &GgufLayout, path: &Path, b: usize, e: usize) { + let w = extract_expert(layout, b, e).unwrap_or_else(|err| { + panic!("extract_expert({}, {b}, {e}): {err}", path.display()); + }); + + assert_expert_weights_valid(path, b, e, &w); + + eprintln!( + "OK moe {} pair=({b},{e}) complete={} stacked_gate={}", + path.display(), + w.is_complete(), + w.gate.as_ref().map(|g| g.stacked_slice).unwrap_or(false), + ); +} + +/// Load one pilot and extract up to `samples` MoE expert pairs. +/// Returns `true` if the file contained at least one discoverable MoE pair. +fn scan_one_pilot(path: &Path, samples: usize) -> bool { + let (layout, experts) = load_and_scan(path); + + if experts.is_empty() { + eprintln!("skip MoE (none discovered): {}", path.display()); + return false; + } + + let take = samples.min(experts.len()); + for &(b, e) in experts.iter().take(take) { + extract_and_report(&layout, path, b, e); + } + + if let Some(n) = layout.metadata.expert_count() { + assert!(n > 0, "{}: expert_count metadata is 0", path.display()); + } + true +} + +fn pilot_gguf_paths_from( + single: Option<&str>, + model_dir: Option<&str>, + max: Option<&str>, +) -> Vec { + if let Some(single) = single { + let p = PathBuf::from(single); + return if p.is_file() { vec![p] } else { Vec::new() }; + } + + let Some(root) = model_dir else { + return Vec::new(); + }; + let root = PathBuf::from(root); + if !root.is_dir() { + return Vec::new(); + } + + let max = max.and_then(|s| s.parse::().ok()).unwrap_or(1); + + // Scan the directory tree for `*.gguf` files, limited by depth and the + // requested cap. Entries are sorted at each directory so the result is + // deterministic even though `fs::read_dir` order is unspecified. + let mut out = Vec::new(); + collect_gguf(&root, 0, 6, max, &mut out); + out.sort(); + out.truncate(max); + out +} + +fn pilot_gguf_paths() -> Vec { + pilot_gguf_paths_from( + env::var(ENV_GGUF).ok().as_deref(), + env::var(ENV_MODEL_DIR).ok().as_deref(), + env::var(ENV_MAX).ok().as_deref(), + ) +} + +fn partition_entries(dir: &Path) -> (Vec, Vec) { + let mut files = Vec::new(); + let mut dirs = Vec::new(); + let Ok(entries) = fs::read_dir(dir) else { + return (files, dirs); + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } else if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("gguf")) + { + files.push(path); + } + } + files.sort(); + dirs.sort(); + (files, dirs) +} + +fn collect_gguf(dir: &Path, depth: usize, max_depth: usize, max: usize, out: &mut Vec) { + if depth > max_depth || out.len() >= max { + return; + } + let (files, dirs) = partition_entries(dir); + for f in files { + if out.len() >= max { + break; + } + out.push(f); + } + for d in dirs { + if out.len() >= max { + break; + } + collect_gguf(&d, depth + 1, max_depth, max, out); + } +} + +fn require_pilots() -> Vec { + let paths = pilot_gguf_paths(); + assert!( + !paths.is_empty(), + "no pilot GGUFs found — set {ENV_GGUF}=/path/to/model.gguf \ + or {ENV_MODEL_DIR}=~/.models/gguf (optional {ENV_MAX}=N)" + ); + for p in &paths { + assert!(p.is_file(), "not a file: {}", p.display()); + } + paths +} + +#[test] +#[ignore = "pilot: set ENGRAM_GGUF or ENGRAM_MODEL_DIR; not run in CI"] +fn real_gguf_parse_inventory() { + let paths = require_pilots(); + for path in paths { + let t0 = Instant::now(); + let layout = load_gguf(&path).unwrap_or_else(|e| { + panic!("load_gguf({}) failed: {e}", path.display()); + }); + let ms = t0.elapsed().as_secs_f64() * 1000.0; + + assert!( + !layout.tensors.is_empty(), + "{}: expected tensors", + path.display() + ); + assert!(layout.alignment >= 1, "alignment"); + + // Every directory entry must have a consistent byte_len for known dtypes. + for (name, tensor) in &layout.tensors { + assert!( + tensor.byte_len > 0 || tensor.n_elements == 0, + "{name}: zero byte_len with n_elements={}", + tensor.n_elements + ); + if let Some(expected) = tensor.dtype.byte_len_for_elements(tensor.n_elements) { + assert_eq!( + tensor.byte_len, expected, + "{name}: byte_len mismatch for {:?}", + tensor.dtype + ); + } + // Payload must be in-range. + let bytes = layout.tensor_bytes(tensor).unwrap_or_else(|e| { + panic!("{name}: tensor_bytes: {e}"); + }); + assert_eq!(bytes.len(), tensor.byte_len, "{name}: payload len"); + } + + eprintln!( + "OK inventory {} tensors={} arch={} quant={} parse_ms={ms:.1}", + path.display(), + layout.tensors.len(), + layout.metadata.architecture(), + layout.metadata.quantization(), + ); + } +} + +#[test] +#[ignore = "pilot: set ENGRAM_GGUF or ENGRAM_MODEL_DIR; not run in CI"] +fn real_gguf_moe_extract_when_present() { + let paths = require_pilots(); + let hard = expect_moe(); + let samples = moe_sample_count(); + let mut any_moe = false; + + for path in paths { + if scan_one_pilot(&path, samples) { + any_moe = true; + } + } + + if hard { + assert!( + any_moe, + "ENGRAM_EXPECT_MOE set but no MoE expert tensors found in pilot set" + ); + } else if !any_moe { + eprintln!( + "note: no MoE expert tensors in pilot set; inventory-only is fine for dense GGUFs" + ); + } +} + +/// Generate a unique temporary path under `std::env::temp_dir()`. +fn unique_tmp(prefix: &str) -> PathBuf { + let pid = process::id(); + for _ in 0..10 { + let n = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = env::temp_dir().join(format!("{prefix}_{pid}_{n}")); + if !path.exists() { + return path; + } + } + panic!("could not generate a unique temporary path"); +} + +struct TempFile(PathBuf); +impl Drop for TempFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} + +struct TempDir(PathBuf); +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn real_gguf_helpers_document_env() { + // Always runs in CI: documents the pilot contract and exercises the + // env-dependent helpers without mutating the process environment. + let env_names = [ + (ENV_GGUF, "ENGRAM_GGUF"), + (ENV_MODEL_DIR, "ENGRAM_MODEL_DIR"), + (ENV_MAX, "ENGRAM_GGUF_MAX"), + (ENV_EXPECT_MOE, "ENGRAM_EXPECT_MOE"), + (ENV_MOE_SAMPLES, "ENGRAM_MOE_SAMPLES"), + ]; + for (actual, expected) in env_names { + assert_eq!(actual, expected); + } + + let expect_cases = [ + (None, false), + (Some("1"), true), + (Some("YES"), true), + (Some("no"), false), + ]; + for (input, expected) in expect_cases { + assert_eq!( + expect_moe_from(input), + expected, + "expect_moe_from({input:?})" + ); + } + + let sample_cases = [(Some("7"), 7usize), (Some("0"), 1), (None, 1)]; + for (input, expected) in sample_cases { + assert_eq!( + moe_sample_count_from(input), + expected, + "moe_sample_count_from({input:?})" + ); + } + + // pilot_gguf_paths resolves a single file. + let tmp = unique_tmp("engram_helpers_file"); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp) + .expect("create temp file"); + let _file_guard = TempFile(tmp.clone()); + assert_eq!( + pilot_gguf_paths_from(Some(tmp.to_str().unwrap()), None, None), + vec![tmp.clone()] + ); + assert!(pilot_gguf_paths_from(Some("/does/not/exist"), None, None).is_empty()); + + // pilot_gguf_paths scans a directory for *.gguf. + let dir = unique_tmp("engram_helpers_dir"); + fs::create_dir(&dir).expect("create temp dir"); + let _dir_guard = TempDir(dir.clone()); + let gf = dir.join("model.gguf"); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&gf) + .expect("create temp gguf file"); + assert_eq!( + pilot_gguf_paths_from(None, Some(dir.to_str().unwrap()), Some("5")), + vec![gf] + ); +}