From bd64f9cb9d61fe22e76e3ddbb2a0c1ce83a1fb97 Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Sat, 24 Oct 2026 11:50:14 +0200 Subject: [PATCH 1/9] research(quantized): add independent full-range and symmetric RNE i8 paths with endpoint gates --- src/hpc/quantized.rs | 152 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/hpc/quantized.rs b/src/hpc/quantized.rs index 2a98e214..3ddc13af 100644 --- a/src/hpc/quantized.rs +++ b/src/hpc/quantized.rs @@ -564,6 +564,71 @@ pub fn quantize_f32_to_i8(data: &[f32]) -> (Vec, QuantParams) { ) } +/// Rejection conditions for explicitly scaled, round-to-nearest-even i8 quantizers. +/// +/// Neither numerical path invents an i8 representation for NaN or infinity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum I8RneQuantizeError { + /// Scale must be finite and strictly positive. + InvalidScale, + /// A float input was non-finite. + NonFiniteInput { index: usize }, +} + +/// Quantize finite f32 values using the full signed-i8 range -128..=127. +/// +/// For each input: clamp(round_ties_even(x / scale) + zero_point, -128, 127). +/// This matches the scalar ONNX QuantizeLinear numerical rule for finite f32 +/// inputs with an i8 zero point. It does not cover other dtypes or broadcasting. +/// -128 remains a valid numerical result; it is never a NaN marker. +/// +/// The existing quantize_f32_to_i8 entry point is deliberately unchanged. +pub fn quantize_f32_to_i8_rne_full( + data: &[f32], + scale: f32, + zero_point: i8, +) -> Result, I8RneQuantizeError> { + if !scale.is_finite() || scale <= 0.0 { + return Err(I8RneQuantizeError::InvalidScale); + } + data.iter() + .enumerate() + .map(|(index, &value)| { + if !value.is_finite() { + return Err(I8RneQuantizeError::NonFiniteInput { index }); + } + let rounded = (value / scale).round_ties_even(); + Ok((rounded + f32::from(zero_point)).clamp(-128.0, 127.0) as i8) + }) + .collect() +} + +/// Alternative round-to-even quantization with exact code symmetry -127..=127. +/// +/// The zero point is fixed at integer 0. Code -128 is not emitted and is not +/// reinterpreted as a NaN or a negative zero. The f32 sign bit of -0.0 cannot +/// survive either i8 representation. This is NOT an ONNX QuantizeLinear alias. +/// +/// Supply the SAME external scale to this path and the full-range path when +/// measuring only the effect of retaining or excluding -128. +pub fn quantize_f32_to_i8_rne_symmetric( + data: &[f32], + scale: f32, +) -> Result, I8RneQuantizeError> { + if !scale.is_finite() || scale <= 0.0 { + return Err(I8RneQuantizeError::InvalidScale); + } + data.iter() + .enumerate() + .map(|(index, &value)| { + if !value.is_finite() { + return Err(I8RneQuantizeError::NonFiniteInput { index }); + } + Ok((value / scale).round_ties_even().clamp(-127.0, 127.0) as i8) + }) + .collect() +} + /// Dequantize i8 codes back to f32 using the [`QuantParams`] from /// [`quantize_f32_to_i8`]. /// @@ -922,6 +987,93 @@ mod tests { assert_eq!(q[1], -127); } + #[test] + fn test_i8_rne_full_code_space_and_symmetric_domain() { + let xs: Vec = (-128i16..=127).map(f32::from).collect(); + let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + assert_eq!(full.len(), 256); + assert_eq!(sym.len(), 256); + for (i, &x) in xs.iter().enumerate() { + assert_eq!(full[i], x as i8, "full code {}", x); + assert_eq!(sym[i], (x as i32).clamp(-127, 127) as i8, "sym code {}", x); + } + assert!(sym.iter().all(|&code| code != i8::MIN)); + assert_eq!(full[0], -128); + assert_eq!(sym[0], -127); + } + + #[test] + fn test_i8_rne_ties_endpoint_and_zero_point() { + let xs = [ + -1_000.0, -128.5, -127.5, -126.5, -2.5, -1.5, -0.5, + -0.0, 0.0, 0.5, 1.5, 2.5, 126.5, 127.5, 1_000.0, + ]; + let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + assert_eq!(full, vec![-128, -128, -128, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); + assert_eq!(sym, vec![-127, -127, -127, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); + assert_eq!( + quantize_f32_to_i8_rne_full(&[-200.0, -2.5, 0.0, 2.5, 200.0], 1.0, -3).unwrap(), + vec![-128, -5, -3, -1, 127] + ); + } + + #[test] + fn test_i8_rne_symmetric_sign_and_calibration_identity() { + let xs: Vec = (-127i16..=127).map(f32::from).collect(); + let a = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + let negative: Vec = xs.iter().map(|x| -x).collect(); + let b = quantize_f32_to_i8_rne_symmetric(&negative, 1.0).unwrap(); + for (&plus, &minus) in a.iter().zip(b.iter()) { + assert_eq!(plus, -minus); + } + // When max-abs/127 keeps every result in range, the two paths + // should be identical. This is an essential negative control. + let bounded = [-12.0, -1.0, -0.1, -0.0, 0.0, 0.1, 1.0, 12.0]; + let scale = 12.0 / 127.0; + assert_eq!( + quantize_f32_to_i8_rne_full(&bounded, scale, 0).unwrap(), + quantize_f32_to_i8_rne_symmetric(&bounded, scale).unwrap() + ); + } + + #[test] + fn test_i8_rne_rejects_invalid_scale_and_nonfinite_values() { + for scale in [0.0, -0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!( + quantize_f32_to_i8_rne_full(&[1.0], scale, 0), + Err(I8RneQuantizeError::InvalidScale) + ); + assert_eq!( + quantize_f32_to_i8_rne_symmetric(&[1.0], scale), + Err(I8RneQuantizeError::InvalidScale) + ); + } + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert_eq!( + quantize_f32_to_i8_rne_full(&[0.0, bad], 1.0, 0), + Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) + ); + assert_eq!( + quantize_f32_to_i8_rne_symmetric(&[0.0, bad], 1.0), + Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) + ); + } + } + + #[test] + fn test_i8_rne_range_can_change_reconstruction_error() { + // Both paths get exactly the same externally supplied scale. + let data = [-128.0, -127.0, 0.0, 127.0]; + let full = quantize_f32_to_i8_rne_full(&data, 1.0, 0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric(&data, 1.0).unwrap(); + let full_error: f32 = data.iter().zip(full.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); + let sym_error: f32 = data.iter().zip(sym.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); + assert_eq!(full_error, 0.0); + assert_eq!(sym_error, 1.0); + } + #[test] fn test_int8_gemm() { let a: Vec = vec![128, 128, 128, 128]; // centered at 128 From 5d4210c4f2471767ae98ba85c3297012727c529b Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Sat, 24 Oct 2026 12:22:42 +0200 Subject: [PATCH 2/9] research(quantized): reserve -128 as NaN in opt-in symmetric i8 leg --- src/hpc/quantized.rs | 116 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/src/hpc/quantized.rs b/src/hpc/quantized.rs index 3ddc13af..594ecc04 100644 --- a/src/hpc/quantized.rs +++ b/src/hpc/quantized.rs @@ -566,12 +566,13 @@ pub fn quantize_f32_to_i8(data: &[f32]) -> (Vec, QuantParams) { /// Rejection conditions for explicitly scaled, round-to-nearest-even i8 quantizers. /// -/// Neither numerical path invents an i8 representation for NaN or infinity. +/// The full-range path rejects non-finite inputs; symmetric+NaN maps float +/// NaN to the reserved -128 code and rejects infinities. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum I8RneQuantizeError { /// Scale must be finite and strictly positive. InvalidScale, - /// A float input was non-finite. + /// A non-finite input was unsupported by the selected mode. NonFiniteInput { index: usize }, } @@ -603,15 +604,23 @@ pub fn quantize_f32_to_i8_rne_full( .collect() } -/// Alternative round-to-even quantization with exact code symmetry -127..=127. +/// Reserved invalid/NaN code for the opt-in symmetric numerical i8 leg. /// -/// The zero point is fixed at integer 0. Code -128 is not emitted and is not -/// reinterpreted as a NaN or a negative zero. The f32 sign bit of -0.0 cannot -/// survive either i8 representation. This is NOT an ONNX QuantizeLinear alias. +/// This is a *codec sentinel*, not an IEEE-754 bit-pattern NaN. Palette256 +/// identity indices remain independent u8 values in the full 0..=255 range. +pub const I8_SYMMETRIC_NAN_CODE: i8 = i8::MIN; + +/// Alternative signed-i8 path: -127..=127 finite codes, -128 means NaN. +/// +/// Finite x maps to clamp(round_ties_even(x / scale), -127, 127). Input NaN +/// maps to -128. Infinities are rejected, and the scale must be finite and +/// positive. Zero point is fixed at 0; -0.0 and +0.0 encode identically. /// -/// Supply the SAME external scale to this path and the full-range path when -/// measuring only the effect of retaining or excluding -128. -pub fn quantize_f32_to_i8_rne_symmetric( +/// The sentinel is not valid numeric input to ordinary i8 arithmetic: encoded +/// consumers must check/propagate it explicitly (including LUT entries). +/// This is NOT ONNX QuantizeLinear, where -128 is a normal finite code. +/// Supply the SAME scale as the full-range path to isolate range effects. +pub fn quantize_f32_to_i8_rne_symmetric_nan( data: &[f32], scale: f32, ) -> Result, I8RneQuantizeError> { @@ -621,6 +630,9 @@ pub fn quantize_f32_to_i8_rne_symmetric( data.iter() .enumerate() .map(|(index, &value)| { + if value.is_nan() { + return Ok(I8_SYMMETRIC_NAN_CODE); + } if !value.is_finite() { return Err(I8RneQuantizeError::NonFiniteInput { index }); } @@ -629,6 +641,27 @@ pub fn quantize_f32_to_i8_rne_symmetric( .collect() } +/// Decode the opt-in symmetric i8+NaN leg at a named observation boundary. +/// +/// Integer -128 becomes an IEEE-754 f32 NaN. All other codes are finite. +/// This helper is for comparison/export, NOT an instruction to materialize +/// floats inside a Palette-native or LUT execution path. +pub fn dequantize_i8_rne_symmetric_nan( + codes: &[i8], + scale: f32, +) -> Result, I8RneQuantizeError> { + if !scale.is_finite() || scale <= 0.0 { + return Err(I8RneQuantizeError::InvalidScale); + } + Ok(codes.iter().map(|&code| { + if code == I8_SYMMETRIC_NAN_CODE { + f32::NAN + } else { + f32::from(code) * scale + } + }).collect()) +} + /// Dequantize i8 codes back to f32 using the [`QuantParams`] from /// [`quantize_f32_to_i8`]. /// @@ -991,7 +1024,7 @@ mod tests { fn test_i8_rne_full_code_space_and_symmetric_domain() { let xs: Vec = (-128i16..=127).map(f32::from).collect(); let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); assert_eq!(full.len(), 256); assert_eq!(sym.len(), 256); for (i, &x) in xs.iter().enumerate() { @@ -1010,7 +1043,7 @@ mod tests { -0.0, 0.0, 0.5, 1.5, 2.5, 126.5, 127.5, 1_000.0, ]; let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); assert_eq!(full, vec![-128, -128, -128, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); assert_eq!(sym, vec![-127, -127, -127, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); assert_eq!( @@ -1022,9 +1055,9 @@ mod tests { #[test] fn test_i8_rne_symmetric_sign_and_calibration_identity() { let xs: Vec = (-127i16..=127).map(f32::from).collect(); - let a = quantize_f32_to_i8_rne_symmetric(&xs, 1.0).unwrap(); + let a = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); let negative: Vec = xs.iter().map(|x| -x).collect(); - let b = quantize_f32_to_i8_rne_symmetric(&negative, 1.0).unwrap(); + let b = quantize_f32_to_i8_rne_symmetric_nan(&negative, 1.0).unwrap(); for (&plus, &minus) in a.iter().zip(b.iter()) { assert_eq!(plus, -minus); } @@ -1034,7 +1067,7 @@ mod tests { let scale = 12.0 / 127.0; assert_eq!( quantize_f32_to_i8_rne_full(&bounded, scale, 0).unwrap(), - quantize_f32_to_i8_rne_symmetric(&bounded, scale).unwrap() + quantize_f32_to_i8_rne_symmetric_nan(&bounded, scale).unwrap() ); } @@ -1046,7 +1079,7 @@ mod tests { Err(I8RneQuantizeError::InvalidScale) ); assert_eq!( - quantize_f32_to_i8_rne_symmetric(&[1.0], scale), + quantize_f32_to_i8_rne_symmetric_nan(&[1.0], scale), Err(I8RneQuantizeError::InvalidScale) ); } @@ -1055,11 +1088,60 @@ mod tests { quantize_f32_to_i8_rne_full(&[0.0, bad], 1.0, 0), Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) ); + } + assert_eq!( + quantize_f32_to_i8_rne_symmetric_nan(&[0.0, f32::NAN, -0.0], 1.0).unwrap(), + vec![0, I8_SYMMETRIC_NAN_CODE, 0] + ); + for bad in [f32::INFINITY, f32::NEG_INFINITY] { assert_eq!( - quantize_f32_to_i8_rne_symmetric(&[0.0, bad], 1.0), + quantize_f32_to_i8_rne_symmetric_nan(&[0.0, bad], 1.0), Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) ); } + assert_eq!( + dequantize_i8_rne_symmetric_nan(&[0], 0.0), + Err(I8RneQuantizeError::InvalidScale) + ); + } + + #[test] + fn test_i8_rne_symmetric_nan_all_256_decoded_codes() { + let all: Vec = (-128i16..=127).map(|i| i as i8).collect(); + let decoded = dequantize_i8_rne_symmetric_nan(&all, 1.0).unwrap(); + assert!(decoded[0].is_nan(), "reserved -128 must decode to NaN"); + for (q, &recovered) in all.iter().zip(decoded.iter()).skip(1) { + assert_eq!(recovered, f32::from(*q)); + } + assert_eq!( + quantize_f32_to_i8_rne_symmetric_nan(&decoded, 1.0).unwrap(), + all, + "full sentinel/codebook roundtrip" + ); + } + + #[test] + fn test_i8_three_modes_distinguish_midpoint_and_nan_contracts() { + // Legacy u8's min-max map spans [0,1] with step 1/255. + // Code 128 is the nearest midpoint, but 128/255 != 0.5 exactly. + let (u8_codes, u8_params) = quantize_f32_to_u8(&[0.0, 0.5, 1.0]); + assert_eq!(u8_codes, vec![0, 128, 255]); + assert!((f32::from(u8_codes[1]) * u8_params.scale - 0.5).abs() > 0.001); + // By contrast 0.5/128 == 1/256 is an EXACT midpoint-fixed + // alternative, but then 255/256 < 1 and 1.0 saturates. Do not + // silently change the legacy u8 contract to that convention. + assert_eq!(128.0f32 / 256.0, 0.5); + assert!(255.0f32 / 256.0 < 1.0); + + let full = quantize_f32_to_i8_rne_full(&[-128.0, f32::NAN], 1.0, 0); + assert_eq!(full, Err(I8RneQuantizeError::NonFiniteInput { index: 1 })); + let asym = quantize_f32_to_i8_rne_full(&[-128.0], 1.0, 0).unwrap(); + assert_eq!(asym, [-128]); + let sym = quantize_f32_to_i8_rne_symmetric_nan(&[-128.0, f32::NAN], 1.0).unwrap(); + assert_eq!(sym, [-127, I8_SYMMETRIC_NAN_CODE]); + let observed = dequantize_i8_rne_symmetric_nan(&sym, 1.0).unwrap(); + assert_eq!(observed[0], -127.0); + assert!(observed[1].is_nan()); } #[test] @@ -1067,7 +1149,7 @@ mod tests { // Both paths get exactly the same externally supplied scale. let data = [-128.0, -127.0, 0.0, 127.0]; let full = quantize_f32_to_i8_rne_full(&data, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric(&data, 1.0).unwrap(); + let sym = quantize_f32_to_i8_rne_symmetric_nan(&data, 1.0).unwrap(); let full_error: f32 = data.iter().zip(full.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); let sym_error: f32 = data.iter().zip(sym.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); assert_eq!(full_error, 0.0); From cf0d5791c8e095d20676dbeb2e0a885331bd7e1c Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Sat, 24 Oct 2026 12:24:25 +0200 Subject: [PATCH 3/9] docs(research): staged three-mode ndarray i8 NaN and ONNX cross-check prompt --- ...ized-u8-i8asym-i8symnan-stages-20261024.md | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md 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..ac409444 --- /dev/null +++ b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md @@ -0,0 +1,67 @@ +# 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]. Code 128 is the rounded midpoint under fitted `scale=1/255`, **but decoded 128/255 is not exactly 0.5**. +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 From 8e509cedf6342ae58dc5a3761814ac08577daf0c Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Sat, 24 Oct 2026 12:26:26 +0200 Subject: [PATCH 4/9] test(quantized): avoid false exact-midpoint assumption in legacy u8 f32 path --- src/hpc/quantized.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/hpc/quantized.rs b/src/hpc/quantized.rs index 594ecc04..287f7a81 100644 --- a/src/hpc/quantized.rs +++ b/src/hpc/quantized.rs @@ -1122,10 +1122,14 @@ mod tests { #[test] fn test_i8_three_modes_distinguish_midpoint_and_nan_contracts() { - // Legacy u8's min-max map spans [0,1] with step 1/255. - // Code 128 is the nearest midpoint, but 128/255 != 0.5 exactly. + // Legacy u8's min-max map spans [0,1] with step approximately 1/255. + // Code 127 OR 128 can occur at 0.5: f32 division by rounded scale + // may produce 127.49999 rather than an exact half-tie. + // Neither code reconstructs 0.5 exactly under this contract. let (u8_codes, u8_params) = quantize_f32_to_u8(&[0.0, 0.5, 1.0]); - assert_eq!(u8_codes, vec![0, 128, 255]); + assert_eq!(u8_codes[0], 0); + assert!(u8_codes[1] == 127 || u8_codes[1] == 128); + assert_eq!(u8_codes[2], 255); assert!((f32::from(u8_codes[1]) * u8_params.scale - 0.5).abs() > 0.001); // By contrast 0.5/128 == 1/256 is an EXACT midpoint-fixed // alternative, but then 255/256 < 1 and 1.0 saturates. Do not From b9b1d4e1061a0a04244818b1846ab0df8b3c36cd Mon Sep 17 00:00:00 2001 From: AdaWorldAPI Date: Sat, 24 Oct 2026 12:26:43 +0200 Subject: [PATCH 5/9] docs(research): reflect f32 midpoint precision in staged U8 audit --- .claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md index ac409444..57e8280e 100644 --- a/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md +++ b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md @@ -19,7 +19,7 @@ The NaN sentinel is a custom **integer codec marker**. IEEE-754 NaN applies only ## 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]. Code 128 is the rounded midpoint under fitted `scale=1/255`, **but decoded 128/255 is not exactly 0.5**. +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. From 62c43c9e8d8b11f7b1f8daee255f22bc8661570a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:03:25 +0000 Subject: [PATCH 6/9] plan(quantized): consolidate the 8-bit codec plan (v2); back out the runtime-scale prototype - .claude/plans/quantized-codec8-v2.md: one compile-time Codec8, calibration as a marker type, prepare vs execute, certified const tables, edge cases, measured results, checklist, open items, superseded designs named once. Goes through /5plus3 next. - .claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md: the /coresearch exploration map, banked so it survives the container. - the staged prompt is marked superseded (kept for provenance). - src/hpc/quantized.rs: back to master. The per-mode runtime-scale functions contradicted the compile-time ruling and never compiled under -D missing-docs; their edge-case inputs carry over into the v2 checklist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .../coresearch-2026-10-24-i8-codec8-map.md | 57 +++++ .claude/plans/quantized-codec8-v2.md | 241 ++++++++++++++++++ ...ized-u8-i8asym-i8symnan-stages-20261024.md | 2 + src/hpc/quantized.rs | 238 ----------------- 4 files changed, 300 insertions(+), 238 deletions(-) create mode 100644 .claude/knowledge/coresearch-2026-10-24-i8-codec8-map.md create mode 100644 .claude/plans/quantized-codec8-v2.md 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..8865d427 --- /dev/null +++ b/.claude/plans/quantized-codec8-v2.md @@ -0,0 +1,241 @@ +# Plan: 8-bit code spaces as one compile-time codec, plus certified tanh tables (v2) + +**Status:** PROPOSAL. Replaces v1 (scratchpad draft, never landed) and the staged prompt +`.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md` (superseded, see its +header). Goes through /5plus3 before any code lands. PR #370 is being refactored to this plan. + +**Source:** /coresearch 2026-10-24 (scouts, crosswalk, three co-architects, exploration map), +the TFLM harvest (AdaWorldAPI/tflite-micro `.claude/harvest/2026-10-24-tflm-quant-lut-harvest.md`, +merged), two lab probes in this session (asm identity, f64 tanh), and the operator's rulings +quoted below. + +## 1. Goal + +Three 8-bit modes in ndarray, run head to head and kept, not one winner: + +| mode | range | -128 | today | +|---|---|---|---| +| I8 asymmetric | -128..127 | a value | `quantize_f32_to_i8` (ties away) | +| I8 symmetric + NaN | -127..127 | NaN | new | +| U8 | 0..255 | (per code space) | spoFC truth, codebooks | + +Plus 256-entry tables so that 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; measured by P-01. + +## 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 is a compile-time parameter** ("Just make NaN another compile time parameter"). +- **Signed/unsigned storage is a parameter**, with storage-specific SIMD code. +- **Measure LLVM output** so the parameters cannot introduce silent degradation. +- **Re-export, not re-implementation:** `lance-graph-tflite` and `lance-graph-onnx` are `pub use` + facades; implementations live here. +- **Unchanged:** `quantize_f32_to_i8`, `dequantize_i8_to_f32`, `quantize_f32_to_u8`, FamilyGamma and + its -128 interpretation, the persisted Fisher-Z format, every existing wire format. +- **Not consumers:** FamilyGamma and bgz-tensor (operator: nothing consumes FamilyGamma; bgz-tensor + is listed only for palette256 and never touches it). No dependency on them. + +## 3. Design + +### 3.1 One codec, four marker parameters + +```rust +pub struct Codec8>(PhantomData<(T, S, R, N)>); +``` + +All four are sealed zero-sized marker traits, selected by monomorphisation. + +| parameter | markers | what it fixes | +|---|---|---| +| `Storage` | `I8`, `U8` | element type, the `ndarray::simd` wrapper (`I8x64` / `U8x64`), storage-specific kernels (compares, min/max, MAC shapes) | +| `CodeSpace` | `Asym` (-128..127), `Sym` (narrow, -128 never emitted), `UnitFull` (c/255, endpoints exact), `UnitOdd` (c/254, exact 0 / 0.5 / 1, code 255 free), `UnitMidrise` (test arm only) | `MIN`, `MAX`, decode affine | +| `Rounding` | `TiesAway`, `TiesEven`, `Floor` | the rounding function | +| `NanPolicy` | `NoNaN`, `NaNAtMin` | `NAN_CODE: Option` | + +`Index` (palette256 / codebook ordinals) is not a `CodeSpace`: it has no `Codec8` impl, only +lookup and ordinal operations. A cyclic Z_256 space (helix azimuth) is parked. + +**Constraint at compile time:** `NaNAtMin` implements `NanPolicy` only for narrow spaces, so +`Codec8<_, Asym, _, NaNAtMin>` does not compile (a `compile_fail` doc test pins it). + +**One body.** Encode, decode and clamp read `S::MIN`, `S::MAX` and `N::NAN_CODE`. The NaN branch is +a match on a constant and disappears for `NoNaN`. Storage `U8` stores the I8 code XOR 0x80 (TFLM's +own requantize fast path, `requantize.h:47`). + +**Aliases (the only names facades re-export):** + +| alias | definition | +|---|---| +| TFLite activation | `Codec8` | +| TFLite weight | `Codec8` | +| ONNX `QuantizeLinear` | `Codec8` | +| native value lane | `Codec8` (rounding: proposal, operator open item 1) | +| spoFC truth | `Codec8` (today's behaviour; kept until P-16) | + +### 3.2 Calibration is compile-time + +```rust +pub trait Calibration { const SCALE: f32; const ZERO_POINT: i32; } +``` + +A calibration is a marker type. Production code never takes scale or zero point as a runtime +argument. Runtime calibration exists only behind the Cargo feature `lab-calibration` (off by +default, enabled by no production crate) for sweeps where recompiling hurts and speed does not +matter; a lab table that is adopted is promoted by generating its compile-time source. + +### 3.3 Preparation and execution are separate + +- **Preparation** happens offline (production) or in the lab prepare step. It may compute, + certify, raise precision, and fail. +- **Execution** is a direct lookup into a `const` array (production) or an owned array held by its + concrete type (lab). Execution performs no codec, rounding or NaN selection, uses no trait objects, + function pointers or dispatchers, does no certification or precision raising, never calls tanh, + and never initialises lazily (no `OnceLock` / `LazyLock` on the lookup path). + +### 3.4 Certified tables + +- Tables are `const TABLE: [T::Elem; 256]` (code-to-code laws) or `[f32; 256]` (boundary tables), + one per (Storage, CodeSpace, Rounding, NanPolicy, Calibration, function), generated offline into + checked-in Rust source. A regeneration test proves the committed source equals the generator's + output (stronger than a stored hash). +- **Reference:** correctly rounded mathematical tanh at the exact value of the decoded f32, i.e. + `c as f32 * scale` exactly as the runtime decode computes it. `vstanh` is a separate + implementation with its own measured bound (differential accuracy test), never the definition. +- **Certificate per cell:** an enclosing interval from directed rounding; raise precision until + both ends round to the same target cell (an 8-bit code under the encoder's affine and rounding, + or f32 bits). Double-double is one precision level, not a proof. At the stated precision limit + the generator returns an explicit error naming the cell. It never writes an uncertified table. +- **Tools:** existing ones only (MPFR via mpmath or `rug`; a correctly rounded binary64 tanh such as + CORE-MATH as a candidate generator). No new transcendental library. Rust's `f64::tanh` calls the + platform libm with unspecified precision, so no table may depend on it. +- **Edge cases stated in the contract:** + - signed zero: tanh(-0.0) = -0.0 in f32 tables; code tables have one zero; + - NaN slot per storage: i8 0x80, U8 (XOR) 0x00; every law maps NaN to NaN; + - decode overflow: `c·scale` reaching ±inf in f32 maps to tanh(±inf) = ±1, or is a preparation + error if the calibration makes it reachable unintentionally. + +### 3.5 Native path stays in code space + +Element-wise and pairwise ops on tanh-domain values are closed code-to-code laws baked before the +hot path: unary 256 B, binary 64 KiB indexed `(first << 8) | second` (the `PairAddress` orientation +of lance-graph's `MoorePalettePairs` / Palette256 law). The f32 table is the boundary and the +oracle, used where a float must leave (ONNX conformance, export). Helix lesson: with codes uniform +in z (Fisher-Z), the table IS the decode, `tanh(z0 + c·dz)`; uniform-in-z versus uniform-in-value is +a design axis for P-14, not only the range. + +### 3.6 Boundaries for NoNaN + +Validation happens once per slice at the boundary, never per element (per-element checks measured ++40% and stop vectorisation). `encode_checked` returns an error if any input is NaN or infinite where +the mode rejects it; `decode_checked` returns an error if any code is the unused -128 under +`Sym + NoNaN`. The unchecked hot paths state that their input was validated. + +### 3.7 SIMD + +Only through `ndarray::simd` typed wrappers, all backends (AVX-512 / AVX2 / NEON / wasm / scalar), +with a parity test, per the W1a consumer contract +(`.claude/knowledge/vertical-simd-consumer-contract.md`). No raw intrinsics. + +## 4. What is already measured (lab probes, this session) + +- **Compile-time parameters cost nothing.** `codec-asm-probe` (rustc 1.99.0, release, + codegen-units 1): generic `Codec8` versus hand-written kernels produce identical + label-normalised assembly for SymNaN+TiesEven, Asym+TiesAway and UnitFull+Floor on x86-64-v3 and + -v4; a negative control differs; all vectorise. 1.311 vs 1.308 ns/elem, order-swapped 1.314 vs + 1.305. A per-element mode match does not vectorise (1.85-1.96 ns/elem). +- **f64 tanh.** `f64::tanh` compiles to `call tanh@GLIBC_2.2.5`; glibc 2.39 is up to 1.95 ulp off. + 8-bit tanh outputs never land on a tie (tanh of a nonzero rational is transcendental), so + TiesEven and TiesAway give identical tanh tables; only Floor differs. Rounding matters for data, + not for baked tanh tables. glibc `tanhf` is not correctly rounded on 6.5% of samples. +- **Confidence rounding hazard.** Rounded spoFC confidence reaches 255 (k=1: m=510 gives 254.501), + manufacturing certainty; Floor never does. Any rounded confidence encoder needs a cap at 254. +- **MAC.** A -128 weight term gives 32640, breaking TFLM's stated per-product bound (±32512, + `conv.h:104-110`) without overflowing int16; u8×s8 VPMADDUBSW pair sums (255·127·2 = 64770) + saturate int16 even with valid weights. The masked MAC arm needs an i32 accumulator or a proven + bound; NaN detection per dot product needs only an any-bit test. + +## 5. Checklist + +### Phase 0: pins and contracts (tests first) +- [ ] P-03 pin today's `quantize_f32_to_i8` on NaN, ±inf, inf-in-data (values from a run) +- [ ] Byte identity: `Codec8` with today's fitted calibration equals + `quantize_f32_to_i8` on a fixed corpus +- [ ] P-04 Sym+NaNAtMin contract (NaN, ±1e9, ±inf, -0.0); disables: floor -128, drop the NaN branch +- [ ] P-12 encode∘decode identity on all 256 codes per flavour +- [ ] P-02 ties: TiesEven vs TiesAway, exact counts incl. negative half-ties; non-tie twin +- [ ] Sym+NoNaN and Sym+NaNAtMin agree on all finite inputs and codes -127..127; differ only on NaN + and -128 +- [ ] `compile_fail`: `Codec8<_, Asym, _, NaNAtMin>` +- [ ] U8 storage equals I8 storage XOR 0x80 on all 256 codes, encode and decode, every flavour +- [ ] Carried from #370: signed zero, both saturations, negative half-ties, nonzero asym zero point, + the explicit -128 divergence (as test inputs) + +### Phase 1: codec +- [ ] `src/hpc/codec8.rs`: marker traits, `Codec8`, `Calibration`, aliases (scalar first) +- [ ] SIMD via `ndarray::simd`, parity test per backend +- [ ] asm gate: alias vs hand-written kernel identical per flavour (port `codec-asm-probe`); + negative control must differ +- [ ] timing gate: alias within noise of direct (repeated + order-swapped, tier printed, ns) +- [ ] `lab-calibration` feature + test that no production facade enables it + +### Phase 2: certified tables +- [ ] offline generator (outside the hot crate): interval certificate per cell, explicit error at + the precision limit; signed zero, NaN slot, decode overflow +- [ ] committed `const` tables for the alias set; regeneration test +- [ ] P-07 every cell equals the independent certified reference; disables: index offset 128, + wrong calibration +- [ ] P-08 odd mirror: decides whether a 128-entry form exists at all (exhaustive, not assumed) +- [ ] `vstanh` differential: f32 table vs `vstanh(decode(c))`, ULP histogram (reported, not a gate) +- [ ] P-09 lookup ≤ 0.5× `vstanh(decode)` ns/elem at 64 Ki and 16 Mi, release, tier printed; + preparation cost reported separately from lookup cost + +### Phase 2b: code laws +- [ ] unary law exhaustive: `law[c] == encode(tanh(decode(c)))` per flavour, all 256 +- [ ] NaNAtMin totality: `law[NaN] == NaN`; binary law NaN in either operand gives NaN (256×256) +- [ ] binary 64 KiB law, `(first << 8) | second`, swapped-operand anti-vacuity + +### Phase 3: head to head (measurement only) +- [ ] P-01 modes × populations (centred, skewed, uniform codes, 90% sparse): RMSE, bias (LSB), + exact zeros, oddness violations, -128 emissions; calibration rule frozen before the run; + H-SYM refuted only where Asym actually emits -128 +- [ ] P-13 entropy gap +- [ ] P-14 uniform-in-z vs uniform-in-value on bounded-cosine data, error by band +- [ ] P-15 MAC: Sym+NaNAtMin weights × Asym activations; arm 1 unmasked (anti-vacuity), arm 2 + any-bit NaN test + blend + i32 accumulator; equal to the no-NaN reference bit for bit on + NaN-free rows; NaN-free VPMADDUBSW saturation counterexample +- [ ] P-16 spoFC: UnitFull×{Floor, TiesEven} vs UnitOdd vs UnitMidrise over the evidence grid + (m in 0..=4096, k in {1, 2, 5}); semantic invariants first (confidence < 255, exact f = 0/1, + gate selection F_MIN 153 / C_MIN 230), bias second; midrise rejected if it loses exact + endpoints with no error gain + +### Phase 4: facades (separate PRs) +- [ ] `lance-graph-onnx`: per flavour, all 256 codes, `bounded::tanh_f32(decode(c))` vs the + certified table; ONNX reference dequant vs `decode`; Sym+NaNAtMin -128 as a stated divergence +- [ ] `lance-graph-tflite`: `pub use` only; conformance against a C++ oracle built from TFLM + reference headers (`8f6596d`), exhaustive 256 (int8) / 65536 (int16) per (scale, zp) + +## 6. Open for the operator + +1. Rounding for the native value lane: TiesEven (proposed) or TiesAway. +2. spoFC stays Floor until P-16 (proposed yes). +3. A zero-filled U8 NaNAtMin buffer reads as all-NaN (fail-closed): accept or not. +4. Certification tool: mpmath offline or `rug`/MPFR. +5. ±inf under Sym+NaNAtMin: saturate to ±127 (proposed) or NaN. + +## 7. Superseded (named once, not carried) + +- runtime mode enum (`I8CodeSpace`, `Rounding` as values) and the per-slice selector; +- separate `SymNaN` code space (now `Sym` + `NaNAtMin`); +- runtime calibration and runtime table build in production; the runtime certifier; +- "U8 deferred" (U8 is in scope for spoFC and codebooks); +- PR #370's per-mode functions with runtime scale returning `Result>`. + +## 8. Not searched / not run + +- TFLM per-target kernels (`cmsis_nn/`, `xtensa/`, `ceva/`) and the gemmlowp fixed-point tanh body. +- No real-model data (manifest item D in the 2026-10-24 report is NOT RUN). +- No ONNX Runtime comparison. diff --git a/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md index 57e8280e..d5ec5560 100644 --- a/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md +++ b/.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md @@ -1,3 +1,5 @@ +> **⊘ 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. diff --git a/src/hpc/quantized.rs b/src/hpc/quantized.rs index 287f7a81..2a98e214 100644 --- a/src/hpc/quantized.rs +++ b/src/hpc/quantized.rs @@ -564,104 +564,6 @@ pub fn quantize_f32_to_i8(data: &[f32]) -> (Vec, QuantParams) { ) } -/// Rejection conditions for explicitly scaled, round-to-nearest-even i8 quantizers. -/// -/// The full-range path rejects non-finite inputs; symmetric+NaN maps float -/// NaN to the reserved -128 code and rejects infinities. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum I8RneQuantizeError { - /// Scale must be finite and strictly positive. - InvalidScale, - /// A non-finite input was unsupported by the selected mode. - NonFiniteInput { index: usize }, -} - -/// Quantize finite f32 values using the full signed-i8 range -128..=127. -/// -/// For each input: clamp(round_ties_even(x / scale) + zero_point, -128, 127). -/// This matches the scalar ONNX QuantizeLinear numerical rule for finite f32 -/// inputs with an i8 zero point. It does not cover other dtypes or broadcasting. -/// -128 remains a valid numerical result; it is never a NaN marker. -/// -/// The existing quantize_f32_to_i8 entry point is deliberately unchanged. -pub fn quantize_f32_to_i8_rne_full( - data: &[f32], - scale: f32, - zero_point: i8, -) -> Result, I8RneQuantizeError> { - if !scale.is_finite() || scale <= 0.0 { - return Err(I8RneQuantizeError::InvalidScale); - } - data.iter() - .enumerate() - .map(|(index, &value)| { - if !value.is_finite() { - return Err(I8RneQuantizeError::NonFiniteInput { index }); - } - let rounded = (value / scale).round_ties_even(); - Ok((rounded + f32::from(zero_point)).clamp(-128.0, 127.0) as i8) - }) - .collect() -} - -/// Reserved invalid/NaN code for the opt-in symmetric numerical i8 leg. -/// -/// This is a *codec sentinel*, not an IEEE-754 bit-pattern NaN. Palette256 -/// identity indices remain independent u8 values in the full 0..=255 range. -pub const I8_SYMMETRIC_NAN_CODE: i8 = i8::MIN; - -/// Alternative signed-i8 path: -127..=127 finite codes, -128 means NaN. -/// -/// Finite x maps to clamp(round_ties_even(x / scale), -127, 127). Input NaN -/// maps to -128. Infinities are rejected, and the scale must be finite and -/// positive. Zero point is fixed at 0; -0.0 and +0.0 encode identically. -/// -/// The sentinel is not valid numeric input to ordinary i8 arithmetic: encoded -/// consumers must check/propagate it explicitly (including LUT entries). -/// This is NOT ONNX QuantizeLinear, where -128 is a normal finite code. -/// Supply the SAME scale as the full-range path to isolate range effects. -pub fn quantize_f32_to_i8_rne_symmetric_nan( - data: &[f32], - scale: f32, -) -> Result, I8RneQuantizeError> { - if !scale.is_finite() || scale <= 0.0 { - return Err(I8RneQuantizeError::InvalidScale); - } - data.iter() - .enumerate() - .map(|(index, &value)| { - if value.is_nan() { - return Ok(I8_SYMMETRIC_NAN_CODE); - } - if !value.is_finite() { - return Err(I8RneQuantizeError::NonFiniteInput { index }); - } - Ok((value / scale).round_ties_even().clamp(-127.0, 127.0) as i8) - }) - .collect() -} - -/// Decode the opt-in symmetric i8+NaN leg at a named observation boundary. -/// -/// Integer -128 becomes an IEEE-754 f32 NaN. All other codes are finite. -/// This helper is for comparison/export, NOT an instruction to materialize -/// floats inside a Palette-native or LUT execution path. -pub fn dequantize_i8_rne_symmetric_nan( - codes: &[i8], - scale: f32, -) -> Result, I8RneQuantizeError> { - if !scale.is_finite() || scale <= 0.0 { - return Err(I8RneQuantizeError::InvalidScale); - } - Ok(codes.iter().map(|&code| { - if code == I8_SYMMETRIC_NAN_CODE { - f32::NAN - } else { - f32::from(code) * scale - } - }).collect()) -} - /// Dequantize i8 codes back to f32 using the [`QuantParams`] from /// [`quantize_f32_to_i8`]. /// @@ -1020,146 +922,6 @@ mod tests { assert_eq!(q[1], -127); } - #[test] - fn test_i8_rne_full_code_space_and_symmetric_domain() { - let xs: Vec = (-128i16..=127).map(f32::from).collect(); - let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); - assert_eq!(full.len(), 256); - assert_eq!(sym.len(), 256); - for (i, &x) in xs.iter().enumerate() { - assert_eq!(full[i], x as i8, "full code {}", x); - assert_eq!(sym[i], (x as i32).clamp(-127, 127) as i8, "sym code {}", x); - } - assert!(sym.iter().all(|&code| code != i8::MIN)); - assert_eq!(full[0], -128); - assert_eq!(sym[0], -127); - } - - #[test] - fn test_i8_rne_ties_endpoint_and_zero_point() { - let xs = [ - -1_000.0, -128.5, -127.5, -126.5, -2.5, -1.5, -0.5, - -0.0, 0.0, 0.5, 1.5, 2.5, 126.5, 127.5, 1_000.0, - ]; - let full = quantize_f32_to_i8_rne_full(&xs, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); - assert_eq!(full, vec![-128, -128, -128, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); - assert_eq!(sym, vec![-127, -127, -127, -126, -2, -2, 0, 0, 0, 0, 2, 2, 126, 127, 127]); - assert_eq!( - quantize_f32_to_i8_rne_full(&[-200.0, -2.5, 0.0, 2.5, 200.0], 1.0, -3).unwrap(), - vec![-128, -5, -3, -1, 127] - ); - } - - #[test] - fn test_i8_rne_symmetric_sign_and_calibration_identity() { - let xs: Vec = (-127i16..=127).map(f32::from).collect(); - let a = quantize_f32_to_i8_rne_symmetric_nan(&xs, 1.0).unwrap(); - let negative: Vec = xs.iter().map(|x| -x).collect(); - let b = quantize_f32_to_i8_rne_symmetric_nan(&negative, 1.0).unwrap(); - for (&plus, &minus) in a.iter().zip(b.iter()) { - assert_eq!(plus, -minus); - } - // When max-abs/127 keeps every result in range, the two paths - // should be identical. This is an essential negative control. - let bounded = [-12.0, -1.0, -0.1, -0.0, 0.0, 0.1, 1.0, 12.0]; - let scale = 12.0 / 127.0; - assert_eq!( - quantize_f32_to_i8_rne_full(&bounded, scale, 0).unwrap(), - quantize_f32_to_i8_rne_symmetric_nan(&bounded, scale).unwrap() - ); - } - - #[test] - fn test_i8_rne_rejects_invalid_scale_and_nonfinite_values() { - for scale in [0.0, -0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!( - quantize_f32_to_i8_rne_full(&[1.0], scale, 0), - Err(I8RneQuantizeError::InvalidScale) - ); - assert_eq!( - quantize_f32_to_i8_rne_symmetric_nan(&[1.0], scale), - Err(I8RneQuantizeError::InvalidScale) - ); - } - for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!( - quantize_f32_to_i8_rne_full(&[0.0, bad], 1.0, 0), - Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) - ); - } - assert_eq!( - quantize_f32_to_i8_rne_symmetric_nan(&[0.0, f32::NAN, -0.0], 1.0).unwrap(), - vec![0, I8_SYMMETRIC_NAN_CODE, 0] - ); - for bad in [f32::INFINITY, f32::NEG_INFINITY] { - assert_eq!( - quantize_f32_to_i8_rne_symmetric_nan(&[0.0, bad], 1.0), - Err(I8RneQuantizeError::NonFiniteInput { index: 1 }) - ); - } - assert_eq!( - dequantize_i8_rne_symmetric_nan(&[0], 0.0), - Err(I8RneQuantizeError::InvalidScale) - ); - } - - #[test] - fn test_i8_rne_symmetric_nan_all_256_decoded_codes() { - let all: Vec = (-128i16..=127).map(|i| i as i8).collect(); - let decoded = dequantize_i8_rne_symmetric_nan(&all, 1.0).unwrap(); - assert!(decoded[0].is_nan(), "reserved -128 must decode to NaN"); - for (q, &recovered) in all.iter().zip(decoded.iter()).skip(1) { - assert_eq!(recovered, f32::from(*q)); - } - assert_eq!( - quantize_f32_to_i8_rne_symmetric_nan(&decoded, 1.0).unwrap(), - all, - "full sentinel/codebook roundtrip" - ); - } - - #[test] - fn test_i8_three_modes_distinguish_midpoint_and_nan_contracts() { - // Legacy u8's min-max map spans [0,1] with step approximately 1/255. - // Code 127 OR 128 can occur at 0.5: f32 division by rounded scale - // may produce 127.49999 rather than an exact half-tie. - // Neither code reconstructs 0.5 exactly under this contract. - let (u8_codes, u8_params) = quantize_f32_to_u8(&[0.0, 0.5, 1.0]); - assert_eq!(u8_codes[0], 0); - assert!(u8_codes[1] == 127 || u8_codes[1] == 128); - assert_eq!(u8_codes[2], 255); - assert!((f32::from(u8_codes[1]) * u8_params.scale - 0.5).abs() > 0.001); - // By contrast 0.5/128 == 1/256 is an EXACT midpoint-fixed - // alternative, but then 255/256 < 1 and 1.0 saturates. Do not - // silently change the legacy u8 contract to that convention. - assert_eq!(128.0f32 / 256.0, 0.5); - assert!(255.0f32 / 256.0 < 1.0); - - let full = quantize_f32_to_i8_rne_full(&[-128.0, f32::NAN], 1.0, 0); - assert_eq!(full, Err(I8RneQuantizeError::NonFiniteInput { index: 1 })); - let asym = quantize_f32_to_i8_rne_full(&[-128.0], 1.0, 0).unwrap(); - assert_eq!(asym, [-128]); - let sym = quantize_f32_to_i8_rne_symmetric_nan(&[-128.0, f32::NAN], 1.0).unwrap(); - assert_eq!(sym, [-127, I8_SYMMETRIC_NAN_CODE]); - let observed = dequantize_i8_rne_symmetric_nan(&sym, 1.0).unwrap(); - assert_eq!(observed[0], -127.0); - assert!(observed[1].is_nan()); - } - - #[test] - fn test_i8_rne_range_can_change_reconstruction_error() { - // Both paths get exactly the same externally supplied scale. - let data = [-128.0, -127.0, 0.0, 127.0]; - let full = quantize_f32_to_i8_rne_full(&data, 1.0, 0).unwrap(); - let sym = quantize_f32_to_i8_rne_symmetric_nan(&data, 1.0).unwrap(); - let full_error: f32 = data.iter().zip(full.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); - let sym_error: f32 = data.iter().zip(sym.iter()).map(|(&x, &q)| (x - f32::from(q)).abs()).sum(); - assert_eq!(full_error, 0.0); - assert_eq!(sym_error, 1.0); - } - #[test] fn test_int8_gemm() { let a: Vec = vec![128, 128, 128, 128]; // centered at 128 From ebb687767f4f5a2310b36aa3fd09ded25f2149a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:11:09 +0000 Subject: [PATCH 7/9] plan(quantized): ratify the Codec8 plan (v3) after a 5+3 council The plan file keeps its name; its content is now v3. Changes from the council: true division in encode (a precomputed reciprocal changes codes at half-ties), exact scale bits for the byte-identity gate, a concrete compile_fail with a compiling twin, a clippy opt-in in codec8 (pub mod hpc allows clippy::all), the table generator in tools/ instead of a crate, SIMD primitives landing first, XOR storage scoped to Asym/Sym, UnitOdd/UnitMidrise test-only, NativeLane held until its rounding is decided, a per-mode table inventory, separate data and table rounding gates, spoFC byte and three-mode head-to-head gates, and restore conditions for parked arms. Blackboard entry added in the same commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/blackboard.md | 14 + .claude/plans/quantized-codec8-v2.md | 517 +++++++++++++++------------ 2 files changed, 310 insertions(+), 221 deletions(-) diff --git a/.claude/blackboard.md b/.claude/blackboard.md index 8d733430..6ba45e82 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -3721,3 +3721,17 @@ 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. diff --git a/.claude/plans/quantized-codec8-v2.md b/.claude/plans/quantized-codec8-v2.md index 8865d427..18391073 100644 --- a/.claude/plans/quantized-codec8-v2.md +++ b/.claude/plans/quantized-codec8-v2.md @@ -1,27 +1,31 @@ -# Plan: 8-bit code spaces as one compile-time codec, plus certified tanh tables (v2) +# Plan: 8-bit code spaces as one compile-time codec, plus certified tanh tables (v3, RATIFIED) -**Status:** PROPOSAL. Replaces v1 (scratchpad draft, never landed) and the staged prompt -`.claude/prompts/quantized-u8-i8asym-i8symnan-stages-20261024.md` (superseded, see its -header). Goes through /5plus3 before any code lands. PR #370 is being refactored to this plan. +**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 (scouts, crosswalk, three co-architects, exploration map), -the TFLM harvest (AdaWorldAPI/tflite-micro `.claude/harvest/2026-10-24-tflm-quant-lut-harvest.md`, -merged), two lab probes in this session (asm identity, f64 tanh), and the operator's rulings -quoted below. +**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 in ndarray, run head to head and kept, not one winner: +Three 8-bit modes, kept and run head to head (no single winner): -| mode | range | -128 | today | -|---|---|---|---| -| I8 asymmetric | -128..127 | a value | `quantize_f32_to_i8` (ties away) | -| I8 symmetric + NaN | -127..127 | NaN | new | -| U8 | 0..255 | (per code space) | spoFC truth, codebooks | +| 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 | -Plus 256-entry tables so that 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; measured by P-01. +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 @@ -29,213 +33,284 @@ so the symmetric range has no skew. Not assumed; measured by P-01. 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 is a compile-time parameter** ("Just make NaN another compile time parameter"). -- **Signed/unsigned storage is a parameter**, with storage-specific SIMD code. -- **Measure LLVM output** so the parameters cannot introduce silent degradation. -- **Re-export, not re-implementation:** `lance-graph-tflite` and `lance-graph-onnx` are `pub use` - facades; implementations live here. -- **Unchanged:** `quantize_f32_to_i8`, `dequantize_i8_to_f32`, `quantize_f32_to_u8`, FamilyGamma and - its -128 interpretation, the persisted Fisher-Z format, every existing wire format. -- **Not consumers:** FamilyGamma and bgz-tensor (operator: nothing consumes FamilyGamma; bgz-tensor - is listed only for palette256 and never touches it). No dependency on them. - -## 3. Design - -### 3.1 One codec, four marker parameters - -```rust -pub struct Codec8>(PhantomData<(T, S, R, N)>); -``` +- **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 | -All four are sealed zero-sized marker traits, selected by monomorphisation. +**D7 Tables (phase c).** -| parameter | markers | what it fixes | +| mode | tables | NaN slot | |---|---|---| -| `Storage` | `I8`, `U8` | element type, the `ndarray::simd` wrapper (`I8x64` / `U8x64`), storage-specific kernels (compares, min/max, MAC shapes) | -| `CodeSpace` | `Asym` (-128..127), `Sym` (narrow, -128 never emitted), `UnitFull` (c/255, endpoints exact), `UnitOdd` (c/254, exact 0 / 0.5 / 1, code 255 free), `UnitMidrise` (test arm only) | `MIN`, `MAX`, decode affine | -| `Rounding` | `TiesAway`, `TiesEven`, `Floor` | the rounding function | -| `NanPolicy` | `NoNaN`, `NaNAtMin` | `NAN_CODE: Option` | - -`Index` (palette256 / codebook ordinals) is not a `CodeSpace`: it has no `Codec8` impl, only -lookup and ordinal operations. A cyclic Z_256 space (helix azimuth) is parked. - -**Constraint at compile time:** `NaNAtMin` implements `NanPolicy` only for narrow spaces, so -`Codec8<_, Asym, _, NaNAtMin>` does not compile (a `compile_fail` doc test pins it). - -**One body.** Encode, decode and clamp read `S::MIN`, `S::MAX` and `N::NAN_CODE`. The NaN branch is -a match on a constant and disappears for `NoNaN`. Storage `U8` stores the I8 code XOR 0x80 (TFLM's -own requantize fast path, `requantize.h:47`). - -**Aliases (the only names facades re-export):** - -| alias | definition | -|---|---| -| TFLite activation | `Codec8` | -| TFLite weight | `Codec8` | -| ONNX `QuantizeLinear` | `Codec8` | -| native value lane | `Codec8` (rounding: proposal, operator open item 1) | -| spoFC truth | `Codec8` (today's behaviour; kept until P-16) | - -### 3.2 Calibration is compile-time - -```rust -pub trait Calibration { const SCALE: f32; const ZERO_POINT: i32; } -``` - -A calibration is a marker type. Production code never takes scale or zero point as a runtime -argument. Runtime calibration exists only behind the Cargo feature `lab-calibration` (off by -default, enabled by no production crate) for sweeps where recompiling hurts and speed does not -matter; a lab table that is adopted is promoted by generating its compile-time source. - -### 3.3 Preparation and execution are separate - -- **Preparation** happens offline (production) or in the lab prepare step. It may compute, - certify, raise precision, and fail. -- **Execution** is a direct lookup into a `const` array (production) or an owned array held by its - concrete type (lab). Execution performs no codec, rounding or NaN selection, uses no trait objects, - function pointers or dispatchers, does no certification or precision raising, never calls tanh, - and never initialises lazily (no `OnceLock` / `LazyLock` on the lookup path). - -### 3.4 Certified tables - -- Tables are `const TABLE: [T::Elem; 256]` (code-to-code laws) or `[f32; 256]` (boundary tables), - one per (Storage, CodeSpace, Rounding, NanPolicy, Calibration, function), generated offline into - checked-in Rust source. A regeneration test proves the committed source equals the generator's - output (stronger than a stored hash). -- **Reference:** correctly rounded mathematical tanh at the exact value of the decoded f32, i.e. - `c as f32 * scale` exactly as the runtime decode computes it. `vstanh` is a separate - implementation with its own measured bound (differential accuracy test), never the definition. -- **Certificate per cell:** an enclosing interval from directed rounding; raise precision until - both ends round to the same target cell (an 8-bit code under the encoder's affine and rounding, - or f32 bits). Double-double is one precision level, not a proof. At the stated precision limit - the generator returns an explicit error naming the cell. It never writes an uncertified table. -- **Tools:** existing ones only (MPFR via mpmath or `rug`; a correctly rounded binary64 tanh such as - CORE-MATH as a candidate generator). No new transcendental library. Rust's `f64::tanh` calls the - platform libm with unspecified precision, so no table may depend on it. -- **Edge cases stated in the contract:** - - signed zero: tanh(-0.0) = -0.0 in f32 tables; code tables have one zero; - - NaN slot per storage: i8 0x80, U8 (XOR) 0x00; every law maps NaN to NaN; - - decode overflow: `c·scale` reaching ±inf in f32 maps to tanh(±inf) = ±1, or is a preparation - error if the calibration makes it reachable unintentionally. - -### 3.5 Native path stays in code space - -Element-wise and pairwise ops on tanh-domain values are closed code-to-code laws baked before the -hot path: unary 256 B, binary 64 KiB indexed `(first << 8) | second` (the `PairAddress` orientation -of lance-graph's `MoorePalettePairs` / Palette256 law). The f32 table is the boundary and the -oracle, used where a float must leave (ONNX conformance, export). Helix lesson: with codes uniform -in z (Fisher-Z), the table IS the decode, `tanh(z0 + c·dz)`; uniform-in-z versus uniform-in-value is -a design axis for P-14, not only the range. - -### 3.6 Boundaries for NoNaN - -Validation happens once per slice at the boundary, never per element (per-element checks measured -+40% and stop vectorisation). `encode_checked` returns an error if any input is NaN or infinite where -the mode rejects it; `decode_checked` returns an error if any code is the unused -128 under -`Sym + NoNaN`. The unchecked hot paths state that their input was validated. - -### 3.7 SIMD - -Only through `ndarray::simd` typed wrappers, all backends (AVX-512 / AVX2 / NEON / wasm / scalar), -with a parity test, per the W1a consumer contract -(`.claude/knowledge/vertical-simd-consumer-contract.md`). No raw intrinsics. - -## 4. What is already measured (lab probes, this session) - -- **Compile-time parameters cost nothing.** `codec-asm-probe` (rustc 1.99.0, release, - codegen-units 1): generic `Codec8` versus hand-written kernels produce identical - label-normalised assembly for SymNaN+TiesEven, Asym+TiesAway and UnitFull+Floor on x86-64-v3 and - -v4; a negative control differs; all vectorise. 1.311 vs 1.308 ns/elem, order-swapped 1.314 vs - 1.305. A per-element mode match does not vectorise (1.85-1.96 ns/elem). -- **f64 tanh.** `f64::tanh` compiles to `call tanh@GLIBC_2.2.5`; glibc 2.39 is up to 1.95 ulp off. - 8-bit tanh outputs never land on a tie (tanh of a nonzero rational is transcendental), so - TiesEven and TiesAway give identical tanh tables; only Floor differs. Rounding matters for data, - not for baked tanh tables. glibc `tanhf` is not correctly rounded on 6.5% of samples. -- **Confidence rounding hazard.** Rounded spoFC confidence reaches 255 (k=1: m=510 gives 254.501), - manufacturing certainty; Floor never does. Any rounded confidence encoder needs a cap at 254. -- **MAC.** A -128 weight term gives 32640, breaking TFLM's stated per-product bound (±32512, - `conv.h:104-110`) without overflowing int16; u8×s8 VPMADDUBSW pair sums (255·127·2 = 64770) - saturate int16 even with valid weights. The masked MAC arm needs an i32 accumulator or a proven - bound; NaN detection per dot product needs only an any-bit test. - -## 5. Checklist - -### Phase 0: pins and contracts (tests first) -- [ ] P-03 pin today's `quantize_f32_to_i8` on NaN, ±inf, inf-in-data (values from a run) -- [ ] Byte identity: `Codec8` with today's fitted calibration equals - `quantize_f32_to_i8` on a fixed corpus -- [ ] P-04 Sym+NaNAtMin contract (NaN, ±1e9, ±inf, -0.0); disables: floor -128, drop the NaN branch -- [ ] P-12 encode∘decode identity on all 256 codes per flavour -- [ ] P-02 ties: TiesEven vs TiesAway, exact counts incl. negative half-ties; non-tie twin -- [ ] Sym+NoNaN and Sym+NaNAtMin agree on all finite inputs and codes -127..127; differ only on NaN - and -128 -- [ ] `compile_fail`: `Codec8<_, Asym, _, NaNAtMin>` -- [ ] U8 storage equals I8 storage XOR 0x80 on all 256 codes, encode and decode, every flavour -- [ ] Carried from #370: signed zero, both saturations, negative half-ties, nonzero asym zero point, - the explicit -128 divergence (as test inputs) - -### Phase 1: codec -- [ ] `src/hpc/codec8.rs`: marker traits, `Codec8`, `Calibration`, aliases (scalar first) -- [ ] SIMD via `ndarray::simd`, parity test per backend -- [ ] asm gate: alias vs hand-written kernel identical per flavour (port `codec-asm-probe`); - negative control must differ -- [ ] timing gate: alias within noise of direct (repeated + order-swapped, tier printed, ns) -- [ ] `lab-calibration` feature + test that no production facade enables it - -### Phase 2: certified tables -- [ ] offline generator (outside the hot crate): interval certificate per cell, explicit error at - the precision limit; signed zero, NaN slot, decode overflow -- [ ] committed `const` tables for the alias set; regeneration test -- [ ] P-07 every cell equals the independent certified reference; disables: index offset 128, - wrong calibration -- [ ] P-08 odd mirror: decides whether a 128-entry form exists at all (exhaustive, not assumed) -- [ ] `vstanh` differential: f32 table vs `vstanh(decode(c))`, ULP histogram (reported, not a gate) -- [ ] P-09 lookup ≤ 0.5× `vstanh(decode)` ns/elem at 64 Ki and 16 Mi, release, tier printed; - preparation cost reported separately from lookup cost - -### Phase 2b: code laws -- [ ] unary law exhaustive: `law[c] == encode(tanh(decode(c)))` per flavour, all 256 -- [ ] NaNAtMin totality: `law[NaN] == NaN`; binary law NaN in either operand gives NaN (256×256) -- [ ] binary 64 KiB law, `(first << 8) | second`, swapped-operand anti-vacuity - -### Phase 3: head to head (measurement only) -- [ ] P-01 modes × populations (centred, skewed, uniform codes, 90% sparse): RMSE, bias (LSB), - exact zeros, oddness violations, -128 emissions; calibration rule frozen before the run; - H-SYM refuted only where Asym actually emits -128 -- [ ] P-13 entropy gap -- [ ] P-14 uniform-in-z vs uniform-in-value on bounded-cosine data, error by band -- [ ] P-15 MAC: Sym+NaNAtMin weights × Asym activations; arm 1 unmasked (anti-vacuity), arm 2 - any-bit NaN test + blend + i32 accumulator; equal to the no-NaN reference bit for bit on - NaN-free rows; NaN-free VPMADDUBSW saturation counterexample -- [ ] P-16 spoFC: UnitFull×{Floor, TiesEven} vs UnitOdd vs UnitMidrise over the evidence grid - (m in 0..=4096, k in {1, 2, 5}); semantic invariants first (confidence < 255, exact f = 0/1, - gate selection F_MIN 153 / C_MIN 230), bias second; midrise rejected if it loses exact - endpoints with no error gain - -### Phase 4: facades (separate PRs) -- [ ] `lance-graph-onnx`: per flavour, all 256 codes, `bounded::tanh_f32(decode(c))` vs the - certified table; ONNX reference dequant vs `decode`; Sym+NaNAtMin -128 as a stated divergence -- [ ] `lance-graph-tflite`: `pub use` only; conformance against a C++ oracle built from TFLM - reference headers (`8f6596d`), exhaustive 256 (int8) / 65536 (int16) per (scale, zp) - -## 6. Open for the operator - -1. Rounding for the native value lane: TiesEven (proposed) or TiesAway. -2. spoFC stays Floor until P-16 (proposed yes). -3. A zero-filled U8 NaNAtMin buffer reads as all-NaN (fail-closed): accept or not. -4. Certification tool: mpmath offline or `rug`/MPFR. -5. ±inf under Sym+NaNAtMin: saturate to ±127 (proposed) or NaN. - -## 7. Superseded (named once, not carried) - -- runtime mode enum (`I8CodeSpace`, `Rounding` as values) and the per-slice selector; -- separate `SymNaN` code space (now `Sym` + `NaNAtMin`); -- runtime calibration and runtime table build in production; the runtime certifier; -- "U8 deferred" (U8 is in scope for spoFC and codebooks); -- PR #370's per-mode functions with runtime scale returning `Result>`. - -## 8. Not searched / not run +| 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) +- [ ] `src/hpc/codec8.rs`: D1-D6 scalar, docs with an example on every `pub` item. +- [ ] Tests: + - [ ] G2, G3, G4, G13. + - [ ] P-02 ties, including negative half-ties. + - [ ] P-04 Sym+NaNAtMin: NaN, ±1e9, ±inf (as proposal), ±0. + - [ ] P-12 encode∘decode on all 256 codes per alias. + - [ ] Sym+NoNaN vs Sym+NaNAtMin agree everywhere except NaN and -128. + - [ ] D5: the pinned defined result on out-of-range input. + - [ ] Carried from #370: signed zero, both saturations, nonzero zero point. +- [ ] Negative control for D1, run before the commit and recorded: a deliberate clippy violation in + codec8.rs fails G1, then is reverted. +- [ ] `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. +- [ ] 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. +- [ ] 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 (manifest item D in the 2026-10-24 report is NOT RUN). +- No real-model data. - No ONNX Runtime comparison. +- SIMD backends other than AVX-512 not inventoried for the missing primitives (phase b does that). From 4c82adf3a5418d62a70517161b8aae4873ae3d7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:32:28 +0000 Subject: [PATCH 8/9] =?UTF-8?q?feat(hpc):=20Codec8=20phase=20(a)=20?= =?UTF-8?q?=E2=80=94=20compile-time=208-bit=20codec,=20scalar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One `Codec8` over sealed marker types, with calibration as a marker type (plan .claude/plans/quantized-codec8-v2.md, ratified by a 5+3 council). Nothing is selected at run time. - Storage I8 / U8 (u8 = i8 ^ 0x80 for Asym/Sym, raw for UnitFull); code spaces Asym, Sym, UnitFull; rounding TiesAway / TiesEven / Floor; NaNAtMin only for Sym (Asym + NaNAtMin and I8 + UnitFull are E0277, pinned by doctests). - Encode uses a true division (a reciprocal changes codes at half-ties) and adds the zero point as an integer; const assertions reject a bad calibration. - Checked encode/decode validate once per slice; the unchecked bodies are pub(crate) and total. - Aliases: TfliteAct, TfliteWeight, OnnxQ, SpoFcTruth. NativeLane waits for the operator's rounding decision. - codec8.rs opts back into clippy::all (pub mod hpc allows it). Gates: 14 unit tests and 10 doctests pass; G2 byte identity with quantize_f32_to_i8; G13 spoFC floor encoder equality; clippy -D warnings and fmt clean. G5: generic and hand-written kernels compile to identical asm on x86-64-v3 and -v4, negative control differs (scripts/codec8_asm_diff.sh, examples/codec8_asm_probe.rs). G6's 2% timing band is not resolvable on this host (identical code varies up to 17%) and is recorded as not met. Blackboard and plan updated in the same commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- .claude/blackboard.md | 8 + .claude/plans/quantized-codec8-v2.md | 57 +- Cargo.toml | 4 + examples/codec8_asm_probe.rs | 148 +++++ scripts/codec8_asm_diff.sh | 20 + src/hpc/codec8.rs | 877 +++++++++++++++++++++++++++ src/hpc/mod.rs | 1 + 7 files changed, 1102 insertions(+), 13 deletions(-) create mode 100644 examples/codec8_asm_probe.rs create mode 100755 scripts/codec8_asm_diff.sh create mode 100644 src/hpc/codec8.rs diff --git a/.claude/blackboard.md b/.claude/blackboard.md index 6ba45e82..a27b987b 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -3735,3 +3735,11 @@ does not gate any hpc file today (codec8 opts back in); simd-matrix does not tri 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/plans/quantized-codec8-v2.md b/.claude/plans/quantized-codec8-v2.md index 18391073..a3ffcc48 100644 --- a/.claude/plans/quantized-codec8-v2.md +++ b/.claude/plans/quantized-codec8-v2.md @@ -219,24 +219,24 @@ rounding stays a separate parameter. ## 6. Checklist ### Phase (a) -- [ ] `src/hpc/codec8.rs`: D1-D6 scalar, docs with an example on every `pub` item. -- [ ] Tests: - - [ ] G2, G3, G4, G13. - - [ ] P-02 ties, including negative half-ties. - - [ ] P-04 Sym+NaNAtMin: NaN, ±1e9, ±inf (as proposal), ±0. - - [ ] P-12 encode∘decode on all 256 codes per alias. - - [ ] Sym+NoNaN vs Sym+NaNAtMin agree everywhere except NaN and -128. - - [ ] D5: the pinned defined result on out-of-range input. - - [ ] Carried from #370: signed zero, both saturations, nonzero zero point. -- [ ] Negative control for D1, run before the commit and recorded: a deliberate clippy violation in +- [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. -- [ ] `examples/codec8_asm_probe.rs` (G5/G6), ported from the lab `codec-asm-probe`. It has its own +- [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. -- [ ] Commit discipline: +- [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. -- [ ] Workers, if any, edit only. The orchestrator is the single compiler on the shared `target/`. +- [x] Workers, if any, edit only. The orchestrator is the single compiler on the shared `target/`. Every timing names its tier. ### Phases (b)-(f) @@ -314,3 +314,34 @@ Tracked per PR as listed in D9 and §5. - 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/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..144b19fa --- /dev/null +++ b/src/hpc/codec8.rs @@ -0,0 +1,877 @@ +//! 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 { + (2 * n + d) / (2 * d) + } +} +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); + match (2 * r).cmp(&d) { + 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); +/// ``` +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. + 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"); + } + 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) + } + + /// 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 { + 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 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; From 9c8d0ceb86caf2471f4860eaf12ca02da49f4428 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:57:54 +0000 Subject: [PATCH 9/9] codec8: validate calibration on decode; overflow-safe div_round Codex review on #370: - The SCALE/ZERO_POINT const asserts lived only in encode_one, so a consumer that only decoded never instantiated them; SCALE = -1.0 compiled and decoded. Both directions now call assert_calibration, pinned by a compile_fail,E0080 doctest on the decode-only path. - TiesAway::div_round computed 2n + d and 2d, TiesEven compared 2r with d; both wrap for large u128 operands. Both now use quotient and remainder with the half test as r vs d - r. Tests at 2^127 and u128::MAX, plus small-operand tie tables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- src/hpc/codec8.rs | 69 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src/hpc/codec8.rs b/src/hpc/codec8.rs index 144b19fa..941a8fa0 100644 --- a/src/hpc/codec8.rs +++ b/src/hpc/codec8.rs @@ -232,7 +232,14 @@ impl Rounding for TiesAway { } #[inline(always)] fn div_round(n: u128, d: u128) -> u128 { - (2 * n + d) / (2 * d) + // 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 { @@ -243,7 +250,8 @@ impl Rounding for TiesEven { #[inline(always)] fn div_round(n: u128, d: u128) -> u128 { let (q, r) = (n / d, n % d); - match (2 * r).cmp(&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), @@ -309,6 +317,20 @@ impl NanPolicy for NaNAtMin { /// } /// 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. @@ -398,10 +420,7 @@ where 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. - 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"); - } + 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 @@ -417,10 +436,21 @@ where 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, @@ -653,6 +683,33 @@ mod tests { } // 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).