turbovec 1.0.0

Fast vector quantization with 2-4 bit compression and SIMD search
Documentation
//! Absolute golden anchor for every stage that decides an encoded byte.
//!
//! # Why this exists
//!
//! Before this test, encoded-byte determinism was only ever checked by
//! *self-consistency*: the CI `Encode fingerprint agrees across OSes`
//! leg runs `examples/encode_hash` on Linux, macOS and Windows within a
//! single locked build and fails unless the three agree. That catches
//! platform-specific divergence — a libm difference, a SIMD kernel not
//! reproducing the scalar reference — and it is the right tool for that
//! job. It is structurally blind to anything that moves **all platforms
//! together**, because it has nothing absolute to compare against
//! (#352).
//!
//! Such changes are routine, not exotic:
//!
//! * `statrs` decides the TQ+ calibration. It used to reach it through
//!   `ContinuousCDF::inverse_cdf`, which `Beta` does not override — a
//!   fixed 16-step bisection on `[-2, 2]`, resolution 6.1e-5, whose own
//!   doc comment calls it "ill-behaved". A release adding a specialised
//!   `inverse_cdf` moved `tqplus_shift`/`tqplus_scale` by ~5e-4
//!   relative, flipping ~0.1% of all codes and changing every stored
//!   scale, and nothing failed (#346). Since #454 the fit anchors on the
//!   codebook's outermost centroid, so `inverse_cdf` is no longer called
//!   from production code at all and the calibration's drift surface is
//!   `Beta::cdf` — the same function the centroids column already
//!   depends on. The exposure is unchanged in kind, only in which
//!   function carries it. The dependency is now exact-pinned
//!   (`=0.17.1`), so that upgrade can no longer arrive on its own — but
//!   the pin only decides *which* version is compiled, not what the
//!   resulting bytes are, and it is one line that a deliberate bump or a
//!   routine dependency sweep can raise. This test is what turns such a
//!   bump into a visible byte change instead of a silent one.
//! * `NORM_CHAINS` and the frozen combine tree decide the per-vector
//!   norm. `norm_simd_matches_scalar_bit_exactly` compares SIMD to the
//!   scalar reference, so a change to the *reference* moves both and
//!   still passes. 8 -> 16 chains changes the f32 norm for ~47% of
//!   vectors at dim 768 — landing entirely in `scales` (#352 gap 2).
//! * `TQPLUS_MIN_SAMPLES` is an unpinned format cliff (#352 gap 5).
//!
//! The six columns below pin all of it: not "this build agrees with
//! itself" but "this build agrees with the bytes turbovec has always
//! written". `codebook_determinism.rs` pins the first two columns for a
//! different (8-vector) fixture; this pins all six for the fixture CI
//! actually fingerprints.
//!
//! # These values are architecture-stable, and that is checked, not assumed
//!
//! This test is part of `cargo test -p turbovec --release`, which CI's
//! `Rust (<os>)` leg runs on all three operating systems —
//! `ubuntu-latest` (x86_64), `macos-14` (aarch64) and `windows-latest`
//! (x86_64). So every column below is asserted byte-for-byte on all
//! three on every run: a row that is not architecture-stable cannot go
//! green, whichever machine it was frozen on. That is a standing check,
//! not a one-time observation, and it is what makes these constants
//! something other than an arm64-local artifact. The separate cross-OS
//! `Encode fingerprint` leg remains in CI and keeps doing its own job —
//! reporting *which stage* diverged when platforms disagree.
//!
//! # Regenerating (only for a deliberate, decided format change)
//!
//! ```text
//! TURBOVEC_REFREEZE=1 cargo test --release -p turbovec \
//!     --test encode_fingerprint -- --nocapture
//! ```
//!
//! prints the `GOLDEN` rows for this build; paste them in verbatim. Do
//! **not** hand-edit a hex word: a wrong golden is worse than no golden.
//! And before pasting, confirm the change was intended — a failure here
//! means every index written by this build differs from every index
//! written before it, i.e. a format break, not a rounding nit.

#[path = "common/fingerprint.rs"]
mod fingerprint;

use fingerprint::Fingerprint;

