|
| 1 | +# AGENTS.md — rumcodecs |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +rumcodecs is a pnpm workspace providing **buffer compression codecs for |
| 6 | +JavaScript** — a Rust/WASM reimplementation of |
| 7 | +[numcodecs.js](https://github.com/manzt/numcodecs.js) built for Zarr. Five |
| 8 | +codec classes are exported from one npm package, |
| 9 | +`@fideus-labs/rumcodecs`: |
| 10 | + |
| 11 | +- **`Blosc`** — the Blosc meta-compressor via the pure-Rust |
| 12 | + [blusc](https://crates.io/crates/blusc) crate, compiled to WASM (SIMD128). |
| 13 | +- **`LZ4`** — [lz4_flex](https://crates.io/crates/lz4_flex) via WASM, using |
| 14 | + numcodecs framing (4-byte little-endian original size + LZ4 block). |
| 15 | +- **`Zstd`** — the [zstd](https://crates.io/crates/zstd) crate via WASM, |
| 16 | + standard Zstd frames. |
| 17 | +- **`GZip`** / **`Zlib`** — pure JS via |
| 18 | + [fflate](https://github.com/101arrowz/fflate) (a peer dependency); no WASM |
| 19 | + involved. |
| 20 | + |
| 21 | +The contract is **drop-in compatibility**: identical API surface to |
| 22 | +numcodecs.js and identical byte formats to Python numcodecs, so chunks are |
| 23 | +interchangeable across ecosystems. Three layers hold that up: |
| 24 | +[`test/compat.test.ts`](./test/compat.test.ts) asserts the API surface, |
| 25 | +[`test/interop.test.ts`](./test/interop.test.ts) decodes buffers actually |
| 26 | +written by Python `numcodecs`, and the Rust unit tests in |
| 27 | +[`lib.rs`](./crates/rumcodecs-wasm/src/lib.rs) pin the framing we emit. Note |
| 28 | +what the per-codec suites do _not_ prove: they only show encode/decode symmetry, |
| 29 | +which would survive both directions drifting off the format together. The |
| 30 | +interop fixtures are the ones that fail when that happens. |
| 31 | + |
| 32 | +## Modules |
| 33 | + |
| 34 | +| Module | Read | |
| 35 | +| ---------------------------------------------- | ------------------------------------------------------------------------ | |
| 36 | +| TypeScript codec classes | [./src/](./src/) | |
| 37 | +| Shared `Codec` / `CodecConstructor` interfaces | [./src/types.ts](./src/types.ts) | |
| 38 | +| Rust WASM bindings (blusc, lz4_flex, zstd) | [./crates/rumcodecs-wasm/src/lib.rs](./crates/rumcodecs-wasm/src/lib.rs) | |
| 39 | +| Test suites (round-trip, compat, interop) | [./test/](./test/) | |
| 40 | +| Benchmark package (vs numcodecs.js) | [./benchmark/](./benchmark/) | |
| 41 | +| CI / release workflows | [./.github/workflows/](./.github/workflows/) | |
| 42 | + |
| 43 | +## Context to load on demand |
| 44 | + |
| 45 | +| Task | Read | |
| 46 | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 47 | +| Add or change a codec class | [./src/types.ts](./src/types.ts), the sibling codec in [./src/](./src/), its test in [./test/](./test/) | |
| 48 | +| numcodecs API-compatibility rules | [./test/compat.test.ts](./test/compat.test.ts) | |
| 49 | +| Byte-format / interop questions | [./test/interop.test.ts](./test/interop.test.ts), [./test/fixtures/README.md](./test/fixtures/README.md) | |
| 50 | +| Change or add WASM exports | [./crates/rumcodecs-wasm/src/lib.rs](./crates/rumcodecs-wasm/src/lib.rs), the `build:wasm*` scripts in [./package.json](./package.json) | |
| 51 | +| Run or extend benchmarks | [./benchmark/src/run.ts](./benchmark/src/run.ts), [./benchmark/src/data.ts](./benchmark/src/data.ts), [./benchmark/src/results.ts](./benchmark/src/results.ts) | |
| 52 | +| CI matrix and gates | [./.github/workflows/ci.yml](./.github/workflows/ci.yml), [./.github/workflows/rust-ci.yml](./.github/workflows/rust-ci.yml) | |
| 53 | +| Release / publish flow | [./.github/workflows/release.yml](./.github/workflows/release.yml) | |
| 54 | + |
| 55 | +## Conventions |
| 56 | + |
| 57 | +- Conventional Commits with optional scopes: `feat: …`, `fix(blosc): …`, |
| 58 | + `ci: …`, `build: …` — match the existing `git log` style. |
| 59 | +- Tooling is [vite-plus](https://www.npmjs.com/package/vite-plus) (`vp`): |
| 60 | + `pnpm build` / `pnpm test` / `pnpm check` wrap `vp build` / `vp test` / |
| 61 | + `vp check` (oxlint + oxfmt + typecheck). Keep `pnpm check` clean before |
| 62 | + committing — CI runs it as a gate. |
| 63 | +- **Every codec class is a drop-in for numcodecs.js**: static `codecId`, |
| 64 | + static `fromConfig(config)`, the same constructor signature, and the same |
| 65 | + static members (`Blosc.SHUFFLE`, `LZ4.max_buffer_size`, |
| 66 | + `Zstd.MAX_CLEVEL`, …). Any new surface must be asserted in |
| 67 | + [./test/compat.test.ts](./test/compat.test.ts). |
| 68 | +- **Byte formats are Python-numcodecs compatible** and must never change |
| 69 | + silently: LZ4 uses a 4-byte little-endian original-size header before the |
| 70 | + block; Zstd emits standard frames; Blosc emits Blosc1-compatible frames |
| 71 | + (`BLOSC_FORWARD_COMPAT_SPLIT`). A new codec or option combination needs a |
| 72 | + fixture in [./test/fixtures/](./test/fixtures/) — regenerate with its |
| 73 | + `generate.py`, and do not refresh existing fixtures to make a failure go |
| 74 | + away, since that discards the evidence of the break. |
| 75 | +- WASM-backed codecs load the module lazily through a cached dynamic import |
| 76 | + (the `getModule()` pattern in [./src/blosc.ts](./src/blosc.ts)); new |
| 77 | + WASM-backed codecs must reuse it so pure-JS codecs never pay the WASM load. |
| 78 | +- `GZip` and `Zlib` stay pure-JS on fflate, and fflate stays a |
| 79 | + `peerDependency` — do not move it to `dependencies` or reimplement it in |
| 80 | + the WASM crate. |
| 81 | +- The Rust functions return an empty `Vec` on failure; the TypeScript layer |
| 82 | + converts that into a thrown `Error`. Keep error handling on that boundary — |
| 83 | + **no panics across `wasm_bindgen`**, because a panic traps the WASM instance |
| 84 | + and every later call fails with it, so one corrupt chunk breaks every |
| 85 | + subsequent read. |
| 86 | +- **An empty `Vec` alone is not a sufficient error signal**, because a frame |
| 87 | + that legitimately holds zero bytes decodes to exactly the same thing. Each |
| 88 | + wrapper compares the result against the length the frame itself declares: |
| 89 | + `blosc_declared_size`, the 4-byte LE header `src/lz4.ts` reads directly, and |
| 90 | + `zstd_declared_content_size`. Only a mismatch is an error. Getting this wrong |
| 91 | + is silent: with `out` supplied, a "failed" decode returns the caller's own |
| 92 | + untouched buffer, which reads as data rather than as an error. Note the limit |
| 93 | + of this check — it catches truncation and a short decode, but a Blosc1 frame |
| 94 | + carries no checksum over its payload, so corruption that still parses can |
| 95 | + decode to the declared length and be returned as valid. That is pinned in |
| 96 | + [./test/compat.test.ts](./test/compat.test.ts) so it stays a known fact. |
| 97 | +- **Allocations sized from a header must be fallible.** The plausibility guards |
| 98 | + below bound what a header may claim, not what the allocator can supply; an |
| 99 | + infallible `vec![0; n]` turns a shortfall into `handle_alloc_error`, which |
| 100 | + aborts the module exactly like a panic. Reserve through `try_reserve_exact` |
| 101 | + (see `try_zeroed`) so it degrades to an ordinary decode failure. |
| 102 | +- **Sizes read out of a compressed header are untrusted input.** Every decoder |
| 103 | + we wrap trusts them to index or to size an allocation up front — `blusc` |
| 104 | + indexes the source with them, `lz4_flex` zero-fills the declared size before |
| 105 | + reading a byte, `zstd` reserves the declared content size — so a corrupt |
| 106 | + header turns into an out-of-bounds panic or a multi-gigabyte allocation that |
| 107 | + aborts the module. Validate before handing anything over: |
| 108 | + `blosc_header_is_sane`, `lz4_declared_size_is_plausible`, |
| 109 | + `zstd_declared_size_is_plausible`. The expansion bounds come from each |
| 110 | + format's own ceiling (LZ4 255:1, zstd 32768:1) and are pinned by tests that |
| 111 | + compress a constant buffer, which sits right on that ceiling — tighten them |
| 112 | + and real data starts failing to decode. |
| 113 | +- `decode(data, out?)` on the WASM-backed codecs must honor the optional `out` |
| 114 | + buffer (write into it and return it) — zarr consumers rely on this. `GZip` |
| 115 | + and `Zlib` ignore `out` and return fflate's own buffer, matching numcodecs.js |
| 116 | + exactly; both behaviors are pinned in |
| 117 | + [./test/compat.test.ts](./test/compat.test.ts). |
| 118 | +- WASM builds require `RUSTFLAGS="-C target-feature=+simd128"` and build both |
| 119 | + `web` and `nodejs` targets into `pkg/` (see the `build:wasm*` scripts); |
| 120 | + `pkg/` is generated output, never hand-edited. |
| 121 | +- A new codec touches five places: `src/<codec>.ts`, `src/index.ts`, the export |
| 122 | + map in `package.json` (subpath export), the `build.lib.entry` list in |
| 123 | + [./vite.config.ts](./vite.config.ts), and a `test/<codec>.test.ts` — plus the |
| 124 | + codec table in [./README.md](./README.md). Miss the vite entry and the |
| 125 | + advertised subpath resolves to a file that was never built; |
| 126 | + [./test/index.test.ts](./test/index.test.ts) checks the three lists agree. |
| 127 | +- Node ≥ 18 is supported (`engines`); CI tests Node 22 and 24. |
| 128 | + |
| 129 | +## Key dependencies |
| 130 | + |
| 131 | +| Dependency | Version | Notes | |
| 132 | +| ------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------- | |
| 133 | +| `blusc` | 0.0.6 | Pure-Rust Blosc (Blosc2 API, Blosc1-compatible output) — the `Blosc` codec | |
| 134 | +| `lz4_flex` | ~0.12 | Pure-Rust LZ4 block compression — the `LZ4` codec | |
| 135 | +| `zstd` | ~0.13 (no default features) | Zstd — the `Zstd` codec | |
| 136 | +| `wasm-bindgen` / `js-sys` | 0.2 / 0.3 | WASM boundary | |
| 137 | +| `fflate` | ^0.8 (peer) | Pure-JS gzip/zlib — the `GZip` and `Zlib` codecs | |
| 138 | +| `vite-plus` (`vp`) | 0.2 | Build / test / check driver | |
| 139 | +| `vitest` | 4 | Test runner | |
| 140 | +| `tinybench` | 6 | Benchmark timing (benchmark package) | |
| 141 | +| `numcodecs` (npm) | ^0.3.2 (dev) | **numcodecs.js** — the head-to-head baseline in [./benchmark/](./benchmark/) | |
| 142 | +| `numcodecs` (PyPI) | 0.16.5 | **Python numcodecs** — wrote [./test/fixtures/](./test/fixtures/); never installed, `uv` fetches it pinned | |
0 commit comments