diff --git a/.claude/blackboard.md b/.claude/blackboard.md index 8d733430..a27b987b 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -3721,3 +3721,25 @@ Operator chose Option A after #348. P0 landed: `.cargo/config-avx.toml` `qemu-x86_64-static -cpu SandyBridge`). Before the backend exists the arm FAILS with exit 132 from `cpu_guard`'s AVX2 floor (measured) — the expected red. No CI row yet; it is added when the arm goes green. + +## Codec8: 8-bit code spaces + certified tanh tables — plan RATIFIED 2026-10-24 (PR #370, being refactored) + +Plan `.claude/plans/quantized-codec8-v2.md` (content v3), ratified by a 5+3 council (five Sonnet +savants: prior-art, iron-rule, code-truth, cascade-impact, creative-explorer; three Sonnet reviewers: +overclaim, dilution-collapse, firewall; 0 BLOCK, 7 FIX(P1), 8 FIX(P2), all applied). +Decisions recorded: one `Codec8` over sealed markers, +calibration as a marker type, no runtime dispatch (operator ruling), certified `const` tables from +`tools/gen_codec8_tables.py` (mpmath, `--check` in CI), SIMD primitives land first under W1a. +Loose ends found by the council: `pub mod hpc` allows `clippy::all` (`src/lib.rs:525`), so clippy +does not gate any hpc file today (codec8 opts back in); simd-matrix does not trigger on hpc files +other than amx; `I8x64` lacks xor/cmpeq_mask/blend and no f32->i8 round+narrow primitive exists. +Open for the operator: plan §8 (NativeLane rounding, spoFC rounding, U8+NaN arm, tool, ±inf). +PR #370's runtime-scale prototype was backed out in 62c43c9. + +### Codec8 phase (a) landed on PR #370 (2026-10-24) + +`src/hpc/codec8.rs` scalar: 14 unit tests + 10 doctests green, clippy -D warnings clean (codec8 opts +back into clippy::all), fmt clean. G5 asm identity: generic == hand-written on x86-64-v3 and -v4, +negative control differs. G6 2% timing band not resolvable here (identical code varies up to 17%); +recorded as NOT MET, G5 is the evidence. Results in plan §11. Next: phase (b) SIMD primitives in +src/simd_*.rs under W1a. diff --git a/.claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md b/.claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md new file mode 100644 index 00000000..4bc82b02 --- /dev/null +++ b/.claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md @@ -0,0 +1,57 @@ +# /coresearch exploration map: ndarray 8-bit code spaces + tanh tables (2026-10-24) + +> Banked verbatim from the session scratchpad so the council record survives the container. **Read through `.claude/plans/quantized-codec8-v2.md`:** the plan supersedes X1 (runtime enum → compile-time `Codec8` markers), X11 (U8 is in scope now) and the X12 key (calibration is compile-time). The verdicts and probes otherwise stand. + + +Live consumers: ndarray, lance-graph-onnx (#1758 draft). Not consumers: FamilyGamma, bgz-tensor i8 encoders (operator). palette256 = index consumer of all 256 codes. thinking-engine: unconfirmed. + +## Verdicts +| # | idea | verdict | reason | +|---|---|---|---| +| X1 | `I8CodeSpace {Asym, SymNaN}` (#[non_exhaustive], U8 later) + `Rounding {TiesAway, TiesEven}` beside, not inside, QuantParams | ADOPT-NOW (design) | all three co-architects agree; QuantParams has pub fields | +| X2 | Asym = today's quantize/dequantize byte-identical (ties-away) | ADOPT-NOW | A3 "as is"; pin with byte-identity test | +| X3/X4 | SymNaN: clamp(-127,127), NaN -> -128, decode(-128) = NaN | PROBE (P-04, P-12) then adopt | precedent posit NaR / ONNX float8 FNUZ; OCP MX rejected variant recorded | +| X5 | today's NaN->0, inf->scale inf defect | PROBE (P-03 pin) | behaviour change to Asym needs its own decision; not in this work | +| X6 | NaN-preserving i8 neg/abs | PARK | cheaper: decode to f32, no i8 arithmetic in SymNaN space; saturating_abs untouched | +| X7 | rounding as explicit field | PROBE (P-02) | precedents split (ONNX/OCP RNE, TFLite/ndarray away) | +| X8 | H-SYM head-to-head | PROBE (P-01, P-13) | expected: no gain on data-derived scales (-128 never emitted); gain only where -128 is emitted | +| X9 | 255 vs 256 doc table | ADOPT-NOW (doc) | A7; SymNaN never as index | +| X10 | i16 pair-accumulation | PARK | only s8xs8 paths; no consumer | +| X11 | U8 | PARK (deferred, A3) | offset = Asym ^ 0x80; midrise has no exact zero | +| X12 | `CodeLut256` (keyed spec+scale+zp+f) + mode-agnostic `lut256_apply` via ndarray::simd (all backends) | PROBE (P-07, P-09 gate) | adopt only if <= 0.5x vstanh(decode) ns/elem | +| X13 | 128-entry odd-mirror table | PROBE (P-08) | needs vstanh bitwise odd | +| X14 | "free" measured in ns | ADOPT-NOW (gate) | release, tier named, == not <=ULP | +| X15 | Arrow bitmap vs in-band NaN | PARK (Lance boundary) | decide when a Lance column carries SymNaN | +| X16 | lance-graph-onnx per-mode exactness tests (test-only) | ADOPT-NOW (after X1/X12 land) | A8; guard: non-test src never names the codec | +| X17 | "LUT next to mish" | SKIP (placement) | mechanism kept, placed with the codec | +| X18 | vml.rs:637 "2 ULP" -> "<= 1 ULP" | ADOPT-NOW (separate one-liner) | doc drift | + +## Corrections found during synthesis +- H-SYM count: 127 neg + 0 + 127 pos (0 is the unique median code); I8asym mean -0.5 LSB only where -128 is emitted. +- Falsifier P-01 refutation (a) contradicted its own silent outcome; fixed: refute only where Asym emits -128, strict >; elsewhere assert equality. +- lance-graph-onnx already depends on ndarray normally; "dev-dep only" is moot. A2 enforcement = non-test src does not reference the codec module. + +## Not searched +XNNPACK/oneDNN LUT internals; ONNX opset diffs 19->28; Lance's own null docs; real-data histograms (D3, NOT RUN); thinking-engine live-ness. + +## Addendum — helix / Signed360 (operator detour, 2026-10-24) +| # | idea | verdict | reason | +|---|---|---|---| +| X19 | Cyclic code space (Z_256): wrap, no NaN, neg=(256-c)&0xFF, ring metric, circular mean | PARK (record as a named kind; `#[non_exhaustive]` leaves room) | helix ships the metric (`DistanceLut::circular`), not a codec; no live consumer; its tables are sin/cos (PhaseLut), not tanh | +| X20 | **The tanh table is the decode, not a post-step**: with codes uniform in z, value = tanh(z0 + (c[+0.5])·dz) | ADOPT-NOW (design correction) | helix measured uniform-in-z axis 1.0x distortion vs 11.6x (y) / 204x (r); bounded pole error vs divergent | +| X21 | Table key must carry the full decode affine (z0, dz, midtread vs midrise), not just "scale" | ADOPT-NOW | otherwise a tanh of a linearly decoded value is a different code | +| X22 | SymNaN is i8-only; never reinterpret u8 helix bytes (0x80 = +0 / bucket 128 / azimuth π) | ADOPT-NOW (doc + type) | namespace collision on 0x80 | +| X23 | U8 sign-partition (helix polar: two zeros, neg = c^0xFF) | PARK with U8 | measured pole penalty 1/r | +| X24 | Helix two azimuth rounding conventions | report only | out of scope; helix not live | + +## Addendum — Moore tenant / Palette256 law (operator, 2026-10-24) +Read on origin/main: `cognitive-shader-driver/src/palette_perturbation.rs` (module doc + PaletteLut/PairAddress), `tests/moore_palette_pairs_lut.rs`, board entry `2026-10-08-hhtl-nars-moore-value-tenants.md`. +- `ValueTenant::MoorePalettePairs` (ord 19): 8 Moore slots NW..SE, each a `(u8:u8)` Palette256 pair; law address `(first<<8)|second`. Layout TEST-PINNED; no production writer/reader yet. +- The law is closed: `Palette256 × Palette256 -> Palette256` (u8,u8 -> u8), 64 KiB, "Runtime code does not reconstruct cosine, Fisher-Z, covariance, or any other continuous representation." Calibration pays the tax before the hot path; evaluation = one indexed byte load. Composition `[a,b]->r0, [c,d]->r1, [r0,r1]->delta` keeps intermediates in registers; no 256^4 table, no intermediate population. + +| # | idea | verdict | reason | +|---|---|---|---| +| X25 | **Stay in code space**: the native hot path for tanh-domain values is a closed code->code law (u8 or i8, 256 / 256×256 entries returning a CODE), never decode->f32->tanh per element | ADOPT-NOW (design correction) | Moore/Palette256 precedent; matches helix "table bake once, never reconstruct" | +| X26 | The f32 `CodeLut256` (tanh(decode(c))) is the BOUNDARY / ORACLE table: used where a float must leave (ONNX facade conformance, export), and as the oracle the code->code law is baked from | ADOPT-NOW (reposition X12) | keeps A8 exactness tests; removes the per-element f32 path from the native claim | +| X27 | Unary code law `[f32? no] code -> code` (256 B) for element-wise ops in a code space (e.g. tanh re-quantized into an output code space, TFLite S3 shape) beside the binary 64 KiB law | PROBE | TFLite int8 tanh LUT is exactly this (int8 out); bake = decode -> vstanh -> encode once | +| X28 | Which code space the law runs in: SymNaN value lanes (NaN slot must map NaN->NaN in every law) vs Asym/Palette256 indices (all 256) | PROBE | law totality: a SymNaN law must send code -128 to -128 in both operands; test exhaustively | diff --git a/.claude/plans/quantized-codec8-v2.md b/.claude/plans/quantized-codec8-v2.md new file mode 100644 index 00000000..a3ffcc48 --- /dev/null +++ b/.claude/plans/quantized-codec8-v2.md @@ -0,0 +1,347 @@ +# Plan: 8-bit code spaces as one compile-time codec, plus certified tanh tables (v3, RATIFIED) + +**Status:** RATIFIED by a 5+3 council on 2026-10-24 (v1 → v2 → v3; council record in §9). +Phase (a) is executable. The filename keeps `-v2` so links stay stable; the content is v3. +Replaces the staged prompt `.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md` +(superseded). PR #370 is being refactored to this plan. + +**Source:** /coresearch 2026-10-24 (exploration map in +`.claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md`), the TFLM harvest (AdaWorldAPI/tflite-micro +`.claude/harvest/2026-10-24-tflm-quant-lut-harvest.md`, merged), lab probes from the same session, and +the operator's rulings quoted in §2. + +## 1. Goal + +Three 8-bit modes, kept and run head to head (no single winner): + +| mode | code space | range | NaN | storage | +|---|---|---|---|---| +| I8 asymmetric | `Asym` | -128..127, -128 a value | none | i8, or u8 = code ^ 0x80 | +| I8 symmetric + NaN | `Sym` + `NaNAtMin` | -127..127 | -128 | i8 (u8 + NaN parked, §8 item 3) | +| U8 for spoFC truth and codebooks | `UnitFull` | 0..255, value c/255 | none | u8, raw code | + +U8 is a separate code space, not an XOR image of I8: `UnitFull` has no I8 counterpart. The XOR +relation applies only between I8 and u8 storage of `Asym` / `Sym`. + +Plus 256-entry tables so tanh of a decoded code costs one byte load in native lance-graph. +Hypothesis under test (H-SYM, operator): -127..0 and 0..127 are symmetric with 0 as the median, so +the symmetric range has no skew. Not assumed; P-01 measures it. + +## 2. Rulings this plan is bound by + +- **No runtime dispatch.** Operator: "Lookup runtime dispatch is a category error we want no + dispatch overhead cost that's why the compile time parameters. For Lab calibration when + performance is negligible and recompiling hurt's you can use it behind a Cargo parameter, the + performance needs to have it compile time." +- **NaN policy is a compile-time parameter.** Storage (i8 | u8) is a compile-time parameter with + storage-specific SIMD code. +- **LLVM output is measured**, so the parameters cannot degrade code silently. +- **Re-export, not re-implementation.** `lance-graph-tflite` and `lance-graph-onnx` are `pub use` + facades; the implementations live here. +- **Unchanged:** `quantize_f32_to_i8` (`src/hpc/quantized.rs:545`), `dequantize_i8_to_f32` (:586), + `quantize_f32_to_u8` (:517), `QuantParams` (:496), FamilyGamma and its -128 interpretation, the + persisted Fisher-Z format, every wire format. +- **Not consumers:** FamilyGamma and bgz-tensor. No dependency on them. + +## 3. Facts the design rests on + +Verified by reading the code at `62c43c9` unless marked otherwise. + +- `quantize_f32_to_i8` (:545): scale `abs_max / 127.0`, falling back to 1.0 when abs_max is 0 + (:549); `(v / scale).round().clamp(-128.0, 127.0) as i8` (:553). `f32::round` rounds ties away from + zero. A NaN input becomes 0 through the saturating cast. The min/max folds use `f32::min`/`max`, + which ignore NaN. +- `pub mod hpc` (`src/lib.rs:524-526`) is `#[cfg(feature = "std")]`, so codec8 never compiles on the + no_std row. It also carries `#[allow(clippy::all, unused_imports, unused_variables, unused_mut, + dead_code)]`, so clippy does not check files under `hpc/` today. `#![warn(missing_docs)]` is + crate-wide (:31). +- `ci.yaml:48` sets `RUSTFLAGS` to `-D warnings` plus two cfgs. Jobs that run `env -u RUSTFLAGS` + (e.g. `ci.yaml:334-336`) do not get `-D warnings`. +- Per the council's S1/S2 searches of `src/simd_avx512.rs` (not re-read for every backend): + - `I8x64` (:2003-2081) has splat, zero, from/to slice/array, add, sub, min, max, cmp_gt. + - `U8x64` (:522-851) has cmpeq_mask (:609), cmpgt_mask, mask_blend, saturating ops, + permute/shuffle_bytes and BitXor. + - `F32x16` has round, floor, simd_clamp, cast_i32. + - No f32→i8 round-and-narrow primitive turned up in that file. +- Generator precedent: `tools/gen_ternlog_bodies.py` has a `--check` mode that runs in + `.github/workflows/simd-matrix.yaml`. A repo-wide glob found no `*_generated.rs` and no tablegen + crate. `Cargo.toml:516-518` makes every `crates/*` directory a workspace member. +- The simd-matrix path filter (`simd-matrix.yaml:3-19`) does not include `src/hpc/codec8*`. +- Reported by council savant S3, not re-run by the reviewers: over tie-adjacent inputs, `v / s` and + `v * (1/s)` produced 25,928 different rounded codes. For example, at s = 0.0137 and v = -1.73305, + `v / s` is -126.5 but `v * (1/s)` is -126.49999. +- Measured earlier this session (lab probes, scratchpad, not in the repo): + - Generic vs hand-written kernels produce label-normalised-identical assembly on x86-64-v3 and + -v4 (1.311 vs 1.308 ns/elem). A per-element mode match does not vectorise (1.85-1.96 ns/elem). + - `f64::tanh` calls glibc, which is up to 1.95 ulp off. TiesEven and TiesAway give identical tanh + tables, because 8-bit tanh outputs never land on a tie. + - A rounded spoFC confidence reaches 255 (k=1, m=510), so a rounded encoder needs a cap at 254. + - A u8×s8 VPMADDUBSW pair sum saturates int16 even with valid weights. + +## 4. Design (committed) + +**D1 Module.** `src/hpc/codec8.rs`, plus `pub mod codec8;` in `src/hpc/mod.rs`. +- The file opens with `#![warn(clippy::all)]` to undo the parent allow. A negative control (§6) + proves this override is real. +- No `unsafe`. Tests are endian-neutral, because the i686 and s390x rows compile hpc. + +**D2 Markers.** All markers are sealed zero-sized types. +- `Storage`: `I8`, `U8` (associated `Elem`). +- `CodeSpace`: `Asym` (-128..127), `Sym` (-127..127), `UnitFull` (c/255, 0..255). +- `#[cfg(test)]`-only P-16 arms: `UnitOdd` (c/254) and `UnitMidrise` ((c+0.5)/256). They become + public only if P-16 adopts one. +- `Rounding`: `TiesAway`, `TiesEven`, `Floor`. +- `NanPolicy`: `NoNaN` for every space; `NaNAtMin` implemented for `Sym` only. +- `struct Codec8 where N: NanPolicy`. So `Codec8` is + expected to fail with E0277; G3 checks it. + +**D3 Calibration and encode/decode.** `trait Calibration { const SCALE: f32; const ZERO_POINT: i32; }`. +- Encode, per element: `R::round(v / C::SCALE)` (a true division; never a precomputed reciprocal + in a rounding mode), then `+ ZERO_POINT`, then clamp `(S::MIN, S::MAX)` in f32, then a saturating + cast. For NaN input, `N::NAN_CODE` replaces the result. +- Decode is defined per code space: + - Asym/Sym: `(c - ZP) as f32 * C::SCALE`, mirroring `dequantize_i8_to_f32`. + - UnitFull: `c as f32 / 255.0`, a division so that 255 decodes to exactly 1.0. +- UnitFull also has an integer encoder `encode_ratio(n, d) = floor(n·255/d).min(255)`, the spoFC + form. G13 checks it against the spoFC encoder. +- Signatures are `encode(&[f32], &mut [T::Elem])` and `decode(&[T::Elem], &mut [f32])`: no `Vec`, + no `Result` in the hot body. + +**D4 Storage.** I8 stores the code itself. U8 stores `code ^ 0x80` for `Asym` / `Sym` (TFLM requantize +`requantize.h:47`) and the raw code for `UnitFull`. For U8 + `Sym` the unused slot is 0x00. + +**D5 Boundary.** +- `encode_checked` / `decode_checked` are `pub` and validate once per slice, then run the unchecked + body. +- The unchecked bodies are `pub(crate)`. Out-of-range or NaN input to them gives a defined + saturating result, never undefined behaviour. So a missed validation produces a wrong but defined + value, and a test pins that value. + +**D6 Aliases.** Each alias is a named preset that fixes a rounding for a known external format; the +rounding stays a separate parameter. + +| alias | parameters | exported | +|---|---|---| +| `TfliteAct` | `` | phase (a) | +| `TfliteWeight` | `` | phase (a) | +| `OnnxQ` | `` | phase (a) | +| `SpoFcTruth` | `` | phase (a) | +| `NativeLane` | `` | not until §8 item 1 is decided; meanwhile tested with both roundings | + +**D7 Tables (phase c).** + +| mode | tables | NaN slot | +|---|---|---| +| I8 asym | unary code→code tanh law `[i8; 256]`; f32 boundary `[f32; 256]` | none | +| I8 sym + NaN | same two tables | entry for code -128 is the NaN code / f32 NaN | +| U8 UnitFull | f32 boundary `[f32; 256]`; unary law into a named output space | none | + +- **Generator.** `tools/gen_codec8_tables.py` (mpmath), following `tools/gen_ternlog_bodies.py`. It + writes `src/hpc/codec8/tables.rs` (with a "generated, do not edit" header) and binary 64 KiB laws + as `src/hpc/codec8/laws/*.bin` read with `include_bytes!`. Laws are generated only on demand. +- **Generator design** (not built yet): + - interval narrowing for each cell; + - an explicit error naming the cell when the precision limit is reached; + - signed zero handled explicitly; + - the NaN slot handled explicitly; + - decode overflow defined: tanh(±inf) = ±1, or a generation error if the calibration makes it + reachable unintentionally. +- **Reference.** The tables use correctly rounded tanh at the exact decoded f32 value. `vstanh` is + only a differential accuracy test, never the definition. + +**D8 Calibration source and lab.** +- **Calibration markers.** For a given model they come from a calibration manifest, read by the + same generator. +- **Arbitrary-model conformance.** It runs in tests under the Cargo feature `lab-calibration`. +- **What `lab-calibration` adds:** + - `LabCodec8`, a concrete type with an owned `Box<[T::Elem; 256]>`; + - `prepare() -> Result<_, PrepareError>`, which may fail; + - lookup that takes `&self`. +- **What it never has:** no enum, no `dyn`, no `OnceLock`/`LazyLock`, and it is off by default. + +**D9 Landing order.** Each step is its own PR. +- (a) codec8 scalar + Phase 0 tests + asm/timing probe. +- (b) SIMD primitives in `src/simd_*.rs` under the W1a contract + (`.claude/knowledge/vertical-simd-consumer-contract.md`: all backends, parity test, `///` docs + with examples), then codec8 SIMD. Candidate lookup primitive: `U8x64::permute_bytes`, measured + against gather. +- (c) generator + tables. +- (d) laws. +- (e) head-to-head probes. +- (f) facade PRs in lance-graph. + +## 5. Gates (pre-registered) + +- **G1 Local checks.** Before any push, the orchestrator alone runs, with env + `CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 CARGO_INCREMENTAL=0`: + - `cargo fmt --check`; + - `RUSTFLAGS="-D warnings" cargo clippy -p ndarray --all-targets`; + - `cargo test --release -p ndarray --lib hpc::codec8`; + - `cargo test --doc -p ndarray codec8`. +- **G2 Byte identity.** `TfliteAct` with `SCALE = f32::from_bits(B)` equals `quantize_f32_to_i8` + bit for bit on a fixed corpus, including NaN and ±inf rows. + - B is the fitted scale of that corpus. The test asserts that the runtime fit produces exactly B + and that the corpus does not trigger the 1.0 fallback. + - This gate tests **data rounding** only. +- **G3 Compile-fail.** A `compile_fail` doctest `let _: Codec8;`, plus + a compiling twin with `Sym`. +- **G4 XOR storage.** U8 storage equals I8 storage ^ 0x80 on all 256 codes for every Asym/Sym alias. +- **G5 Assembly.** For each alias vs a hand-written kernel, label-normalised assembly on x86-64-v3 + and -v4 is identical, else FAIL with the diff reported. A negative control must differ, and the + tier is printed. +- **G6 Timing.** Each alias is within 2% of the hand-written kernel, over ≥5 runs, order-swapped, + in ns/elem, with the tier printed. The 2% is a pre-registered threshold, not derived from + measured variance. +- **G7 Table cells.** Every table cell equals the reference, computed by mpmath **independently of + the generator code path**. This gate tests **table rounding** only (decoded f32 → tanh → target + cell). + - Disable 1: index offset 128 (indexing). + - Disable 2: wrong calibration (decode affine). + - Either disable must turn the gate red. +- **G8 Generator check.** `tools/gen_codec8_tables.py --check` runs as a step in + `.github/workflows/simd-matrix.yaml`, path-triggered on `src/hpc/codec8/**` and `tools/gen_codec8*`. +- **G9 Lab feature.** A `ci.yaml` step asserts that `lab-calibration` is absent from + `cargo tree -e features -p ndarray`. +- **G10 Lookup speed.** Lookup ≤ 0.5× `vstanh(decode)` ns/elem at 64 Ki and 16 Mi elements + (release). Preparation cost is reported separately (lab). +- **G11 Table size.** Generated table bytes are reported per landing; more than 1 MiB in total + needs an explicit decision. +- **G12 Backend parity.** From (b), the simd-matrix path filter includes `src/hpc/codec8*` in the + same PR, and rounding parity is checked on every arm (wasm, neon-qemu, avx-qemu, v2-qemu, + nightly). +- **G13 spoFC bytes.** `encode_ratio` equals the spoFC encoder + (lance-graph `crates/lance-graph-arm-discovery/src/translator.rs:61-120`, transcribed into the + test with a source citation) over the evidence grid m in 0..=4096, k in {1, 2, 5}. +- **G14 Head to head.** In phase (e), the three modes run on the same corpus: per-mode error (RMSE, + bias in LSB), -128 emissions and ns/elem, in one table. H-SYM is refuted only where Asym actually + emits -128. + +## 6. Checklist + +### Phase (a) +- [x] `src/hpc/codec8.rs`: D1-D6 scalar, docs with an example on every `pub` item. +- [x] Tests (14 unit tests, 10 doctests incl. two `compile_fail,E0277`): + - [x] G2, G3, G4, G13. + - [x] P-02 ties, including negative half-ties. + - [x] P-04 Sym+NaNAtMin: NaN, ±1e9, ±inf (as proposal), ±0. + - [x] P-12 encode∘decode on all 256 codes per alias. + - [x] Sym+NoNaN vs Sym+NaNAtMin agree everywhere except NaN and -128. + - [x] D5: the pinned defined result on out-of-range input. + - [x] Carried from #370: signed zero, both saturations, nonzero zero point. +- [x] Negative control for D1, run before the commit and recorded: a deliberate clippy violation in + codec8.rs fails G1, then is reverted. +- [x] `examples/codec8_asm_probe.rs` (G5/G6), ported from the lab `codec-asm-probe`. It has its own + doc comment and is not in the default test run. +- [x] Commit discipline: + - edit script `&&` `git add` `&&` `git commit` (never sequenced); + - the blackboard entry and plan ticks are in the same commit; + - no model identifier anywhere except the attribution trailer. +- [x] Workers, if any, edit only. The orchestrator is the single compiler on the shared `target/`. + Every timing names its tier. + +### Phases (b)-(f) +Tracked per PR as listed in D9 and §5. + +## 7. Non-goals + +- Existing quantizers, FamilyGamma, Fisher-Z and wire formats. +- An ONNX runtime, a graph compiler, or operator fusion. +- A port of TFLM's gemmlowp code (facade work, later). +- The cyclic Z_256 space and the U8 sign-partition (parked). +- Real-model data runs (manifest D stays NOT RUN). + +## 8. Open for the operator + +1. **NativeLane rounding:** TiesEven (proposed) or TiesAway. + - Restore condition: the decision exports the `NativeLane` alias. +2. **spoFC rounding:** Floor is kept as today's behaviour. + - Rounding stays a P-16 head-to-head variable (Floor vs TiesEven with a 254 cap), not a settled + fact. +3. **U8 + NaNAtMin** (a zero-filled buffer reads as all-NaN): a requested comparison arm, parked. + - Un-park evidence: P-15 / P-16 show a consumer that stores NaN-capable values in u8, or you ask + for the arm. +4. **Certification tool:** mpmath in `tools/` (proposed, matching `gen_ternlog_bodies.py`). +5. **±inf under Sym+NaNAtMin:** saturate to ±127 (proposed, and tested as a proposal) or NaN. + +## 9. Council record (2026-10-24) + +- **Five savants (Sonnet):** + - prior-art: 8 findings; + - iron-rule: 10; + - code-truth (runtime-archaeologist charter): 9; + - cascade-impact: 7; + - creative-explorer: 6. +- **Draft v2:** 18 changes. The main ones: + - a true division in encode; + - exact scale bits for G2; + - a concrete `compile_fail` with a twin; + - the clippy override in codec8; + - the generator in `tools/` instead of a crate; + - SIMD primitives first; + - the path filter; + - XOR scoped to Asym/Sym; + - UnitOdd/UnitMidrise test-only; + - NativeLane held back; + - the calibration source; + - the size budget; + - the blackboard rule. +- **Three reviewers (Sonnet), on draft v2:** + - overclaim-auditor: 0 BLOCK, 3 FIX(P1), 3 FIX(P2); + - dilution-collapse-sentinel: 0 BLOCK, 3 FIX(P1), 2 FIX(P2); + - firewall-warden: 0 BLOCK, 1 FIX(P1), 3 FIX(P2). +- **v3 adds:** + - claims labelled by evidence (§3); + - the per-mode table inventory (D7); + - the D5 defined result for unchecked calls; + - data vs table rounding kept separate (G2, G7); + - G7 reference independent of the generator; + - G13 spoFC bytes and G14 head-to-head; + - restore conditions for parked arms (§8); + - the clippy negative control; + - DEBUG=0 and single-compiler discipline; + - named CI rows for G8/G9. +- **Superseded:** + - the runtime mode enum and per-slice selector; + - a separate `SymNaN` space; + - runtime calibration in production and the runtime certifier; + - "U8 deferred"; + - `crates/codec8-tablegen`; + - PR #370's per-mode runtime-scale functions. + +## 10. Not searched / not run + +- TFLM per-target kernels (`cmsis_nn/`, `xtensa/`, `ceva/`) and the gemmlowp fixed-point tanh body. +- No real-model data. +- No ONNX Runtime comparison. +- SIMD backends other than AVX-512 not inventoried for the missing primitives (phase b does that). + +## 11. Phase (a) results (2026-10-24) + +Commands run by the orchestrator, env `CARGO_PROFILE_{DEV,TEST,RELEASE}_DEBUG=0 CARGO_INCREMENTAL=0`, +rustc 1.99.0: + +- `cargo test --release -p ndarray --lib hpc::codec8`: 14 passed. +- `cargo test --release -p ndarray --doc codec8`: 10 passed, including both `compile_fail,E0277` + doctests (the error code is pinned, so an unrelated error cannot satisfy them). +- `RUSTFLAGS="-D warnings" cargo clippy -p ndarray --lib --tests --example codec8_asm_probe`: clean. + Negative control for D1: before the fix, clippy rejected codec8 with `type_complexity` on the + `PhantomData` field, so the file's `#![warn(clippy::all)]` overrides the parent allow. +- `cargo fmt --check` on the changed files: clean. +- G5, `scripts/codec8_asm_diff.sh`: + + | tier | `g_lane_even` vs `d_lane_even` | `g_tflite_act` vs `d_tflite_act` | negative control `g_onnx` vs `d_tflite_act` | + |---|---|---|---| + | x86-64-v4 | IDENTICAL (278 lines) | IDENTICAL (295) | DIFFERS (285 vs 295) | + | x86-64-v3 | IDENTICAL (283) | IDENTICAL (295) | DIFFERS | + + Both are vectorised with `vdivps` on ymm registers. Labels and the symbol name are normalised. +- G6 (timing): NOT MET as written. Generic and hand-written kernels are the same machine code + (G5), yet repeated runs of identical code differ by up to 17% (v4, `tflite_act`, 4096 elements: + 2.08 vs 1.78 ns/elem); `lane_even` stays within 1.40-1.60 ns/elem at 4 Ki and 1.56-1.75 at 4 Mi in + both orders. A 2% band cannot be resolved on this host. The evidence for "no cost" is G5, not G6. +- Design change during implementation: the zero point is now added as an integer after clamping to + the shifted range. An f32 `+ 0.0` is not removable (it changes -0.0) and would have made the + generic body differ from a hand-written kernel; the two forms give the same code for integral + values. Consequence for D5: an unchecked NaN under `NoNaN` gives the zero-point code (0 for every + alias). `const` assertions reject a calibration with a non-positive or non-finite scale, or a zero + point outside the code space, at compile time. diff --git a/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md new file mode 100644 index 00000000..d5ec5560 --- /dev/null +++ b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md @@ -0,0 +1,69 @@ +> **⊘ SUPERSEDED 2026-10-24** by `.claude/plans/quantized-codec8-v2.md`. This prompt describes per-mode functions with a runtime scale returning `Result>`; the agreed design is one compile-time `Codec8` with compile-time calibration and certified `const` tables. Kept for provenance only; do not implement from it. + +# Claude Code staged implementation prompt: ndarray three 8-bit modes + +Repository: `AdaWorldAPI/ndarray`. Continue **draft PR #370** on branch `research/i8-rne-dual-range-20261024`. Do not open a duplicate PR. This PR owns only the additive contracts in `src/hpc/quantized.rs`, tests, and this stage prompt. + +## Architectural rule + +**ndarray already is the contract. Add the missing leg, do not create an abstraction framework.** Reuse existing RNE primitives. Keep existing public APIs and production wire formats unchanged. No dependencies on Helix, FamilyGamma, BGZ, Fisher-Z, DR, Palette256, or ONNX runtime. Those are consumers/references, not prerequisites. + +Three modes to examine distinctly: + +| Mode | Source of truth | Finite code range | Invalid semantics | +| --- | --- | --- | --- | +| U8 legacy | `quantize_f32_to_u8` already in ndarray | 0..255; fitted affine parameters | Legacy behavior, no new NaN claim | +| I8 asymmetric/full | `quantize_f32_to_i8_rne_full(data, scale, zero_point)` in #370 | -128..127; externally supplied scale, arbitrary signed zero point | Reject nonfinite; -128 is **numeric** | +| I8 symmetric + NaN | `quantize_f32_to_i8_rne_symmetric_nan(data, scale)` in #370 | -127..127; zero point 0 | -128 is **NaN sentinel**; reject +/-infinity | + +The NaN sentinel is a custom **integer codec marker**. IEEE-754 NaN applies only after decoding at a named observation boundary. Plain i8 arithmetic does NOT automatically propagate it. All 256 `u8` Palette indices remain valid; they are *identities*, not signed numbers. + +## Stage 0: source audit and baseline, read-only + +1. Read `src/hpc/quantized.rs` and existing `src/hpc/vml.rs` / SIMD rounding. Do not recreate ties-to-even or replace existing `quantize_f32_to_i8`. +2. Check the legacy U8 affine mapping with [0, 0.5, 1]. The midpoint can quantize to code **127 or 128** depending on f32 rounding of the fitted `scale=1/255` and division. Neither decoded code is exactly 0.5. For example, f32 division can yield 127.49999, so do not assume 128. +3. Separately evaluate the hypothetical exact-midpoint mapping `scale=0.5/128=1/256`: 128 maps exactly to 0.5, but 255 maps to 255/256, not 1.0. Do not silently change U8 semantics to resolve this tradeoff. Record the SPOFC analogy as a hypothesis, not a verified equivalence. +4. Establish current branch/CI state and check #370 for newer edits before modifying any file. + +**Gate:** short baseline inventory with source paths and exact formulas. No code in this stage. + +## Stage 1: complete only the additive signed leg inside #370 + +1. Keep the full signed-i8 RNE path with -128 valid. Keep any existing callers untouched. +2. The symmetric alternative must map input NaN to `I8_SYMMETRIC_NAN_CODE == i8::MIN`, clamp finite values to -127..127, reject infinities, and validate positive finite scale. It must not emit -128 for finite values, even on overflow. +3. Provide an explicit decoder for test/export use; never assume decoding belongs in the native hot path. +4. Run the repo's fmt, targeted unit tests and clippy gates. Check against current `master` and resolve real failures, reporting commands and results honestly. + +**Gate:** all 256 raw codes decoded (one NaN + 255 finite); all 255 finite codes re-encode identically at scale 1; ties-even including negative half-ties; both saturations; NaN and infinity; invalid scale; negative and positive zero; signed symmetry; nonzero asym zero point; explicit -128 divergence. Avoid a `Vec` claim of IEEE-exact intermediate execution. + +## Stage 2: three-mode comparison, measurement only + +1. Run all three on identical **documented data distributions**, including symmetric, negatively skewed, positively skewed, near-zero, saturated, and NaN-bearing inputs. +2. Compare signed paths with identical supplied scales and an independent scalar oracle. Also report fitted-calibration outcomes separately: absmax/127 often makes full and symmetric produce exactly the same finite codes. Preserve this negative control. +3. Compare U8 as its actual fitted affine baseline. If also testing exact-midpoint `1/256`, name it a *separate policy*, not a secret rewrite of U8. +4. Measure MAE, RMSE, signed bias, saturation, sentinel propagation, code occupancy and, where relevant, decision/rank agreement. Separate conversion cost from integer runtime cost; do not claim a speedup without benchmarks. +5. Verify that preserving -128 numerically can sometimes have **lower** reconstruction error than symmetric+NaN. Never presume symmetric is universally more accurate. + +**Gate:** reproducible fixture, concise result table with units, seed, platform and commands. No changes to legacy APIs based solely on this experiment. + +## Stage 3: optional native LUT closure, separate follow-up PR only + +Treat `Mish` as an annotated **semantic operator**, and `LUT` as an annotated **execution strategy**. Never conflate them. Demonstrate a table-driven encoded-to-encoded path where a unary operation is at most a 256-entry lookup and an actually binary operation may use a 256x256 table. Reserve -128 as invalid in signed numerical LUTs and propagate it explicitly. Palette identity indices remain u8 0..255. + +The table may be generated/calibrated offline with floats. **The hot path must stay entirely in integer identities/codes.** Instrument or audit this condition rather than merely asserting it. Fusion is allowed only if a separately declared observable error bound or exact domain proof survives composition. + +**Gate:** deterministic LUT construction plus independent oracle, exhaustive finite-domain behavior, NaN propagation, layout and memory accounting, and real measured cost. Do not implement a new ONNX runtime or a graph compiler in ndarray. + +## Stage 4: independent ONNX facade cross-check, separate PR + +Only after Stage 1 lands, use `AdaWorldAPI/lance-graph/crates/lance-graph-onnx` as a **comparison facade**, not a runtime: +- Cross-check full signed-i8 RNE against an independent scalar ONNX QuantizeLinear reference (and ONNX Runtime where accessible). The current path is only the finite scalar/i8 sub-contract, not full ONNX shape/axis coverage. +- Cross-check symmetric+NaN against its own independent oracle. It is NOT an ONNX QuantizeLinear alias. +- Keep `Mish` semantics separate from any LUT execution annotation. +- State explicitly whether observation-boundary equivalence is exact, bounded, or refused; do not infer it from an annotation. + +Do not change existing lance-graph draft PR #1758 or production wire formats unless separately authorized. + +## Reporting contract for CC + +After each stage: `commit/branch | changed files | tests actually run | measured evidence | contradictions | next gate`. Mark unrun work **NOT RUN**. No FamilyGamma/Helix/HighHeelBGZ migrations, no speculative performance claims, and no duplicate PRs. \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 2d82d288..ae75923a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ required-features = ["std"] name = "vstanh_exhaustive" required-features = ["std"] +[[example]] +name = "codec8_asm_probe" +required-features = ["std"] + [[example]] name = "cpu_guard_probe" required-features = ["std"] diff --git a/examples/codec8_asm_probe.rs b/examples/codec8_asm_probe.rs new file mode 100644 index 00000000..0161030e --- /dev/null +++ b/examples/codec8_asm_probe.rs @@ -0,0 +1,148 @@ +//! Probe for plan `.claude/plans/quantized-codec8-v2.md`, gates G5 and G6. +//! +//! Do the compile-time parameters of `hpc::codec8::Codec8` cost anything compared with a +//! hand-written kernel of the same behaviour? +//! +//! - G5 (assembly): build with `--emit asm` and compare each `g_*` symbol with its `d_*` +//! twin after normalising local labels (`scripts/codec8_asm_diff.sh` does this). +//! - G6 (timing): run the binary; it checks bit identity, then prints ns/elem for each pair +//! as the median of 15 runs. Set `SWAP=1` to time the direct kernels first. +//! +//! The binary prints the SIMD tier it was built for. Pin the tier with +//! `env -u RUSTFLAGS cargo --config .cargo/config-v3.toml run --release --example codec8_asm_probe` +//! (or `config-v4.toml`). + +use ndarray::hpc::codec8::{Calibration, Codec8, Codec8Error, NaNAtMin, OnnxQ, Sym, TfliteAct, TiesEven, I8}; +use std::hint::black_box; +use std::time::Instant; + +struct Q; +impl Calibration for Q { + const SCALE: f32 = 1.0 / 63.5; + const ZERO_POINT: i32 = 0; +} + +type Lane = Codec8; + +/// Generic: native lane, ties to even, NaN at -128. +#[no_mangle] +#[inline(never)] +pub fn g_lane_even(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { + Lane::encode_checked::(x, o) +} + +/// Hand-written twin of `g_lane_even`. +#[no_mangle] +#[inline(never)] +pub fn d_lane_even(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { + if x.len() != o.len() { + return Err(Codec8Error::LengthMismatch { + src: x.len(), + dst: o.len(), + }); + } + for (o, &v) in o.iter_mut().zip(x) { + let q = (v / Q::SCALE).round_ties_even().clamp(-127.0, 127.0) as i32; + *o = (if v.is_nan() { -128 } else { q }) as i8; + } + Ok(()) +} + +/// Generic: TFLite activation, ties away. +#[no_mangle] +#[inline(never)] +pub fn g_tflite_act(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { + TfliteAct::encode_checked::(x, o) +} + +/// Hand-written twin of `g_tflite_act`. +#[no_mangle] +#[inline(never)] +pub fn d_tflite_act(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { + if x.len() != o.len() { + return Err(Codec8Error::LengthMismatch { + src: x.len(), + dst: o.len(), + }); + } + if let Some(index) = x.iter().position(|v| v.is_nan()) { + return Err(Codec8Error::NanInput { index }); + } + for (o, &v) in o.iter_mut().zip(x) { + *o = ((v / Q::SCALE).round().clamp(-128.0, 127.0) as i32) as i8; + } + Ok(()) +} + +/// Generic: ONNX, ties to even (negative control for `d_tflite_act`: must differ). +#[no_mangle] +#[inline(never)] +pub fn g_onnx(x: &[f32], o: &mut [i8]) -> Result<(), Codec8Error> { + OnnxQ::encode_checked::(x, o) +} + +fn time(n: usize, reps: usize, mut f: F) -> f64 { + let mut v: Vec = (0..15) + .map(|_| { + let t = Instant::now(); + for _ in 0..reps { + f(); + } + t.elapsed().as_nanos() as f64 / (reps * n) as f64 + }) + .collect(); + v.sort_by(|a, b| a.total_cmp(b)); + v[7] +} + +fn main() { + println!("avx512f={} avx2={}", cfg!(target_feature = "avx512f"), cfg!(target_feature = "avx2")); + for &n in &[4096usize, 1 << 22] { + let reps = if n == 4096 { 20_000 } else { 20 }; + let mut s = 0x9E37_79B9_7F4A_7C15u64; + let mk = |nan: bool, s: &mut u64| -> Vec { + (0..n) + .map(|i| { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + if nan && i % 997 == 0 { + f32::NAN + } else { + ((*s >> 40) as f32 / (1u64 << 24) as f32) * 4.0 - 2.0 + } + }) + .collect() + }; + let with_nan = mk(true, &mut s); + let finite = mk(false, &mut s); + let (mut a, mut b) = (vec![0i8; n], vec![0i8; n]); + + g_lane_even(&with_nan, &mut a).unwrap(); + d_lane_even(&with_nan, &mut b).unwrap(); + assert_eq!(a, b); + assert!(a.contains(&-128), "anti-vacuity: NaN codes present"); + g_tflite_act(&finite, &mut a).unwrap(); + d_tflite_act(&finite, &mut b).unwrap(); + assert_eq!(a, b); + + let row = |name: &str, t: f64| println!("n={n:>8} {name:<18} {t:.4} ns/elem"); + let run = |name: &str, f: &mut dyn FnMut()| row(name, time(n, reps, f)); + let mut ga = vec![0i8; n]; + let mut gb = vec![0i8; n]; + let swap = std::env::var("SWAP").is_ok(); + for pass in 0..2 { + let generic_first = (pass == 0) != swap; + if generic_first { + run("generic lane_even", &mut || g_lane_even(black_box(&with_nan), black_box(&mut ga)).unwrap()); + run("direct lane_even", &mut || d_lane_even(black_box(&with_nan), black_box(&mut gb)).unwrap()); + } else { + run("direct lane_even", &mut || d_lane_even(black_box(&with_nan), black_box(&mut gb)).unwrap()); + run("generic lane_even", &mut || g_lane_even(black_box(&with_nan), black_box(&mut ga)).unwrap()); + } + } + run("generic tflite_act", &mut || g_tflite_act(black_box(&finite), black_box(&mut ga)).unwrap()); + run("direct tflite_act", &mut || d_tflite_act(black_box(&finite), black_box(&mut gb)).unwrap()); + run("generic onnx", &mut || g_onnx(black_box(&finite), black_box(&mut ga)).unwrap()); + } +} diff --git a/scripts/codec8_asm_diff.sh b/scripts/codec8_asm_diff.sh new file mode 100755 index 00000000..2137f314 --- /dev/null +++ b/scripts/codec8_asm_diff.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Gate G5 of .claude/plans/quantized-codec8-v2.md: compare the assembly of each generic +# codec8 instantiation in examples/codec8_asm_probe.rs with its hand-written twin. +# Usage: scripts/codec8_asm_diff.sh [cargo --config file], e.g. .cargo/config-v4.toml +set -euo pipefail +cfg=() +[ $# -ge 1 ] && cfg=(--config "$1") +env -u RUSTFLAGS cargo "${cfg[@]}" rustc --release --example codec8_asm_probe -- --emit asm -C codegen-units=1 >/dev/null +asm=$(ls -t target/release/examples/codec8_asm_probe-*.s | head -1) +cut() { awk -v f="$1" '$0 ~ "^"f":" {p=1} p {print} p && /\.cfi_endproc/ {exit}' "$asm" \ + | sed -E 's/\.(LBB|LCPI|Ltmp|Lfunc_end)[0-9_]+/.L/g; s/\<'"$1"'\>/FN/g'; } +status=0 +for pair in "g_lane_even d_lane_even" "g_tflite_act d_tflite_act" "g_onnx d_tflite_act"; do + set -- $pair + a=$(cut "$1"); b=$(cut "$2") + [ -n "$a" ] && [ -n "$b" ] || { echo "MISSING $1 or $2"; status=1; continue; } + if [ "$a" = "$b" ]; then r=IDENTICAL; else r=DIFFERS; fi + echo "$1 vs $2: $r ($(wc -l <<<"$a") / $(wc -l <<<"$b") lines)" +done +exit $status diff --git a/src/hpc/codec8.rs b/src/hpc/codec8.rs new file mode 100644 index 00000000..941a8fa0 --- /dev/null +++ b/src/hpc/codec8.rs @@ -0,0 +1,934 @@ +//! 8-bit code spaces as one codec, selected at compile time. +//! +//! [`Codec8`] is parameterised by four sealed marker types, and a calibration is a fifth +//! marker passed to each call: +//! +//! | parameter | markers | fixes | +//! |---|---|---| +//! | storage | [`I8`], [`U8`] | the element type, and how a code is stored | +//! | code space | [`Asym`], [`Sym`], [`UnitFull`] | the code range and the decode rule | +//! | rounding | [`TiesAway`], [`TiesEven`], [`Floor`] | how a scaled value becomes a code | +//! | NaN policy | [`NoNaN`], [`NaNAtMin`] | whether a code is reserved for NaN | +//! | calibration | any type implementing [`Calibration`] | scale and zero point | +//! +//! Every combination is a separate monomorphised function. Nothing is selected at run +//! time: there is no mode enum, no trait object and no function pointer, and the NaN +//! branch disappears for [`NoNaN`]. +//! +//! Combinations that make no sense do not compile. [`NaNAtMin`] exists only for the +//! narrow space [`Sym`], because in [`Asym`] the code -128 is a value: +//! +//! ```compile_fail,E0277 +//! use ndarray::hpc::codec8::{Asym, Codec8, NaNAtMin, TiesAway, I8}; +//! let _: Codec8; +//! ``` +//! +//! The same type with [`Sym`] compiles: +//! +//! ``` +//! use ndarray::hpc::codec8::{Codec8, NaNAtMin, Sym, TiesAway, I8}; +//! let _: Codec8; +//! ``` +//! +//! [`UnitFull`] codes run from 0 to 255, so they cannot be stored as `i8`: +//! +//! ```compile_fail,E0277 +//! use ndarray::hpc::codec8::{Codec8, Floor, NoNaN, UnitFull, I8}; +//! let _: Codec8; +//! ``` +//! +//! The plan and the council record are in `.claude/plans/quantized-codec8-v2.md`. + +// `pub mod hpc` allows `clippy::all` for the whole module tree; this file opts back in. +#![warn(clippy::all)] + +use core::fmt; +use core::marker::PhantomData; + +mod sealed { + pub trait Sealed {} +} + +// --------------------------------------------------------------------------- +// Code spaces +// --------------------------------------------------------------------------- + +/// The range of codes a [`Codec8`] uses. +pub trait CodeSpace: sealed::Sealed { + /// Smallest code the encoder emits for a value. + const MIN: i32; + /// Largest code the encoder emits for a value. + const MAX: i32; +} + +/// A code space whose codes decode linearly through a [`Calibration`]: +/// `value = (code - ZERO_POINT) * SCALE`. +pub trait Linear: CodeSpace {} + +/// Full signed range, -128..=127. Every code is a value, including -128. +/// +/// This is the range of today's `quantize_f32_to_i8`, of TFLite activations and of ONNX +/// `QuantizeLinear` with an `int8` output. +#[derive(Debug, Clone, Copy)] +pub struct Asym; + +/// Narrow signed range, -127..=127. The encoder never emits -128. +/// +/// This is the range TFLite uses for weights. With [`NaNAtMin`] the unused code -128 +/// means NaN; with [`NoNaN`] it is never produced and is rejected by +/// [`Codec8::decode_checked`]. +#[derive(Debug, Clone, Copy)] +pub struct Sym; + +/// Unit interval on 0..=255: code `c` stands for `c / 255`, so 0 and 255 are exactly 0.0 +/// and 1.0. +/// +/// This is the spoFC truth encoding (frequency and confidence). It has no calibration and +/// no signed counterpart, so it is stored only as `u8`. +#[derive(Debug, Clone, Copy)] +pub struct UnitFull; + +impl sealed::Sealed for Asym {} +impl sealed::Sealed for Sym {} +impl sealed::Sealed for UnitFull {} + +impl CodeSpace for Asym { + const MIN: i32 = -128; + const MAX: i32 = 127; +} +impl CodeSpace for Sym { + const MIN: i32 = -127; + const MAX: i32 = 127; +} +impl CodeSpace for UnitFull { + const MIN: i32 = 0; + const MAX: i32 = 255; +} +impl Linear for Asym {} +impl Linear for Sym {} + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +/// How a code is stored. +pub trait Storage: sealed::Sealed { + /// The stored element type. + type Elem: Copy + fmt::Debug + PartialEq; +} + +/// Codes stored as `i8`; the stored byte is the code itself. +#[derive(Debug, Clone, Copy)] +pub struct I8; + +/// Codes stored as `u8`. +/// +/// For [`Asym`] and [`Sym`] the stored byte is the signed code XOR 0x80, the same +/// relation TFLite Micro's int8↔uint8 requantize uses. For [`UnitFull`] it is the code +/// itself. +#[derive(Debug, Clone, Copy)] +pub struct U8; + +impl sealed::Sealed for I8 {} +impl sealed::Sealed for U8 {} +impl Storage for I8 { + type Elem = i8; +} +impl Storage for U8 { + type Elem = u8; +} + +/// A storage that can hold the codes of code space `S`. +pub trait Stores: Storage { + /// Store a code that is already within `S::MIN..=S::MAX`, or the NaN code. + fn store(code: i32) -> Self::Elem; + /// Read the code back. + fn load(elem: Self::Elem) -> i32; +} + +impl Stores for I8 { + #[inline(always)] + fn store(code: i32) -> i8 { + code as i8 + } + #[inline(always)] + fn load(elem: i8) -> i32 { + i32::from(elem) + } +} +impl Stores for I8 { + #[inline(always)] + fn store(code: i32) -> i8 { + code as i8 + } + #[inline(always)] + fn load(elem: i8) -> i32 { + i32::from(elem) + } +} +impl Stores for U8 { + #[inline(always)] + fn store(code: i32) -> u8 { + (code + 128) as u8 + } + #[inline(always)] + fn load(elem: u8) -> i32 { + i32::from(elem) - 128 + } +} +impl Stores for U8 { + #[inline(always)] + fn store(code: i32) -> u8 { + (code + 128) as u8 + } + #[inline(always)] + fn load(elem: u8) -> i32 { + i32::from(elem) - 128 + } +} +impl Stores for U8 { + #[inline(always)] + fn store(code: i32) -> u8 { + code as u8 + } + #[inline(always)] + fn load(elem: u8) -> i32 { + i32::from(elem) + } +} + +// --------------------------------------------------------------------------- +// Rounding +// --------------------------------------------------------------------------- + +/// How a scaled value or an integer ratio becomes a code. +pub trait Rounding: sealed::Sealed { + /// Round a finite or non-finite `f32` to an integral `f32`. NaN and ±inf pass through. + fn round(x: f32) -> f32; + /// Round `n / d` to an integer, for `d > 0`. + fn div_round(n: u128, d: u128) -> u128; +} + +/// Round half away from zero, as `f32::round` and TFLite (`std::round`). +#[derive(Debug, Clone, Copy)] +pub struct TiesAway; + +/// Round half to even, as ONNX `QuantizeLinear`. +#[derive(Debug, Clone, Copy)] +pub struct TiesEven; + +/// Round toward negative infinity, as today's spoFC integer encoders. +#[derive(Debug, Clone, Copy)] +pub struct Floor; + +impl sealed::Sealed for TiesAway {} +impl sealed::Sealed for TiesEven {} +impl sealed::Sealed for Floor {} + +impl Rounding for TiesAway { + #[inline(always)] + fn round(x: f32) -> f32 { + x.round() + } + #[inline(always)] + fn div_round(n: u128, d: u128) -> u128 { + // Quotient and remainder, with the half test as `r >= d - r` so that no + // intermediate exceeds `n` or `d` (`2 * n` and `2 * d` overflow for large operands). + let (q, r) = (n / d, n % d); + if r >= d - r { + q + 1 + } else { + q + } + } +} +impl Rounding for TiesEven { + #[inline(always)] + fn round(x: f32) -> f32 { + x.round_ties_even() + } + #[inline(always)] + fn div_round(n: u128, d: u128) -> u128 { + let (q, r) = (n / d, n % d); + // `r` against `d - r` is `2 * r` against `d` without the overflow. + match r.cmp(&(d - r)) { + core::cmp::Ordering::Less => q, + core::cmp::Ordering::Greater => q + 1, + core::cmp::Ordering::Equal => q + (q & 1), + } + } +} +impl Rounding for Floor { + #[inline(always)] + fn round(x: f32) -> f32 { + x.floor() + } + #[inline(always)] + fn div_round(n: u128, d: u128) -> u128 { + n / d + } +} + +// --------------------------------------------------------------------------- +// NaN policy +// --------------------------------------------------------------------------- + +/// Whether code space `S` reserves a code for NaN. +pub trait NanPolicy: sealed::Sealed { + /// The code that means NaN, if any. + const NAN_CODE: Option; +} + +/// No NaN code. A NaN input is rejected by the checked encoder. +#[derive(Debug, Clone, Copy)] +pub struct NoNaN; + +/// The code -128 means NaN. Only valid with [`Sym`], whose values never use -128. +#[derive(Debug, Clone, Copy)] +pub struct NaNAtMin; + +impl sealed::Sealed for NoNaN {} +impl sealed::Sealed for NaNAtMin {} + +impl NanPolicy for NoNaN { + const NAN_CODE: Option = None; +} +impl NanPolicy for NaNAtMin { + const NAN_CODE: Option = Some(-128); +} + +// --------------------------------------------------------------------------- +// Calibration +// --------------------------------------------------------------------------- + +/// A compile-time calibration: `value = (code - ZERO_POINT) * SCALE`. +/// +/// Production calibrations are marker types, usually generated from a calibration +/// manifest. Calibrations chosen at run time exist only behind the `lab-calibration` +/// feature. +/// +/// ``` +/// use ndarray::hpc::codec8::Calibration; +/// +/// struct Unit127; +/// impl Calibration for Unit127 { +/// const SCALE: f32 = 1.0 / 127.0; +/// const ZERO_POINT: i32 = 0; +/// } +/// assert_eq!(Unit127::ZERO_POINT, 0); +/// ``` +/// +/// The check also covers a consumer that only decodes: +/// +/// ```compile_fail,E0080 +/// use ndarray::hpc::codec8::{Calibration, OnnxQ}; +/// +/// struct Negative; +/// impl Calibration for Negative { +/// const SCALE: f32 = -1.0; +/// const ZERO_POINT: i32 = 0; +/// } +/// let mut out = [0.0f32]; +/// OnnxQ::decode_checked::(&[1], &mut out).unwrap(); +/// ``` +pub trait Calibration { + /// Width of one code step. Must be finite and positive; a calibration that breaks + /// this, or puts `ZERO_POINT` outside the code space, fails to compile when used. + const SCALE: f32; + /// The code that stands for 0.0. + const ZERO_POINT: i32; +} + +// --------------------------------------------------------------------------- +// The codec +// --------------------------------------------------------------------------- + +/// Why a checked encode or decode refused its input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Codec8Error { + /// The source and destination slices have different lengths. + LengthMismatch { + /// Length of the source slice. + src: usize, + /// Length of the destination slice. + dst: usize, + }, + /// A NaN input under [`NoNaN`]. + NanInput { + /// Position of the first NaN. + index: usize, + }, + /// A stored code outside the code space that is not the NaN code. + InvalidCode { + /// Position of the first invalid code. + index: usize, + }, +} + +impl fmt::Display for Codec8Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Codec8Error::LengthMismatch { src, dst } => { + write!(f, "source has {src} elements, destination {dst}") + } + Codec8Error::NanInput { index } => write!(f, "NaN input at index {index}"), + Codec8Error::InvalidCode { index } => write!(f, "invalid code at index {index}"), + } + } +} + +impl std::error::Error for Codec8Error {} + +/// Carries the marker types without owning a value of any of them. +type Markers = fn() -> (T, S, R, N); + +/// An 8-bit codec fixed at compile time by storage `T`, code space `S`, rounding `R` and +/// NaN policy `N`. +/// +/// It is a zero-sized type; all operations are associated functions. +/// +/// ``` +/// use ndarray::hpc::codec8::{Calibration, OnnxQ}; +/// +/// struct Half; +/// impl Calibration for Half { +/// const SCALE: f32 = 0.5; +/// const ZERO_POINT: i32 = 0; +/// } +/// let mut codes = [0i8; 4]; +/// OnnxQ::encode_checked::(&[0.25, 0.75, -1.0, 100.0], &mut codes).unwrap(); +/// // 0.5 and 1.5 round to the even neighbour; 200 saturates. +/// assert_eq!(codes, [0, 2, -2, 127]); +/// ``` +pub struct Codec8(PhantomData>) +where + T: Stores, + S: CodeSpace, + R: Rounding, + N: NanPolicy; + +impl Codec8 +where + T: Stores, + S: Linear, + R: Rounding, + N: NanPolicy, +{ + /// Encode one value. Total: NaN gives the NaN code, or the zero-point code under + /// [`NoNaN`]; ±inf and out-of-range values saturate to `S::MIN` / `S::MAX`. + #[inline(always)] + pub(crate) fn encode_one(v: f32) -> T::Elem { + // A true division, not a multiplication by a precomputed reciprocal: the two + // differ in f32 exactly at half-ties, where the rounding mode decides the code. + Self::assert_calibration::(); + let r = R::round(v / C::SCALE); + // Clamp to the range shifted by the zero point, then add it as an integer. For + // integral `r` this equals `clamp(r + zp)`, and with a zero point of 0 the add + // vanishes; an f32 `+ 0.0` would not, because it changes -0.0. + // `as i32` maps NaN to 0, as today's quantizer does. + let lo = (S::MIN - C::ZERO_POINT) as f32; + let hi = (S::MAX - C::ZERO_POINT) as f32; + let q = r.clamp(lo, hi) as i32 + C::ZERO_POINT; + let q = match N::NAN_CODE { + Some(nan) if v.is_nan() => nan, + _ => q, + }; + T::store(q) + } + + /// Reject an invalid calibration at compile time. Called by both directions, so a + /// consumer that only decodes gets the same guarantee as one that encodes. + #[inline(always)] + fn assert_calibration() { + const { + assert!(C::SCALE > 0.0 && C::SCALE < f32::INFINITY, "SCALE must be finite and positive"); + assert!(C::ZERO_POINT >= S::MIN && C::ZERO_POINT <= S::MAX, "ZERO_POINT must lie in the code space"); + } + } + + /// Decode one stored code. Total: the NaN code gives NaN; every other byte decodes + /// linearly, including codes outside the code space. + #[inline(always)] + pub(crate) fn decode_one(e: T::Elem) -> f32 { + Self::assert_calibration::(); + let c = T::load(e); + match N::NAN_CODE { + Some(nan) if c == nan => f32::NAN, + _ => (c - C::ZERO_POINT) as f32 * C::SCALE, + } + } + + /// Encode without validation. Defined for every input (see [`Self::encode_one`]). + #[inline(always)] + pub(crate) fn encode_unchecked(src: &[f32], dst: &mut [T::Elem]) { + for (o, &v) in dst.iter_mut().zip(src) { + *o = Self::encode_one::(v); + } + } + + /// Decode without validation. Defined for every input (see [`Self::decode_one`]). + #[inline(always)] + pub(crate) fn decode_unchecked(src: &[T::Elem], dst: &mut [f32]) { + for (o, &e) in dst.iter_mut().zip(src) { + *o = Self::decode_one::(e); + } + } + + /// Encode `src` into `dst` with calibration `C`. + /// + /// The input is validated once, then encoded in one pass. Values outside the range + /// saturate; ±inf saturates. Under [`NoNaN`] a NaN input is an error and nothing is + /// written; under [`NaNAtMin`] it becomes the code -128. + /// + /// ``` + /// use ndarray::hpc::codec8::{Calibration, Codec8, NaNAtMin, Sym, TiesEven, I8}; + /// + /// struct One; + /// impl Calibration for One { + /// const SCALE: f32 = 1.0; + /// const ZERO_POINT: i32 = 0; + /// } + /// type Lane = Codec8; + /// let mut codes = [0i8; 3]; + /// Lane::encode_checked::(&[f32::NAN, -500.0, 2.5], &mut codes).unwrap(); + /// assert_eq!(codes, [-128, -127, 2]); + /// ``` + pub fn encode_checked(src: &[f32], dst: &mut [T::Elem]) -> Result<(), Codec8Error> { + if src.len() != dst.len() { + return Err(Codec8Error::LengthMismatch { + src: src.len(), + dst: dst.len(), + }); + } + if N::NAN_CODE.is_none() { + if let Some(index) = src.iter().position(|v| v.is_nan()) { + return Err(Codec8Error::NanInput { index }); + } + } + Self::encode_unchecked::(src, dst); + Ok(()) + } + + /// Decode `src` into `dst` with calibration `C`. + /// + /// A stored code outside the code space that is not the NaN code (for [`Sym`] with + /// [`NoNaN`], the code -128) is an error and nothing is written. + /// + /// ``` + /// use ndarray::hpc::codec8::{Calibration, TfliteWeight, Codec8Error}; + /// + /// struct One; + /// impl Calibration for One { + /// const SCALE: f32 = 1.0; + /// const ZERO_POINT: i32 = 0; + /// } + /// let mut out = [0.0f32; 2]; + /// TfliteWeight::decode_checked::(&[-127, 5], &mut out).unwrap(); + /// assert_eq!(out, [-127.0, 5.0]); + /// assert_eq!( + /// TfliteWeight::decode_checked::(&[3, -128], &mut out), + /// Err(Codec8Error::InvalidCode { index: 1 }) + /// ); + /// ``` + pub fn decode_checked(src: &[T::Elem], dst: &mut [f32]) -> Result<(), Codec8Error> { + if src.len() != dst.len() { + return Err(Codec8Error::LengthMismatch { + src: src.len(), + dst: dst.len(), + }); + } + let invalid = |&e: &T::Elem| { + let c = T::load(e); + (c < S::MIN || c > S::MAX) && N::NAN_CODE != Some(c) + }; + if let Some(index) = src.iter().position(invalid) { + return Err(Codec8Error::InvalidCode { index }); + } + Self::decode_unchecked::(src, dst); + Ok(()) + } +} + +impl Codec8 { + /// Encode values in [0, 1] as `round(v * 255)`, saturating outside the interval. + /// A NaN input is an error and nothing is written. + /// + /// ``` + /// use ndarray::hpc::codec8::SpoFcTruth; + /// + /// let mut codes = [0u8; 3]; + /// SpoFcTruth::encode_checked(&[0.0, 1.0, 0.999], &mut codes).unwrap(); + /// assert_eq!(codes, [0, 255, 254]); + /// ``` + pub fn encode_checked(src: &[f32], dst: &mut [u8]) -> Result<(), Codec8Error> { + if src.len() != dst.len() { + return Err(Codec8Error::LengthMismatch { + src: src.len(), + dst: dst.len(), + }); + } + if let Some(index) = src.iter().position(|v| v.is_nan()) { + return Err(Codec8Error::NanInput { index }); + } + for (o, &v) in dst.iter_mut().zip(src) { + *o = R::round(v * 255.0).clamp(0.0, 255.0) as u8; + } + Ok(()) + } + + /// Decode codes as `c / 255`. Every byte is a valid code; 255 decodes to exactly 1.0. + /// + /// ``` + /// use ndarray::hpc::codec8::SpoFcTruth; + /// + /// let mut out = [0.0f32; 2]; + /// SpoFcTruth::decode(&[0, 255], &mut out); + /// assert_eq!(out, [0.0, 1.0]); + /// ``` + /// + /// # Panics + /// Panics if the slices have different lengths. + pub fn decode(src: &[u8], dst: &mut [f32]) { + assert_eq!(src.len(), dst.len(), "decode: length mismatch"); + for (o, &c) in dst.iter_mut().zip(src) { + // A division, so that 255 / 255 is exactly 1.0. + *o = f32::from(c) / 255.0; + } + } + + /// Encode the integer ratio `n / d` as a unit code, `round(n * 255 / d)` capped at + /// 255, without going through floating point. Returns `None` when `d` is 0. + /// + /// With [`Floor`] this is the spoFC encoder: frequency + /// `encode_ratio(cooccur, antecedent)` and confidence `encode_ratio(m, m + k)`. + /// + /// ``` + /// use ndarray::hpc::codec8::SpoFcTruth; + /// + /// assert_eq!(SpoFcTruth::encode_ratio(1, 2), Some(127)); + /// assert_eq!(SpoFcTruth::encode_ratio(5, 4), Some(255)); + /// assert_eq!(SpoFcTruth::encode_ratio(1, 0), None); + /// ``` + pub fn encode_ratio(n: u64, d: u64) -> Option { + if d == 0 { + return None; + } + let q = R::div_round(u128::from(n) * 255, u128::from(d)); + Some(q.min(255) as u8) + } +} + +// --------------------------------------------------------------------------- +// Aliases for external formats +// --------------------------------------------------------------------------- + +/// TFLite / TFLite Micro int8 activations: full range, ties away from zero. +pub type TfliteAct = Codec8; + +/// TFLite int8 weights: narrow range (-127..=127), ties away from zero. +pub type TfliteWeight = Codec8; + +/// ONNX `QuantizeLinear` / `DequantizeLinear` with `int8`: full range, ties to even. +pub type OnnxQ = Codec8; + +/// spoFC truth bytes (NARS frequency and confidence): unit range, floor. +pub type SpoFcTruth = Codec8; + +#[cfg(test)] +mod tests { + use super::*; + use crate::hpc::quantized::quantize_f32_to_i8; + + struct One; + impl Calibration for One { + const SCALE: f32 = 1.0; + const ZERO_POINT: i32 = 0; + } + struct Shift3; + impl Calibration for Shift3 { + const SCALE: f32 = 1.0; + const ZERO_POINT: i32 = -3; + } + + type AsymEven = Codec8; + type SymAway = Codec8; + type LaneEven = Codec8; + type LaneAway = Codec8; + + fn enc(src: &[f32]) -> Vec + where + T: Stores, + S: Linear, + R: Rounding, + N: NanPolicy, + C: Calibration, + { + src.iter() + .map(|&v| Codec8::::encode_one::(v)) + .collect() + } + + /// A deterministic corpus with ties, saturating values, signed zeros, NaN and ±inf. + fn corpus() -> Vec { + let mut v = vec![0.0, -0.0, 0.5, -0.5, 1.5, -1.5, 2.5, -2.5, 126.5, -127.5, -128.5]; + v.extend([f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e9, -1e9]); + let mut s = 0x9E37_79B9_7F4A_7C15u64; + for _ in 0..4000 { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + v.push(((s >> 40) as f32 / (1u64 << 24) as f32) * 6.0 - 3.0); + } + v + } + + // G2: TfliteAct with the exact fitted scale reproduces `quantize_f32_to_i8`. + #[test] + fn div_round_does_not_overflow_on_large_operands() { + let big = 1u128 << 127; + assert_eq!(TiesAway::div_round(big, big), 1); + assert_eq!(TiesEven::div_round(big, big), 1); + assert_eq!(Floor::div_round(big, big), 1); + // A remainder above half of a huge divisor: `2 * r` would wrap. + let d = u128::MAX; + assert_eq!(TiesAway::div_round(d - 1, d), 1); + assert_eq!(TiesEven::div_round(d - 1, d), 1); + assert_eq!(TiesAway::div_round(1, d), 0); + assert_eq!(TiesEven::div_round(1, d), 0); + } + + #[test] + fn div_round_ties_on_small_operands() { + // Exact halves: away rounds up, even rounds to the even neighbour. + for (n, d, away, even) in [(1u128, 2u128, 1, 0), (3, 2, 2, 2), (5, 2, 3, 2), (7, 2, 4, 4), (5, 10, 1, 0)] { + assert_eq!(TiesAway::div_round(n, d), away, "away {n}/{d}"); + assert_eq!(TiesEven::div_round(n, d), even, "even {n}/{d}"); + } + for (n, d) in [(1u128, 3u128), (2, 3), (4, 3), (254, 255), (256, 255)] { + let exact = n as f64 / d as f64; + assert_eq!(TiesAway::div_round(n, d), exact.round() as u128, "{n}/{d}"); + } + } + + #[test] + fn tflite_act_matches_quantize_f32_to_i8_bit_for_bit() { + // Finite data with an abs max of 3.0 (the corpus generator stays inside ±3). + let mut data: Vec = corpus() + .into_iter() + .filter(|v| v.is_finite() && v.abs() <= 3.0) + .collect(); + data.push(3.0); + let (codes, params) = quantize_f32_to_i8(&data); + struct Fitted; + impl Calibration for Fitted { + const SCALE: f32 = f32::from_bits(0x3cc1_8306); // 3.0 / 127.0 + const ZERO_POINT: i32 = 0; + } + assert_ne!(params.scale, 1.0, "the corpus must not hit the 1.0 fallback"); + assert_eq!(params.scale.to_bits(), Fitted::SCALE.to_bits(), "fitted scale bits"); + assert_eq!(enc::(&data), codes); + // NaN and ±inf: today's quantizer gives 0 and ±saturation; so does the + // unchecked path. Compare element-wise through the same scale. + let special = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.0]; + let today: Vec = special + .iter() + .map(|&v| (v / params.scale).round().clamp(-128.0, 127.0) as i8) + .collect(); + assert_eq!(enc::(&special), today); + assert_eq!(today, [0, 127, -128, 0]); + } + + // The division matters: a precomputed reciprocal changes codes at half-ties. + #[test] + fn division_and_reciprocal_disagree_at_a_tie() { + struct S; + impl Calibration for S { + const SCALE: f32 = 0.0137; + const ZERO_POINT: i32 = 0; + } + let v = -1.73305f32; + assert_eq!(v / S::SCALE, -126.5); + assert_ne!(v * (1.0 / S::SCALE), -126.5); + assert_eq!(enc::(&[v]), [-127]); + } + + // P-02: ties, both directions, both rounding modes; a non-tie twin. + #[test] + fn ties_round_away_or_to_even() { + let xs = [-2.5, -1.5, -0.5, 0.5, 1.5, 2.5, 126.5, -126.5]; + assert_eq!(enc::(&xs), [-3, -2, -1, 1, 2, 3, 127, -127]); + assert_eq!(enc::(&xs), [-2, -2, 0, 0, 2, 2, 126, -126]); + let near = [-2.4999998, 2.5000002]; + assert_eq!(enc::(&near), [-2, 3]); + assert_eq!(enc::(&near), [-2, 3]); + } + + #[test] + fn saturation_and_signed_zero() { + let xs = [-1e9, -128.5, -127.6, 127.6, 1e9, -0.0, 0.0]; + assert_eq!(enc::(&xs), [-128, -128, -128, 127, 127, 0, 0]); + assert_eq!(enc::(&xs), [-127, -127, -127, 127, 127, 0, 0]); + } + + #[test] + fn nonzero_zero_point_shifts_codes() { + let xs = [-200.0, -2.5, 0.0, 2.5, 200.0]; + assert_eq!(enc::(&xs), [-128, -5, -3, -1, 127]); + let mut out = [0.0f32; 2]; + AsymEven::decode_checked::(&[-3, -5], &mut out).unwrap(); + assert_eq!(out, [0.0, -2.0]); + } + + // P-04: Sym + NaNAtMin contract (±inf saturates: proposal, plan §8 item 5). + #[test] + fn sym_nan_at_min_contract() { + let xs = [f32::NAN, 1e9, -1e9, f32::INFINITY, f32::NEG_INFINITY, -0.0, 0.0]; + for codes in [enc::(&xs), enc::(&xs)] { + assert_eq!(codes, [-128, 127, -127, 127, -127, 0, 0]); + } + let mut out = [0.0f32; 3]; + LaneEven::decode_checked::(&[-128, -127, 127], &mut out).unwrap(); + assert!(out[0].is_nan()); + assert_eq!(&out[1..], &[-127.0, 127.0]); + } + + // Sym + NoNaN and Sym + NaNAtMin agree everywhere except NaN input and code -128. + #[test] + fn nan_policy_changes_only_nan_and_minus_128() { + let finite: Vec = corpus().into_iter().filter(|v| !v.is_nan()).collect(); + assert_eq!(enc::(&finite), enc::(&finite)); + assert!(enc::(&finite) + .iter() + .all(|&c| c != -128)); + for c in -127i8..=127 { + assert_eq!(SymAway::decode_one::(c).to_bits(), LaneAway::decode_one::(c).to_bits()); + } + assert_eq!(SymAway::decode_one::(-128), -128.0); + assert!(LaneAway::decode_one::(-128).is_nan()); + } + + // P-12: encode(decode(c)) == c for every code of every linear alias. + #[test] + fn encode_decode_is_identity_on_every_code() { + fn check, S: Linear, R: Rounding, N: NanPolicy>(codes: &[T::Elem]) { + for &e in codes { + let back = Codec8::::encode_one::(Codec8::::decode_one::(e)); + assert_eq!(back, e); + } + } + let i8_full: Vec = (-128..=127).collect(); + let i8_narrow: Vec = (-127..=127).collect(); + let u8_full: Vec = (0..=255).collect(); + let u8_narrow: Vec = (1..=255).collect(); + check::(&i8_full); + check::(&i8_full); + check::(&i8_narrow); + check::(&i8_full); + check::(&u8_full); + check::(&u8_narrow); + } + + // G4: u8 storage is the i8 code XOR 0x80, on all 256 codes, for every Asym/Sym alias. + #[test] + fn u8_storage_is_i8_xor_0x80() { + let xs: Vec = (-130..=130).map(|i| i as f32).chain([f32::NAN]).collect(); + fn pair>(xs: &[f32]) + where + I8: Stores, + U8: Stores, + { + let a = enc::(xs); + let b = enc::(xs); + for (&x, &y) in a.iter().zip(&b) { + assert_eq!((x as u8) ^ 0x80, y); + } + for byte in 0u8..=255 { + let signed = (byte ^ 0x80) as i8; + assert_eq!( + Codec8::::decode_one::(signed).to_bits(), + Codec8::::decode_one::(byte).to_bits() + ); + } + } + pair::(&xs); + pair::(&xs); + pair::(&xs); + pair::(&xs); + } + + #[test] + fn checked_paths_reject_and_write_nothing() { + let mut codes = [7i8; 3]; + assert_eq!( + OnnxQ::encode_checked::(&[1.0, f32::NAN, 2.0], &mut codes), + Err(Codec8Error::NanInput { index: 1 }) + ); + assert_eq!(codes, [7, 7, 7]); + assert_eq!( + OnnxQ::encode_checked::(&[1.0], &mut codes), + Err(Codec8Error::LengthMismatch { src: 1, dst: 3 }) + ); + LaneEven::encode_checked::(&[1.0, f32::NAN, 2.0], &mut codes).unwrap(); + assert_eq!(codes, [1, -128, 2]); + let mut out = [9.0f32; 2]; + assert_eq!( + Codec8::::decode_checked::(&[128, 0], &mut out), + Err(Codec8Error::InvalidCode { index: 1 }) + ); + assert_eq!(out, [9.0, 9.0]); + } + + // D5: the unchecked path is total; a missed validation gives a pinned value. + #[test] + fn unchecked_path_is_defined_on_bad_input() { + let mut codes = [0i8; 2]; + OnnxQ::encode_unchecked::(&[f32::NAN, f32::INFINITY], &mut codes); + assert_eq!(codes, [0, 127]); + let mut out = [0.0f32; 1]; + SymAway::decode_unchecked::(&[-128], &mut out); + assert_eq!(out, [-128.0]); + } + + #[test] + fn unit_full_round_trip_and_endpoints() { + let codes: Vec = (0..=255).collect(); + let mut vals = vec![0.0f32; 256]; + SpoFcTruth::decode(&codes, &mut vals); + assert_eq!((vals[0], vals[255]), (0.0, 1.0)); + let mut back = vec![0u8; 256]; + Codec8::::encode_checked(&vals, &mut back).unwrap(); + assert_eq!(back, codes); + assert_eq!(SpoFcTruth::encode_checked(&[f32::NAN], &mut [0u8]), Err(Codec8Error::NanInput { index: 0 })); + } + + // G13: encode_ratio with Floor equals the spoFC encoder in lance-graph + // `crates/lance-graph-arm-discovery/src/translator.rs` (`arm_to_truth_u8`), transcribed: + // frequency = ((cooccur * 255) / antecedent).min(255) (antecedent > 0) + // confidence = (m * 255) / (m + k) (k > 0) + #[test] + fn floor_ratio_matches_spofc_encoder() { + for k in [1u64, 2, 5] { + for m in 0u64..=4096 { + assert_eq!(SpoFcTruth::encode_ratio(m, m + k), Some(((m * 255) / (m + k)) as u8)); + } + } + for a in 1u64..=300 { + for c in 0u64..=a + 5 { + assert_eq!(SpoFcTruth::encode_ratio(c, a), Some(((c * 255) / a).min(255) as u8)); + } + } + } + + // Rounded confidence reaches 255 (manufactured certainty); floor never does. + #[test] + fn rounded_confidence_can_reach_255_floor_cannot() { + type Even = Codec8; + type Away = Codec8; + assert_eq!(Even::encode_ratio(510, 511), Some(255)); + assert_eq!(Away::encode_ratio(509, 510), Some(255)); // exact tie 254.5 + assert_eq!(Even::encode_ratio(509, 510), Some(254)); // tie to even + for m in 0u64..=100_000 { + assert!(SpoFcTruth::encode_ratio(m, m + 1).unwrap() < 255); + } + } +} diff --git a/src/hpc/mod.rs b/src/hpc/mod.rs index 54c663b5..1fe2ee9c 100644 --- a/src/hpc/mod.rs +++ b/src/hpc/mod.rs @@ -46,6 +46,7 @@ pub mod projection; pub mod cogrecord; pub mod graph; pub mod quantized; +pub mod codec8; pub mod lapack; pub mod fft; pub mod vml;