/// `(dim, bits, boundaries, centroids, calibration, codes, scales, file)`.
///
/// Generated by the `TURBOVEC_REFREEZE=1` command in the module docs and
/// cross-checked against the three-OS `Encode fingerprint` CI artifacts.
#[rustfmt::skip]
const GOLDEN: &[(usize, usize, u64, u64, u64, u64, u64, u64)] = &[
    (200, 2, 0x4fb0378dc1d86d75, 0xd43ff8712da19915, 0x1974331dac161d9b, 0x122b777da643eba6, 0x467e7fc11cbd63c8, 0x2a967a852de6ce75),
    (600, 3, 0xdbf9f4cb38e0530d, 0xa0f02ffec44b5951, 0x45a9253a5394ef8e, 0x2ade4149a0363781, 0x1f1a60f658173d8d, 0x0e93917cc63837fd),
    (768, 4, 0x27273d875c560161, 0x09704dad5b65a00d, 0x675c5a5d5de10558, 0x2b6e66a18e412461, 0xe19782114e0d4434, 0x6100a91b494493ba),
    (1000, 4, 0xdc246fa18e79015d, 0xa632bd47c9e9628d, 0xe2a3800eca61b130, 0xe02d8df20dcb1dde, 0xd0700a74a602a059, 0x320877041ca60ee9),
    (1024, 3, 0xd9eeefae66c34fd1, 0xfcf5342e5aefb105, 0x570b4b3ef83a267d, 0x06a3982646ab3fb9, 0x647e88ac939e606e, 0x627672d69c84b6bf),
    (1536, 2, 0xb1a97993603c7dcd, 0x1348c9ae60bbdc3d, 0x5d9700e531f98c58, 0x65d6c36402627280, 0xd71c40bb564b1fcf, 0x161e0af5bc55645c),
    (1536, 4, 0x05f7a92f87aba9a1, 0x84f2c25cecbf6761, 0x90eec90ef20017f3, 0xe06d3facabb1be0e, 0x9023d9253dbebb3a, 0xca2563131d4afac1),
    (3072, 4, 0x9a1b3dca8251faad, 0x6a56a1738e12e62d, 0x8bb12d4a388dfa70, 0x2dbc750011989ee3, 0xc2155ee6e517cced, 0xa4c78ff768bf8480),
];

/// Which stage a drifted column implicates. Printed on failure, because
/// "the file hash moved" is not actionable and "the calibration moved,
/// suspect `statrs`" is.
fn diagnosis(column: &str) -> &'static str {
    match column {
        "boundaries" => {
            "codebook boundaries — midpoints of the f32 centroids. If \
             `centroids` also moved, fix that first; if only this moved, \
             the midpoint derivation changed (#259)."
        }
        "centroids" => {
            "Lloyd-Max centroids, computed at runtime from `statrs` Beta \
             cdf/pdf. A `statrs` upgrade or a libm change is the first \
             suspect. Every stored scale is computed against these."
        }
        "calibration" => {
            "TQ+ shift/scale, from `Beta::cdf` via the codebook-derived \
             anchor (`tqplus_anchor`, #454). If `centroids` also moved, \
             fix that first — both columns read the same `statrs` \
             function, so a shared drift shows in both. This column \
             moving alone means the anchor derivation changed."
        }
        "codes" => {
            "packed codes — the rotation, the codebook boundary scan, the \
             calibration, or the bit-packing layout. Check the columns to \
             the left first: they feed this one."
        }
        "scales" => {
            "stored per-vector scales, with the codes unchanged. That \
             isolates it to the per-vector norm (NORM_CHAINS / the frozen \
             combine tree) or the f64 inner-product accumulation order — \
             a change invisible to recall tests and to every SIMD-vs-\
             scalar identity test, since those move together (#352)."
        }
        "file" => {
            "serialized bytes only, with every computed stage unchanged. \
             That is a container change — header, field order, trailer — \
             not an arithmetic one. Deliberate? Then it needs a format \
             version bump, not a re-freeze."
        }
        _ => "unknown column",
    }
}

/// Whether the caller asked for a re-freeze.
///
/// Deliberately *not* `var_os(..).is_some()`: presence alone would make
/// `TURBOVEC_REFREEZE=` and `TURBOVEC_REFREEZE=0` skip every comparison
/// and still report `ok`. An empty or falsy value in a CI environment,
/// a shell profile or a `docker run -e TURBOVEC_REFREEZE` would then
/// silently turn the anchor into a no-op — the one failure mode a golden
/// test must not have. Only an affirmative value refreezes.
fn refreeze_requested() -> bool {
    match std::env::var("TURBOVEC_REFREEZE") {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            !matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
        }
        // Unset, or not UTF-8 (which cannot be an affirmative value).
        Err(_) => false,
    }
}

