diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..992aaae --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,8 @@ +--- +exclude_paths: + - "Cargo.lock" + - ".github/workflows/rust.yml" +engines: + hadolint: + exclude_paths: + - "Dockerfile" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e37a9b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + name: Build & Test + runs-on: ubuntu-latest + steps: + # actions/checkout@v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + + # dtolnay/rust-toolchain@stable + - name: Install Rust stable + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + components: clippy, rustfmt + + # actions/cache@v4 + - name: Cache cargo registry + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy (lint) + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --all-features + + - name: Test + run: cargo test --all-features diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..8081b4b --- /dev/null +++ b/.yamllint @@ -0,0 +1,8 @@ +--- +extends: default + +rules: + document-start: disable + line-length: + max: 120 + truthy: disable diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fbc6578 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +### Changed + +- **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). + +### Added + +- **GitHub Actions CI** — `fmt`, `clippy`, `build`, and `test` on push/PR to `main`. +- **Boundary documentation** — README scope/ownership section linked to Linear LIM-9. + +## [0.1.0] - 2026-06-01 + +- Initial release: pure-Rust, zero-dependency GGUF v3 deserializer. +- MoE expert enumeration and per-expert weight extraction (stacked and per-expert conventions). +- Layout-aware dtype handling for F32, F16, BF16, and opaque quant types. diff --git a/Cargo.toml b/Cargo.toml index 0970670..82e6d28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,10 @@ name = "engram-parser" version = "0.1.0" edition = "2024" +rust-version = "1.87" 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 = "GPL-3.0-or-later" +license = "MIT OR Apache-2.0" +authors = ["Raul Montoya Cardenas "] repository = "https://github.com/Limen-Neural/engram-parser" homepage = "https://github.com/Limen-Neural/engram-parser" keywords = ["gguf", "moe", "parser", "weights", "deserializer"] diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 0000000..cb56f23 --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..5f8aae6 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) 2026 Raul Montoya Cardenas and Limen-Neural contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index d244420..e0b5d9b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # engram-parser +[![CI](https://github.com/Limen-Neural/engram-parser/actions/workflows/ci.yml/badge.svg)](https://github.com/Limen-Neural/engram-parser/actions/workflows/ci.yml) +[![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE-MIT) + Pure-Rust, **zero-dependency** `.gguf` deserializer and Mixture-of-Experts per-expert weight extractor. @@ -21,6 +24,43 @@ Mixture-of-Experts per-expert weight extractor. - No CUDA, no GPU, no SIMD. - No runtime dependencies. `[dependencies]` is intentionally empty. +## Scope / Boundaries + +This crate **owns**: + +- GGUF v3 deserialization (header, KV metadata, tensor directory). +- MoE expert enumeration (`list_experts`). +- Per-expert raw weight extraction (`extract_expert` — gate/up/down byte + buffers with shape and dtype metadata). +- Zero-dependency, layout-aware dtype handling (F32/F16/BF16 plus opaque + quant types as raw bytes). + +This crate **does not own**: + +- Neural-network math (matmul, forward, routing, softmax, dequantization + in the default build). +- CUDA/GPU/SIMD execution. +- Tokenization, inference orchestration, or SNN dynamics. +- Full checkpoint routing or model-family adapters (see + [`cortex-tensor`](https://github.com/Limen-Neural/cortex-tensor)). + +**Allowed dependencies:** none — `[dependencies]` stays empty. + +**Forbidden dependencies:** inference engines, GPU backends, domain-specific +adapters. + +| Crate | Role | +|-------|------| +| `engram-parser` | GGUF parse + per-expert weight extraction | +| [`cortex-tensor`](https://github.com/Limen-Neural/cortex-tensor) | Tensor math + MoE routing on extracted weights | +| [`hybrid-fusion`](https://github.com/Limen-Neural/hybrid-fusion) | ANN→SNN orchestration | +| [`neuromod`](https://github.com/Limen-Neural/neuromod) | SNN neuron dynamics (downstream consumer) | + +See [LIM-9](https://linear.app/saaq-spiking-adaptive-activity/issue/LIM-9/plan-rust-runtime-and-deployment-repo-boundary-matrix) +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. + ## Quick start ```rust @@ -50,4 +90,21 @@ is returned as raw `Vec`. `load_gguf`, `parse_bytes`, `GgufLayout`, `GgufMetadata`, `Tensor`, `DType`, `extract_expert`, `list_experts`, `MoeExpertWeights`, -`RawTensor`, `ParserError`, `Result`. \ No newline at end of file +`RawTensor`, `ParserError`, `Result`. + +## Development + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +``` + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE-2.0](LICENSE-APACHE-2.0) or [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or [http://opensource.org/licenses/MIT](http://opensource.org/licenses/MIT)) + +at your option. diff --git a/src/error.rs b/src/error.rs index 34c41e7..cd74f8e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Hand-rolled error types for `engram-parser`. //! //! Zero external dependencies: no `thiserror`, no `anyhow`. Callers may diff --git a/src/gguf/cursor.rs b/src/gguf/cursor.rs index 4cb67c4..3eb3f76 100644 --- a/src/gguf/cursor.rs +++ b/src/gguf/cursor.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Streaming cursor over the raw GGUF byte stream. //! //! Only what the parser needs: little-endian scalar reads, length-prefixed @@ -86,16 +88,18 @@ impl<'a> GgufCursor<'a> { pub(crate) fn read_u32(&mut self) -> Result { let bytes = self.read_exact(4)?; - Ok(u32::from_le_bytes( - bytes.try_into().expect("slice length is 4"), - )) + let arr: [u8; 4] = bytes + .try_into() + .map_err(|_| self.unsupported("expected 4-byte u32 payload".into()))?; + Ok(u32::from_le_bytes(arr)) } pub(crate) fn read_u64(&mut self) -> Result { let bytes = self.read_exact(8)?; - Ok(u64::from_le_bytes( - bytes.try_into().expect("slice length is 8"), - )) + let arr: [u8; 8] = bytes + .try_into() + .map_err(|_| self.unsupported("expected 8-byte u64 payload".into()))?; + Ok(u64::from_le_bytes(arr)) } pub(crate) fn read_i16(&mut self) -> Result { @@ -128,21 +132,49 @@ impl<'a> GgufCursor<'a> { /// 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 => Ok(self.read_u8()? as u64), - VT_I8 => Ok(self.read_u8()? as i8 as i64 as u64), - VT_U16 => Ok(self.read_u16()? as u64), - VT_I16 => Ok(self.read_i16()? as i64 as u64), - VT_U32 => Ok(self.read_u32()? as u64), - VT_I32 => Ok(self.read_i32()? as i64 as u64), + 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 => Ok(self.read_i64()? as u64), - VT_BOOL => Ok(self.read_u8()? as u64), - other => Err(self.unsupported(format!( - "expected numeric GGUF value, got type {other}" - ))), + 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}"))) + } } } + fn read_u8_as_u64(&mut self) -> Result { + 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) + } + + fn read_i64_as_u64(&mut self) -> Result { + Ok(self.read_i64()? as u64) + } + /// 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) @@ -180,17 +212,20 @@ impl<'a> GgufCursor<'a> { VT_STRING => { let _ = self.read_string()?; } - VT_ARRAY => { - let nested = self.read_u32()?; - let len = self.read_u64()? as usize; - for _ in 0..len { - self.skip_value(nested)?; - } - } + VT_ARRAY => self.skip_array_value()?, other => { return Err(self.unsupported(format!("unsupported GGUF value type {other}"))); } } Ok(()) } + + 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)?; + } + Ok(()) + } } diff --git a/src/gguf/layout.rs b/src/gguf/layout.rs index 9ce80bd..65cb463 100644 --- a/src/gguf/layout.rs +++ b/src/gguf/layout.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Parsed GGUF file layout: metadata KV store + tensor directory. //! //! This module is file-agnostic: it operates on an already-loaded @@ -7,9 +9,7 @@ use std::collections::HashMap; -use super::cursor::{ - GGUF_MAGIC, GGUF_VERSION, GgufCursor, VT_ARRAY, VT_STRING, invalid_layout, unsupported, -}; +use super::cursor::{GGUF_MAGIC, GGUF_VERSION, GgufCursor, VT_STRING, invalid_layout, unsupported}; use super::tensor::{DType, Tensor}; use crate::error::{ParserError, Result}; @@ -100,10 +100,12 @@ impl GgufLayout { /// Lookup a tensor by exact name. pub fn tensor(&self, name: &str) -> Result<&Tensor> { - self.tensors.get(name).ok_or_else(|| ParserError::MissingTensor { - name: name.to_owned(), - path: self.path.clone(), - }) + self.tensors + .get(name) + .ok_or_else(|| ParserError::MissingTensor { + name: name.to_owned(), + path: self.path.clone(), + }) } /// Find all tensors whose name ends with the given suffix. Useful @@ -119,11 +121,36 @@ impl GgufLayout { } } +struct LayoutHeader { + tensor_count: usize, + kv_count: usize, +} + /// Parse the GGUF header + KV metadata + tensor directory out of a /// byte slice. Does not validate payload bytes, only directory offsets. -pub(crate) fn parse_layout(bytes: &[u8], path: &str) -> Result<(GgufMetadata, HashMap, usize, usize)> { +pub(crate) fn parse_layout( + bytes: &[u8], + path: &str, +) -> 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 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)) +} +fn read_layout_header(cursor: &mut GgufCursor<'_>, path: &str) -> Result { + validate_gguf_header(cursor, path)?; + let tensor_count = bounded_count(cursor.read_u64()?, MAX_TENSOR_COUNT, "tensor_count", path)?; + let kv_count = bounded_count(cursor.read_u64()?, MAX_KV_COUNT, "kv_count", path)?; + Ok(LayoutHeader { + tensor_count, + kv_count, + }) +} + +fn validate_gguf_header(cursor: &mut GgufCursor<'_>, path: &str) -> Result<()> { let magic = cursor.read_exact(4)?; if magic != GGUF_MAGIC { return Err(unsupported( @@ -140,91 +167,122 @@ pub(crate) fn parse_layout(bytes: &[u8], path: &str) -> Result<(GgufMetadata, Ha )); } - let tensor_count_raw = cursor.read_u64()?; - if tensor_count_raw > MAX_TENSOR_COUNT { - return Err(unsupported( - path, - format!("tensor_count {tensor_count_raw} exceeds sanity limit {MAX_TENSOR_COUNT}"), - )); - } - let tensor_count = tensor_count_raw as usize; + Ok(()) +} - let kv_count_raw = cursor.read_u64()?; - if kv_count_raw > MAX_KV_COUNT { +fn bounded_count(raw: u64, limit: u64, label: &str, path: &str) -> Result { + if raw > limit { return Err(unsupported( path, - format!("kv_count {kv_count_raw} exceeds sanity limit {MAX_KV_COUNT}"), + format!("{label} {raw} exceeds sanity limit {limit}"), )); } - let kv_count = kv_count_raw as usize; + Ok(raw as usize) +} +fn read_metadata_section( + cursor: &mut GgufCursor<'_>, + kv_count: usize, +) -> Result<(usize, GgufMetadata)> { let mut alignment: usize = 32; let mut metadata = GgufMetadata::default(); for _ in 0..kv_count { let key = cursor.read_string()?; let value_type = cursor.read_u32()?; - match key.as_str() { - "general.alignment" => { - alignment = cursor.read_numeric_as_usize(value_type)?.max(1); - } - _ => { - capture_kv(&mut cursor, &mut metadata, key, value_type)?; - } + if key == "general.alignment" { + alignment = cursor.read_numeric_as_usize(value_type)?.max(1); + } else { + capture_kv(cursor, &mut metadata, key, value_type)?; } } + Ok((alignment, metadata)) +} + +fn read_tensor_directory( + cursor: &mut GgufCursor<'_>, + path: &str, + tensor_count: usize, +) -> Result> { let mut tensors = HashMap::with_capacity(tensor_count); for _ in 0..tensor_count { - let name = cursor.read_string()?; - let n_dims_raw = cursor.read_u32()? as usize; - if n_dims_raw > MAX_TENSOR_DIMS { - return Err(unsupported( - path, - format!("tensor '{name}' has {n_dims_raw} dims; max {MAX_TENSOR_DIMS}"), - )); - } - let mut dims = Vec::with_capacity(n_dims_raw); - for _ in 0..n_dims_raw { - dims.push(cursor.read_u64()? as usize); - } - let ggml_type = cursor.read_u32()?; - let relative_offset = cursor.read_u64()? as usize; - let dtype = DType::from_ggml_type(ggml_type); - - let n_elements = dims.iter().try_fold(1usize, |acc, &d| acc.checked_mul(d)).ok_or_else( - || invalid_layout(path, format!("tensor '{name}' element count overflow")), - )?; - let byte_len = dtype.byte_len_for_elements(n_elements).ok_or_else(|| { - invalid_layout( - path, - format!( - "tensor '{name}' (ggml_type={ggml_type}) has unknown byte-length for {n_elements} elements", - ), - ) - })?; + let tensor = read_tensor_entry(cursor, path)?; + tensors.insert(tensor.name.clone(), tensor); + } + Ok(tensors) +} + +fn read_tensor_entry(cursor: &mut GgufCursor<'_>, path: &str) -> Result { + let name = cursor.read_string()?; + let dims = read_tensor_dims(cursor, path, &name)?; + let ggml_type = cursor.read_u32()?; + 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)?; + let byte_len = tensor_byte_len(dtype, ggml_type, n_elements, &name, path)?; + + Ok(Tensor { + name, + dims, + dtype, + ggml_type, + n_elements, + byte_len, + relative_offset, + absolute_offset: 0, + }) +} + +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 { + return Err(unsupported( + path, + format!("tensor '{name}' has {n_dims_raw} dims; max {MAX_TENSOR_DIMS}"), + )); + } - tensors.insert( - name.clone(), - Tensor { - name, - dims, - dtype, - ggml_type, - n_elements, - byte_len, - relative_offset, - absolute_offset: 0, - }, - ); + let mut dims = Vec::with_capacity(n_dims_raw); + for _ in 0..n_dims_raw { + dims.push(cursor.read_u64()? as usize); } + Ok(dims) +} - let tensor_data_offset = align_up(cursor.offset(), alignment); +fn tensor_element_count(dims: &[usize], name: &str, path: &str) -> Result { + dims.iter() + .try_fold(1usize, |acc, &d| acc.checked_mul(d)) + .ok_or_else(|| invalid_layout(path, format!("tensor '{name}' element count overflow"))) +} + +fn tensor_byte_len( + dtype: DType, + ggml_type: u32, + n_elements: usize, + name: &str, + path: &str, +) -> Result { + dtype.byte_len_for_elements(n_elements).ok_or_else(|| { + invalid_layout( + path, + format!( + "tensor '{name}' (ggml_type={ggml_type}) has unknown byte-length for {n_elements} elements", + ), + ) + }) +} + +fn finalize_tensor_offsets( + tensors: &mut HashMap, + cursor_offset: usize, + alignment: usize, +) -> usize { + let tensor_data_offset = align_up(cursor_offset, alignment); for tensor in tensors.values_mut() { tensor.absolute_offset = tensor_data_offset + tensor.relative_offset; } - - Ok((metadata, tensors, alignment, tensor_data_offset)) + tensor_data_offset } fn capture_kv( @@ -238,31 +296,60 @@ fn capture_kv( }; match value_type { VT_U8 | VT_I8 | VT_U16 | VT_I16 | VT_U32 | VT_I32 | VT_U64 | VT_I64 | VT_BOOL => { - let v = cursor.read_numeric_as_u64(value_type)?; - metadata.numerics.insert(key, v); - } - VT_F32 => { - let v = cursor.read_f32()?; - metadata.floats_32.insert(key, v); - } - VT_F64 => { - let v = cursor.read_f64()?; - metadata.floats_64.insert(key, v); - } - VT_STRING => { - let v = cursor.read_string()?; - metadata.strings.insert(key, v); - } - VT_ARRAY => { - cursor.skip_value(value_type)?; - } - _ => { - cursor.skip_value(value_type)?; + 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), + _ => capture_skipped_kv(cursor, value_type), } +} + +fn capture_numeric_kv( + cursor: &mut GgufCursor<'_>, + metadata: &mut GgufMetadata, + key: String, + value_type: u32, +) -> Result<()> { + let v = cursor.read_numeric_as_u64(value_type)?; + metadata.numerics.insert(key, v); Ok(()) } +fn capture_f32_kv( + cursor: &mut GgufCursor<'_>, + metadata: &mut GgufMetadata, + key: String, +) -> Result<()> { + let v = cursor.read_f32()?; + metadata.floats_32.insert(key, v); + Ok(()) +} + +fn capture_f64_kv( + cursor: &mut GgufCursor<'_>, + metadata: &mut GgufMetadata, + key: String, +) -> Result<()> { + let v = cursor.read_f64()?; + metadata.floats_64.insert(key, v); + Ok(()) +} + +fn capture_string_kv( + cursor: &mut GgufCursor<'_>, + metadata: &mut GgufMetadata, + key: String, +) -> Result<()> { + let v = cursor.read_string()?; + metadata.strings.insert(key, v); + Ok(()) +} + +fn capture_skipped_kv(cursor: &mut GgufCursor<'_>, value_type: u32) -> Result<()> { + cursor.skip_value(value_type) +} + fn align_up(value: usize, alignment: usize) -> usize { if alignment <= 1 { value diff --git a/src/gguf/mod.rs b/src/gguf/mod.rs index 6e3f739..10dbcf5 100644 --- a/src/gguf/mod.rs +++ b/src/gguf/mod.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! GGUF file format: header + metadata + tensor directory. //! //! Entry point: [`load_gguf`] reads a `.gguf` file into a [`GgufLayout`] diff --git a/src/gguf/tensor.rs b/src/gguf/tensor.rs index 6677647..4a519c1 100644 --- a/src/gguf/tensor.rs +++ b/src/gguf/tensor.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Tensor directory entry + dtype enumeration. //! //! A [`Tensor`] is a pure metadata descriptor: name, shape, dtype, and @@ -115,7 +117,7 @@ impl DType { } fn block_bytes(n_elements: usize, block_size: usize, block_bytes: usize) -> Option { - if n_elements % block_size != 0 { + if !n_elements.is_multiple_of(block_size) { return None; } (n_elements / block_size).checked_mul(block_bytes) @@ -149,15 +151,13 @@ pub struct Tensor { } impl Tensor { - /// Reinterpret the tensor's payload as a `&[f32]` slice. - /// - /// Returns an error if the dtype is not `F32` or if alignment / - /// length invariants are violated. - pub fn as_f32_slice<'a>(&self, bytes: &'a [u8]) -> Result<&'a [f32]> { + /// Decode F32 tensor bytes into a `Vec` using little-endian + /// chunk parsing (no `unsafe` reinterpretation). + pub fn read_f32_values(&self, bytes: &[u8]) -> Result> { if self.dtype != DType::F32 { return Err(ParserError::UnsupportedFormat { path: self.name.clone(), - reason: format!("as_f32_slice called on dtype {:?}", self.dtype), + reason: format!("read_f32_values called on dtype {:?}", self.dtype), }); } if bytes.len() != self.n_elements * 4 { @@ -170,27 +170,19 @@ impl Tensor { ), }); } - if bytes.as_ptr() as usize % std::mem::align_of::() != 0 { - return Err(ParserError::InvalidLayout { - path: self.name.clone(), - reason: "f32 tensor payload is not 4-byte aligned".into(), - }); - } - // SAFETY: dtype, length, and alignment all checked above; lifetime - // is tied to the input slice which borrows the owning layout. - let slice = unsafe { - std::slice::from_raw_parts(bytes.as_ptr() as *const f32, self.n_elements) - }; - Ok(slice) + Ok(bytes + .chunks_exact(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect()) } - /// Reinterpret the tensor's payload as a `&[u16]` slice of raw F16 - /// or BF16 bits (no conversion performed). - pub fn as_u16_bits<'a>(&self, bytes: &'a [u8]) -> Result<&'a [u16]> { + /// Decode F16/BF16 tensor bytes into raw `u16` lane values using + /// little-endian chunk parsing (no numeric conversion). + pub fn read_u16_values(&self, bytes: &[u8]) -> Result> { if !matches!(self.dtype, DType::F16 | DType::BF16) { return Err(ParserError::UnsupportedFormat { path: self.name.clone(), - reason: format!("as_u16_bits called on dtype {:?}", self.dtype), + reason: format!("read_u16_values called on dtype {:?}", self.dtype), }); } if bytes.len() != self.n_elements * 2 { @@ -203,17 +195,10 @@ impl Tensor { ), }); } - if bytes.as_ptr() as usize % std::mem::align_of::() != 0 { - return Err(ParserError::InvalidLayout { - path: self.name.clone(), - reason: "16-bit tensor payload is not 2-byte aligned".into(), - }); - } - // SAFETY: dtype, length, and alignment checked above. - let slice = unsafe { - std::slice::from_raw_parts(bytes.as_ptr() as *const u16, self.n_elements) - }; - Ok(slice) + Ok(bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect()) } /// Decode an F16 tensor into a newly-allocated `Vec`. The only @@ -250,12 +235,13 @@ pub fn f16_bits_to_f32(bits: u16) -> f32 { let sign = ((bits as u32) & 0x8000) << 16; let exp = ((bits as u32) & 0x7C00) >> 10; let mant = ((bits as u32) & 0x03FF) << 13; - let val = if exp == 0 { - mant - } else if exp == 31 { - 0x7F800000 | mant - } else { - ((exp + 127 - 15) << 23) | mant - }; - f32::from_bits(sign | val) + f32::from_bits(sign | f16_payload_bits(exp, mant)) +} + +fn f16_payload_bits(exp: u32, mant: u32) -> u32 { + match exp { + 0 => mant, + 31 => 0x7F800000 | mant, + biased => ((biased + 127 - 15) << 23) | mant, + } } diff --git a/src/lib.rs b/src/lib.rs index 12cac1b..1f8f436 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! # engram-parser //! //! Pure-Rust, **zero-dependency** `.gguf` deserializer and @@ -29,7 +31,5 @@ pub mod gguf; pub mod moe; pub use error::{ParserError, Result}; -pub use gguf::{ - DType, GgufLayout, GgufMetadata, Tensor, f16_bits_to_f32, load_gguf, parse_bytes, -}; +pub use gguf::{DType, GgufLayout, GgufMetadata, Tensor, f16_bits_to_f32, load_gguf, parse_bytes}; pub use moe::{MoeExpertWeights, RawTensor, extract_expert, list_experts}; diff --git a/src/moe/expert.rs b/src/moe/expert.rs index 4cd303e..3f6f810 100644 --- a/src/moe/expert.rs +++ b/src/moe/expert.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Per-expert raw weight bundle. //! //! Each expert has up to three projections (`gate`, `up`, `down`), diff --git a/src/moe/extract.rs b/src/moe/extract.rs index 7256bc5..02b0f1c 100644 --- a/src/moe/extract.rs +++ b/src/moe/extract.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Per-expert weight extraction from a parsed [`GgufLayout`]. //! //! Two on-disk conventions are supported: @@ -27,19 +29,25 @@ pub fn list_experts(layout: &GgufLayout) -> Vec<(usize, usize)> { let mut pairs: BTreeSet<(usize, usize)> = BTreeSet::new(); for tensor in layout.tensors.values() { - if let Some((block, role)) = parse_stacked_name(&tensor.name) { - let _ = role; - if let Some(n_experts) = stacked_expert_count(tensor) { - for e in 0..n_experts { - pairs.insert((block, e)); - } + record_expert_pairs(tensor, &mut pairs); + } + + pairs.into_iter().collect() +} + +fn record_expert_pairs(tensor: &Tensor, pairs: &mut BTreeSet<(usize, usize)>) { + if let Some((block, _role)) = parse_stacked_name(&tensor.name) { + if let Some(n_experts) = stacked_expert_count(tensor) { + for expert in 0..n_experts { + pairs.insert((block, expert)); } - } else if let Some((block, _role, expert)) = parse_per_expert_name(&tensor.name) { - pairs.insert((block, expert)); } + return; } - pairs.into_iter().collect() + if let Some((block, _role, expert)) = parse_per_expert_name(&tensor.name) { + pairs.insert((block, expert)); + } } /// Extract the raw weights for a single `(block, expert)` pair. @@ -82,7 +90,7 @@ fn extract_role( // Prefer the stacked convention first. let stacked_name = format!("blk.{block}.ffn_{role}_exps.weight"); if let Some(tensor) = layout.tensors.get(&stacked_name) { - return Ok(Some(slice_stacked_expert(layout, tensor, expert)?)); + return Ok(Some(slice_stacked_expert(layout, block, tensor, expert)?)); } // Fall back to per-expert tensors. GGUF files in the wild use @@ -117,9 +125,31 @@ fn extract_role( /// contiguously in the tensor buffer. fn slice_stacked_expert( layout: &GgufLayout, + block: usize, tensor: &Tensor, expert: usize, ) -> Result { + let n_experts = validate_stacked_expert_count(layout, block, tensor, expert)?; + let bytes = layout.tensor_bytes(tensor)?; + let (start, end) = stacked_slice_range(layout, tensor, expert, n_experts, bytes.len())?; + let per_expert_dims: Vec = tensor.dims[..tensor.dims.len() - 1].to_vec(); + + Ok(RawTensor { + source_name: tensor.name.clone(), + dims: per_expert_dims, + dtype: tensor.dtype, + ggml_type: tensor.ggml_type, + bytes: bytes[start..end].to_vec(), + stacked_slice: true, + }) +} + +fn validate_stacked_expert_count( + layout: &GgufLayout, + block: usize, + tensor: &Tensor, + expert: usize, +) -> Result { let n_experts = stacked_expert_count(tensor).ok_or_else(|| ParserError::InvalidLayout { path: layout.path.clone(), reason: format!( @@ -130,16 +160,32 @@ fn slice_stacked_expert( ), })?; + if n_experts == 0 { + return Err(ParserError::InvalidLayout { + path: layout.path.clone(), + reason: format!("stacked tensor '{}' has zero experts", tensor.name), + }); + } + if expert >= n_experts { return Err(ParserError::ExpertOutOfRange { - block: 0, + block, expert, available: n_experts, }); } - let bytes = layout.tensor_bytes(tensor)?; - if tensor.byte_len % n_experts != 0 { + Ok(n_experts) +} + +fn stacked_slice_range( + layout: &GgufLayout, + tensor: &Tensor, + expert: usize, + n_experts: usize, + buffer_len: usize, +) -> Result<(usize, usize)> { + if !tensor.byte_len.is_multiple_of(n_experts) { return Err(ParserError::InvalidLayout { path: layout.path.clone(), reason: format!( @@ -148,35 +194,26 @@ fn slice_stacked_expert( ), }); } + let stride = tensor.byte_len / n_experts; - let start = expert.checked_mul(stride).ok_or_else(|| ParserError::InvalidLayout { - path: layout.path.clone(), - reason: format!("stacked stride overflow for tensor '{}'", tensor.name), - })?; + let start = expert + .checked_mul(stride) + .ok_or_else(|| ParserError::InvalidLayout { + path: layout.path.clone(), + reason: format!("stacked stride overflow for tensor '{}'", tensor.name), + })?; let end = start + stride; - if end > bytes.len() { + if end > buffer_len { return Err(ParserError::InvalidLayout { path: layout.path.clone(), reason: format!( - "stacked slice range [{start}..{end}] for tensor '{}' exceeds buffer len {}", + "stacked slice range [{start}..{end}] for tensor '{}' exceeds buffer len {buffer_len}", tensor.name, - bytes.len() ), }); } - // Per-expert dims: drop the trailing expert axis. - let per_expert_dims: Vec = tensor.dims[..tensor.dims.len() - 1].to_vec(); - let chunk = bytes[start..end].to_vec(); - - Ok(RawTensor { - source_name: tensor.name.clone(), - dims: per_expert_dims, - dtype: tensor.dtype, - ggml_type: tensor.ggml_type, - bytes: chunk, - stacked_slice: true, - }) + Ok((start, end)) } /// For a stacked MoE tensor, the expert axis is the outermost (last) @@ -192,23 +229,34 @@ fn stacked_expert_count(tensor: &Tensor) -> Option { /// Parse a stacked MoE tensor name into `(block, role)`. Returns /// `None` if the name does not match the `blk.{B}.ffn_{role}_exps.weight` /// convention. +const STACKED_NAME_SUFFIXES: &[(&str, &str)] = &[ + ("ffn_gate_exps.weight", "gate"), + ("ffn_up_exps.weight", "up"), + ("ffn_down_exps.weight", "down"), +]; + fn parse_stacked_name(name: &str) -> Option<(usize, &'static str)> { let rest = name.strip_prefix("blk.")?; let (block_str, tail) = rest.split_once('.')?; let block: usize = block_str.parse().ok()?; - let role = if tail == "ffn_gate_exps.weight" { - "gate" - } else if tail == "ffn_up_exps.weight" { - "up" - } else if tail == "ffn_down_exps.weight" { - "down" - } else { - return None; - }; - Some((block, role)) + for (suffix, role) in STACKED_NAME_SUFFIXES { + if tail == *suffix { + return Some((block, role)); + } + } + None } +const PER_EXPERT_NAME_PATTERNS: &[(&str, &str)] = &[ + ("ffn_gate.", "gate"), + ("ffn_up.", "up"), + ("ffn_down.", "down"), + ("ffn_gate_", "gate"), + ("ffn_up_", "up"), + ("ffn_down_", "down"), +]; + /// Parse a per-expert MoE tensor name into `(block, role, expert)`. /// Matches both `blk.B.ffn_ROLE.E.weight` and `blk.B.ffn_ROLE_E.weight` /// forms. @@ -217,27 +265,15 @@ fn parse_per_expert_name(name: &str) -> Option<(usize, &'static str, usize)> { let (block_str, tail) = rest.split_once('.')?; let block: usize = block_str.parse().ok()?; - for (prefix, role) in [ - ("ffn_gate.", "gate"), - ("ffn_up.", "up"), - ("ffn_down.", "down"), - ] { - if let Some(sub) = tail.strip_prefix(prefix) { - let expert_str = sub.strip_suffix(".weight")?; - let expert: usize = expert_str.parse().ok()?; - return Some((block, role, expert)); - } - } - for (prefix, role) in [ - ("ffn_gate_", "gate"), - ("ffn_up_", "up"), - ("ffn_down_", "down"), - ] { - if let Some(sub) = tail.strip_prefix(prefix) { - let expert_str = sub.strip_suffix(".weight")?; - let expert: usize = expert_str.parse().ok()?; + for (prefix, role) in PER_EXPERT_NAME_PATTERNS { + if let Some(expert) = parse_expert_index(tail, prefix) { return Some((block, role, expert)); } } None } + +fn parse_expert_index(tail: &str, prefix: &str) -> Option { + let sub = tail.strip_prefix(prefix)?; + sub.strip_suffix(".weight")?.parse().ok() +} diff --git a/src/moe/mod.rs b/src/moe/mod.rs index 85bac34..fef0c7e 100644 --- a/src/moe/mod.rs +++ b/src/moe/mod.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! Mixture-of-Experts weight extraction. //! //! Locates MoE expert tensors inside a parsed [`GgufLayout`](crate::gguf::GgufLayout) diff --git a/tests/gguf_smoke.rs b/tests/gguf_smoke.rs index 1c940e9..7605f72 100644 --- a/tests/gguf_smoke.rs +++ b/tests/gguf_smoke.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + //! End-to-end smoke test: build a synthetic GGUF in memory, parse it, //! and verify that expert extraction round-trips both the stacked and //! per-expert storage conventions.