#[test]
fn encode_fingerprint_is_frozen() {
    // A re-freeze prints one row per *cell*, not per existing golden, and
    // does so before the divergence check below. Both halves matter: the
    // point of a re-freeze is often that the cell list just changed, and
    // iterating `GOLDEN` (or asserting the two agree first) would make a
    // newly added cell unfreezable — the regeneration path would refuse
    // to run until someone hand-wrote the row it exists to generate.
    if refreeze_requested() {
        println!("// paste into GOLDEN in tests/encode_fingerprint.rs");
        for &(dim, bits) in fingerprint::CELLS {
            println!("{}", fingerprint::fingerprint(dim, bits).golden_literal(dim, bits));
        }
        return;
    }

    // The cells the goldens cover must be exactly the cells the example
    // prints, or the anchor silently stops covering something CI checks.
    assert_eq!(
        GOLDEN.iter().map(|g| (g.0, g.1)).collect::<Vec<_>>(),
        fingerprint::CELLS.to_vec(),
        "GOLDEN and the fingerprint CELLS have diverged: every cell the \
         cross-OS leg prints must also be absolutely anchored",
    );

    let mut failures: Vec<String> = Vec::new();

    for &(dim, bits, boundaries, centroids, calibration, codes, scales, file) in GOLDEN {
        let got = fingerprint::fingerprint(dim, bits);
        let want = Fingerprint { boundaries, centroids, calibration, codes, scales, file };
        // Report *all* differing columns across *all* cells rather than
        // panicking on the first: which columns moved together is the
        // whole diagnostic value of splitting them, and a one-cell,
        // one-column failure message throws it away.
        for ((name, g), (_, w)) in got.columns().iter().zip(want.columns().iter()) {
            if g != w {
                failures.push(format!(
                    "  dim={dim} bits={bits} {name}: got {g:016x}, want {w:016x}\n    {}",
                    diagnosis(name),
                ));
            }
        }
    }

    assert!(
        failures.is_empty(),
        "the encode fingerprint has drifted — this build writes different \
         bytes than every previous build for identical input:\n{}\n\n\
         This is a format change. Decide it deliberately; see the \
         re-freeze instructions at the top of this file. The cross-OS CI \
         leg cannot see this class of drift, which is why these \
         constants exist.",
        failures.join("\n"),
    );
}

/// `RECON_TABLE_MIN_ROWS = 16` must be a performance switch, never a
/// format one.
///
/// At or above 16 rows the quantize kernels read a hoisted f64
/// reconstruction table; below it they recompute the same values inline.
/// Nothing exercised the switch end-to-end (#369): if the two paths ever
/// disagreed, the same vector would encode to different bytes depending
/// on how many neighbours it happened to be added with — a property no
/// caller could reasonably predict or work around.
///
/// **The warm-up prefix is load-bearing, not incidental.** The obvious
/// version of this test — a bare 15-row index against a bare 16-row one —
/// is very nearly vacuous, and measurably so: below `TQPLUS_MIN_SAMPLES`
/// the calibration is the identity (`shift = 0`, `scale = 1`), which
/// collapses the table entry `centroids[c] * inv - shift` and every
/// plausible reordering of it to the same expression. Reordering
/// `build_recon_table` to `(centroids[c] - shift) * inv` — the exact
/// divergence #369 names — leaves the bare-batch version *passing*.
/// Adding 1000 rows first freezes a fitted, non-identity calibration
/// that the two short batches then share, and the same perturbation
/// fails.
///
/// Scope note, stated rather than glossed: `KERNEL_USES_RECON_TABLE` is
/// `false` on x86_64, so on x86 *both* batches take the inline path and
/// this is a consistency check rather than a cross-path one. The path
/// comparison that runs on every architecture is
/// `encode::simd_identity_tests::quantize_kernel_matches_scalar_bit_exactly`,
/// which forces both settings explicitly.
#[test]
fn the_recon_table_threshold_does_not_change_encoded_bytes() {
    // The rows the calibration is fitted from, frozen before the
    // batches under test are appended.
    const WARM: usize = 1_000;
    // The real constant, not a copy. This used to be a private
    // `THRESHOLD = 16` guarded from the crate side by a
    // `const _: () = assert!(RECON_TABLE_MIN_ROWS == 16, ..)`, which ran
    // one way only: it failed the build if the constant moved and the
    // copy was left behind, and said nothing about drift starting here.
    // Lowering the copy to 8 compiled clean and quietly put *both*
    // depths below the real threshold, leaving both batches on the
    // inline path and the comparison unable to fail. Reading the
    // constant makes the depths move with it in both directions (#410).
    const THRESHOLD: usize = turbovec::encode::RECON_TABLE_MIN_ROWS;
    // Straddle it: one depth below, one at.
    let (below, at) = (THRESHOLD - 1, THRESHOLD);

    for &(dim, bits) in &[(768usize, 4usize), (1024, 3), (200, 2)] {
        let all = fingerprint::lcg_vectors(WARM + at, dim, fingerprint::cell_seed(dim, bits));

        let encode = |rows: usize| {
            let mut index = turbovec::TurboQuantIndex::new(dim, bits).unwrap();
            // Fit and freeze the calibration explicitly (adds no longer
            // fit), from the same rows the old warm-up add used.
            index.calibrate(&all[..WARM * dim]).unwrap();
            index.add(&all[..WARM * dim]);
            // Second add: `rows` is the batch depth the switch reads.
            index.add(&all[WARM * dim..(WARM + rows) * dim]);
            assert!(
                index.tqplus_shift().iter().any(|s| *s != 0.0),
                "dim={dim} bits={bits}: calibration is still the identity, so \
                 this test cannot distinguish the two paths",
            );
            (index.packed_codes().to_vec(), index.scales().to_vec())
        };

        // `below` rows: under the threshold. `at`: the first batch depth
        // that reaches it.
        let (packed_below, scales_below) = encode(below);
        let (packed_at, scales_at) = encode(at);

        let bytes_per_row = bits * (dim / 8);
        assert_eq!(packed_below.len(), (WARM + below) * bytes_per_row);
        assert_eq!(packed_at.len(), (WARM + at) * bytes_per_row);

        let shared = (WARM + below) * bytes_per_row;
        assert_eq!(
            packed_below,
            packed_at[..shared],
            "dim={dim} bits={bits}: the shared rows packed differently when \
             the trailing batch was {below} rows than when it was {at}. \
             RECON_TABLE_MIN_ROWS is then a format switch, not a performance \
             one — the same vector would encode differently depending on how \
             many neighbours it was added with.",
        );
        for r in 0..WARM + below {
            assert_eq!(
                scales_below[r].to_bits(),
                scales_at[r].to_bits(),
                "dim={dim} bits={bits} row {r}: stored scale depends on the \
                 batch depth ({} vs {})",
                scales_below[r],
                scales_at[r],
            );
        }
    }
}

/// There is no fit threshold: an add never fits a calibration, at any
/// row count. `TQPLUS_MIN_SAMPLES = 1000` used to be a hard format
/// cliff here — the same 999 rows encoded differently as a 999-row
/// index than as the first 999 of a 1000-row one. The cliff's *absence*
/// is now the format fact worth pinning: encoded bytes are a function
/// of (rows, committed calibration) alone, so reintroducing any
/// implicit fit cannot happen silently.
#[test]
fn adds_never_fit_at_any_row_count() {
    let (dim, bits) = (768usize, 4usize);
    let all = fingerprint::lcg_vectors(1_001, dim, fingerprint::cell_seed(dim, bits));

    for rows in [999usize, 1_000, 1_001] {
        let mut index = turbovec::TurboQuantIndex::new(dim, bits).unwrap();
        index.add(&all[..rows * dim]);
        assert!(
            index.tqplus_shift().is_empty() && index.tqplus_scale().is_empty(),
            "{rows} vectors produced a fitted TQ+ calibration — an \
             implicit fit has been reintroduced, and with it the format \
             cliff the explicit-calibration design removed",
        );
    }
}

/// The anchor is only worth anything if the calibration it pins is a
/// *fitted* one. An uncalibrated cell would pin nothing about
/// `statrs::Beta::cdf` — the single most exposed input in the pipeline
/// (#346, and the anchor derivation added in #454).
///
/// `fingerprint()` asserts this per cell; this test states the precondition
/// as its own named failure so a fixture change that drops `N` below the
/// threshold reports the cause rather than six mismatched hashes.
#[test]
fn the_pinned_calibration_is_actually_fitted() {
    let (dim, bits) = fingerprint::CELLS[0];
    let vectors = fingerprint::lcg_vectors(fingerprint::N, dim, fingerprint::cell_seed(dim, bits));
    let mut index = turbovec::TurboQuantIndex::new(dim, bits).unwrap();
    index.calibrate(&vectors).unwrap();
    index.add(&vectors);

    let shift = index.tqplus_shift();
    let scale = index.tqplus_scale();
    assert_eq!(shift.len(), dim, "TQ+ shift must be fitted, not empty");
    assert_eq!(scale.len(), dim, "TQ+ scale must be fitted, not empty");
    assert!(
        shift.iter().any(|s| *s != 0.0) || scale.iter().any(|s| *s != 1.0),
        "TQ+ calibration is the identity — N={} is at or below \
         TQPLUS_MIN_SAMPLES, so the `calibration` column of the golden \
         anchor pins nothing",
        fingerprint::N,
    );
}