From f6f644b21cb66e4337cbe8c79ce3d36502eafaa7 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 26 Aug 2026 09:10:24 +0700 Subject: [PATCH 01/13] chore: align .cora.yaml with current cora schema (#4) * chore: align .cora.yaml with current cora schema The old file used `review.enabled`, which the current cora binary rejects (unknown field), silently disabling pre-commit review. Mirrors the uteke .cora.yaml structure with vecq-specific rules (determinism, zero-dependency core). * fix: gate NEON-only bindings behind cfg(aarch64) bpv and q_rot are only used on the NEON path; on x86_64 builds they were unused variables, failing clippy -D warnings in CI. * fix: restore bpv under cfg(aarch64), verify local clippy clean * fix: gate NEON test bindings behind cfg(aarch64) for x86_64 builds * fix: cfg-gate remaining NEON test bindings * fix: cfg-gate the NEON parity test at function level --------- Co-authored-by: ajianaz --- .cora.yaml | 25 +++++++++++++++++-- .../2026-08-26_2e1c0a9_7d4499250c16b5fa.json | 12 +++++++++ .../2026-08-26_59a82f3_a4268fd072a3e331.json | 12 +++++++++ .../2026-08-26_9bdbe82_837357f4ff2d20d0.json | 12 +++++++++ .../2026-08-26_bcc9e97_f4878bf31645aee2.json | 12 +++++++++ .../2026-08-26_c385f05_cd6d6fd06187377a.json | 12 +++++++++ crates/vecq-core/src/store.rs | 4 ++- 7 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json create mode 100644 .cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json create mode 100644 .cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json create mode 100644 .cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json create mode 100644 .cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json diff --git a/.cora.yaml b/.cora.yaml index 48bc046..86c3998 100644 --- a/.cora.yaml +++ b/.cora.yaml @@ -1,2 +1,23 @@ -review: - enabled: true +# Cora Code Review Configuration +# See: https://github.com/codecoradev/cora-cli + +# Provider settings — use global config (~/.cora/config.yaml) + +# Review focus areas: security, performance, bugs, best-practice, style +focus: + - security + - performance + - bugs + +# Custom rules / additional instructions for the reviewer +rules: + - "Determinism guardrail: the scoring path must preserve bit-identical results across platforms (NEON vs scalar). Do not suggest changes that alter float accumulation order or enable FMA contraction." + - "vecq-core is intentionally zero-dependency. Do not suggest adding crates to the quantization path." + - "Scores are converted to u32 monotonic keys (bit-flip sign trick) for NaN-safe heap ordering — this is deliberate, not an unused-cast smell." + +# Ignore patterns +ignore: + files: + - "*.lock" + - "vendor/" + - "node_modules/" diff --git a/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json b/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json new file mode 100644 index 0000000..50b24b5 --- /dev/null +++ b/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T01:38:11.097120597Z", + "commit": "2e1c0a9", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json b/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json new file mode 100644 index 0000000..f5110df --- /dev/null +++ b/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T01:49:09.263337953Z", + "commit": "59a82f3", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json b/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json new file mode 100644 index 0000000..564fd64 --- /dev/null +++ b/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T01:54:05.149325300Z", + "commit": "9bdbe82", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json b/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json new file mode 100644 index 0000000..ac02fd8 --- /dev/null +++ b/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T01:45:22.515375456Z", + "commit": "bcc9e97", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json b/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json new file mode 100644 index 0000000..5b03977 --- /dev/null +++ b/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T01:58:32.239054096Z", + "commit": "c385f05", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/crates/vecq-core/src/store.rs b/crates/vecq-core/src/store.rs index 5262a20..23650d1 100644 --- a/crates/vecq-core/src/store.rs +++ b/crates/vecq-core/src/store.rs @@ -147,6 +147,7 @@ impl VecqIndex { let pq = self.prepare_query(q); let k = k.min(self.n).max(1); + #[cfg(target_arch = "aarch64")] let bpv = self.padded / 2; // f32 -> u32 monotonic key (NaN-safe, preserves total order): // flip all bits for negatives, flip sign bit for positives. @@ -168,6 +169,7 @@ impl VecqIndex { heap.pop(); } }; + #[cfg(target_arch = "aarch64")] let q_rot = &pq.rotated[..self.padded]; let mut idx = 0; #[cfg(target_arch = "aarch64")] @@ -539,6 +541,7 @@ mod tests { } } + #[cfg(target_arch = "aarch64")] #[test] fn neon4_matches_neon_bitwise() { let dim = 128; @@ -552,7 +555,6 @@ mod tests { for chunk_start in (0..12).step_by(4) { let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv]; let qslice = &pq.rotated[..idx.padded()]; - #[cfg(target_arch = "aarch64")] { let batched = unsafe { neon::score_neon4(codes4, qslice, &pq.lut) }; for v in 0..4 { From a66cc23104f1c3df08a881f39526938d22fa367d Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 26 Aug 2026 09:28:56 +0700 Subject: [PATCH 02/13] ci: add release workflow (crates.io publish via tag) (#6) * chore: align .cora.yaml with current cora schema (#4) (#5) * chore: align .cora.yaml with current cora schema The old file used `review.enabled`, which the current cora binary rejects (unknown field), silently disabling pre-commit review. Mirrors the uteke .cora.yaml structure with vecq-specific rules (determinism, zero-dependency core). * fix: gate NEON-only bindings behind cfg(aarch64) bpv and q_rot are only used on the NEON path; on x86_64 builds they were unused variables, failing clippy -D warnings in CI. * fix: restore bpv under cfg(aarch64), verify local clippy clean * fix: gate NEON test bindings behind cfg(aarch64) for x86_64 builds * fix: cfg-gate remaining NEON test bindings * fix: cfg-gate the NEON parity test at function level --------- Co-authored-by: ajianaz * ci: add release workflow (crates.io publish via tag, uteke pattern) --------- Co-authored-by: ajianaz --- .../2026-08-26_42f996e_b779b0deb582ae64.json | 12 +++ .github/workflows/release.yml | 98 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 .cora/history/2026-08-26_42f996e_b779b0deb582ae64.json create mode 100644 .github/workflows/release.yml diff --git a/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json b/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json new file mode 100644 index 0000000..9925dda --- /dev/null +++ b/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T02:05:12.113423910Z", + "commit": "42f996e", + "branch": "chore/cora-config", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8a3cef7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +# Release is triggered by pushing a vX.Y.Z tag. +# Prerequisite: merge develop → main via PR before tagging (tag must be on main). +# Publishes vecq-core to crates.io, then verifies via the crates.io API. + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + verify-main: + name: Verify tag is on main + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Check tag is on main + run: | + git fetch origin main + TAG_SHA=$(git rev-parse "${{ github.ref }}^{commit}") + MAIN_SHA=$(git rev-parse origin/main) + echo "Tag: $TAG_SHA" + echo "main: $MAIN_SHA" + if [ "$TAG_SHA" = "$MAIN_SHA" ]; then + echo "✅ Tag is main HEAD" + elif git merge-base --is-ancestor "$TAG_SHA" origin/main 2>/dev/null; then + echo "✅ Tag commit $TAG_SHA is in main history" + else + echo "::error::Tag commit $TAG_SHA is NOT on main." + echo "Merge develop → main via PR before tagging." + exit 1 + fi + + publish-crates: + name: Publish to crates.io + needs: [verify-main] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@stable + + - name: Publish vecq-core + run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p vecq-core + continue-on-error: true + + - name: Verify crates.io version + run: | + VERSION="${GITHUB_REF_NAME#v}" + fail=0 + for attempt in 1 2 3 4 5; do + MAX=$(curl -sf -A "vecq-release-verify" "https://crates.io/api/v1/crates/vecq-core" | jq -r '.crate.max_version') || MAX="" + [ "$MAX" = "$VERSION" ] && break + echo "attempt $attempt: vecq-core at $MAX (want $VERSION), waiting..." + sleep 30 + done + if [ "$MAX" = "$VERSION" ]; then + echo "✓ vecq-core $VERSION live on crates.io" + else + echo "::error::vecq-core not at $VERSION on crates.io (found: $MAX) — publish step failed silently" + fail=1 + fi + exit $fail + + release: + name: Create GitHub Release + needs: [publish-crates] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Extract changelog for this version + id: changelog + run: | + VER="${GITHUB_REF_NAME#v}" + awk "/^## \[${VER}\]/{found=1; next} /^## \[/{found=0} found" CHANGELOG.md > changelog-body.md + if [ ! -s changelog-body.md ]; then + echo "No changelog entry found for ${VER}." > changelog-body.md + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + body_path: changelog-body.md + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From d31f3630cd4d3a83857a5b1839520e21e667c3b6 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 26 Aug 2026 09:33:40 +0700 Subject: [PATCH 03/13] chore: bump version to 0.1.1 (#8) * chore: align .cora.yaml with current cora schema (#4) (#5) * chore: align .cora.yaml with current cora schema The old file used `review.enabled`, which the current cora binary rejects (unknown field), silently disabling pre-commit review. Mirrors the uteke .cora.yaml structure with vecq-specific rules (determinism, zero-dependency core). * fix: gate NEON-only bindings behind cfg(aarch64) bpv and q_rot are only used on the NEON path; on x86_64 builds they were unused variables, failing clippy -D warnings in CI. * fix: restore bpv under cfg(aarch64), verify local clippy clean * fix: gate NEON test bindings behind cfg(aarch64) for x86_64 builds * fix: cfg-gate remaining NEON test bindings * fix: cfg-gate the NEON parity test at function level --------- Co-authored-by: ajianaz * release: v0.1.1 crates.io publish pipeline (#7) * chore: align .cora.yaml with current cora schema (#4) * chore: align .cora.yaml with current cora schema The old file used `review.enabled`, which the current cora binary rejects (unknown field), silently disabling pre-commit review. Mirrors the uteke .cora.yaml structure with vecq-specific rules (determinism, zero-dependency core). * fix: gate NEON-only bindings behind cfg(aarch64) bpv and q_rot are only used on the NEON path; on x86_64 builds they were unused variables, failing clippy -D warnings in CI. * fix: restore bpv under cfg(aarch64), verify local clippy clean * fix: gate NEON test bindings behind cfg(aarch64) for x86_64 builds * fix: cfg-gate remaining NEON test bindings * fix: cfg-gate the NEON parity test at function level --------- Co-authored-by: ajianaz * ci: add release workflow (crates.io publish via tag) (#6) * chore: align .cora.yaml with current cora schema (#4) (#5) * chore: align .cora.yaml with current cora schema The old file used `review.enabled`, which the current cora binary rejects (unknown field), silently disabling pre-commit review. Mirrors the uteke .cora.yaml structure with vecq-specific rules (determinism, zero-dependency core). * fix: gate NEON-only bindings behind cfg(aarch64) bpv and q_rot are only used on the NEON path; on x86_64 builds they were unused variables, failing clippy -D warnings in CI. * fix: restore bpv under cfg(aarch64), verify local clippy clean * fix: gate NEON test bindings behind cfg(aarch64) for x86_64 builds * fix: cfg-gate remaining NEON test bindings * fix: cfg-gate the NEON parity test at function level --------- Co-authored-by: ajianaz * ci: add release workflow (crates.io publish via tag, uteke pattern) --------- Co-authored-by: ajianaz --------- Co-authored-by: ajianaz * chore: bump version to 0.1.1 for first crates.io release --------- Co-authored-by: ajianaz --- .../history/2026-08-26_f8d934c_737627a575ca69aa.json | 12 ++++++++++++ CHANGELOG.md | 5 +++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .cora/history/2026-08-26_f8d934c_737627a575ca69aa.json diff --git a/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json b/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json new file mode 100644 index 0000000..c874841 --- /dev/null +++ b/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json @@ -0,0 +1,12 @@ +{ + "timestamp": "2026-08-26T02:24:28.397836206Z", + "commit": "f8d934c", + "branch": "chore/release-workflow", + "files_reviewed": 0, + "lines_reviewed": null, + "findings": {}, + "categories": {}, + "quality_score": 10.0, + "gate_status": "disabled", + "duration_ms": null +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6d0c1..bb1d798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.1.1] — 2026-08-26 + +### Added +- Release pipeline: push a `vX.Y.Z` tag on `main` to publish `vecq-core` to crates.io and create the GitHub Release (adapted from the uteke release workflow). + ## [0.1.0] — 2026-08-26 First public release. Training-free 4-bit vector quantization and search. diff --git a/Cargo.lock b/Cargo.lock index a4163b4..94988c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,7 +353,7 @@ dependencies = [ [[package]] name = "vecq-bench" -version = "0.1.0" +version = "0.1.1" dependencies = [ "usearch", "vecq-core", @@ -361,7 +361,7 @@ dependencies = [ [[package]] name = "vecq-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "rand", ] diff --git a/Cargo.toml b/Cargo.toml index 1c3a1e3..44867ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/vecq-core", "crates/vecq-bench"] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "Apache-2.0" repository = "https://github.com/codecoradev/vecq" From 1a68582f36f4a2bb57833b213230f931ebd7ff42 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 11:08:39 +0700 Subject: [PATCH 04/13] ci: add cora AI code review workflow (parity with uteke) (#13) * ci: add cora AI code review workflow (parity with uteke) Runs codecoradev/cora-review-action@v1 on every PR targeting develop or main, posting AI review feedback using org CORA_* secrets, matching the uteke repo reference workflow. Signed-off-by: ajianaz * chore: retrigger CI to verify cora review secrets Signed-off-by: ajianaz --------- Signed-off-by: ajianaz Co-authored-by: ajianaz --- .github/workflows/cora-review.yml | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/cora-review.yml diff --git a/.github/workflows/cora-review.yml b/.github/workflows/cora-review.yml new file mode 100644 index 0000000..d887ebe --- /dev/null +++ b/.github/workflows/cora-review.yml @@ -0,0 +1,35 @@ +name: Cora AI Code Review + +on: + pull_request: + branches: [develop, main] + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: cora-review-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + cora-review: + name: Cora Review + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout PR head + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Cora AI Code Review + uses: codecoradev/cora-review-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cora-api-key: ${{ secrets.CORA_API_KEY }} + cora-base-url: ${{ secrets.CORA_BASE_URL }} + cora-model: ${{ secrets.CORA_MODEL }} From 71f73d07ab9e64c4fdd729e75ba4090ebcaf942e Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 11:21:29 +0700 Subject: [PATCH 05/13] fix(ci): correct CLA bot exemption to match actual bot logins (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dependabot PR authors have user.login `dependabot[bot]`, not `app/dependabot`, so the old skip list never matched and the CLA job failed every dependabot PR. Switch to an exact allowlist of trusted bot logins (dependabot/renovate/github-actions); the legacy app/* forms stay as fallbacks. A substring [bot] match was rejected in review — it would exempt arbitrary third-party GitHub Apps. Signed-off-by: ajianaz Co-authored-by: ajianaz --- .github/workflows/cla-check.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cla-check.yml b/.github/workflows/cla-check.yml index 98820d7..0949f7b 100644 --- a/.github/workflows/cla-check.yml +++ b/.github/workflows/cla-check.yml @@ -12,7 +12,12 @@ permissions: jobs: cla-check: runs-on: ubuntu-latest - if: "!contains(fromJSON('[\"app/dependabot\", \"app/renovate\", \"github-actions[bot]\"]'), github.event.pull_request.user.login)" + # Bot PRs are exempt from the CLA — exact allowlist only (a `[bot]` + # substring match would exempt any third-party GitHub App and turn the + # CLA gate into an opt-out). `user.login` for app-authored PRs is the + # bot account (e.g. `dependabot[bot]`); `app/dependabot` / `app/renovate` + # are kept as legacy fallbacks. + if: "!contains(fromJSON('[\"dependabot[bot]\", \"renovate[bot]\", \"github-actions[bot]\", \"app/dependabot\", \"app/renovate\"]'), github.event.pull_request.user.login)" steps: - name: Fetch & check CLA signature id: check From ea687744e1ebffd3db8a0933cc371d8e11bc582f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:30:29 +0700 Subject: [PATCH 06/13] chore(deps): bump rand from 0.8.8 to 0.10.2 (#2) Bumps [rand](https://github.com/rust-random/rand) from 0.8.8 to 0.10.2. - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/rand/compare/0.8.8...0.10.2) --- updated-dependencies: - dependency-name: rand dependency-version: 0.10.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 108 +++++++++++++++++++---------------------------------- Cargo.toml | 2 +- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94988c0..1dd8ec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.6.6" @@ -61,6 +72,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "cxx" version = "1.0.199" @@ -88,7 +108,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn 3.0.4", + "syn", ] [[package]] @@ -102,7 +122,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -120,7 +140,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -143,13 +163,14 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "getrandom" -version = "0.2.17" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "wasi", + "r-efi", + "rand_core", ] [[package]] @@ -192,15 +213,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -220,34 +232,27 @@ dependencies = [ ] [[package]] -name = "rand" -version = "0.8.8" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", + "chacha20", + "getrandom", "rand_core", ] [[package]] name = "rand_core" -version = "0.6.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "scratch" @@ -282,7 +287,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn", ] [[package]] @@ -297,17 +302,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "3.0.4" @@ -366,12 +360,6 @@ dependencies = [ "rand", ] -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "winapi-util" version = "0.1.11" @@ -395,23 +383,3 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/Cargo.toml b/Cargo.toml index 44867ff..420bdb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ categories = ["algorithms", "data-structures", "science"] authors = ["CodeCora "] [workspace.dependencies] -rand = "0.8" +rand = "0.10" [profile.release] debug = false From 6531476f39cc3d8add4ed52c42235c66b419e03f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:32:52 +0700 Subject: [PATCH 07/13] chore(deps): bump actions/github-script from 7 to 9 (#3) Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cla-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cla-check.yml b/.github/workflows/cla-check.yml index 0949f7b..a88fb67 100644 --- a/.github/workflows/cla-check.yml +++ b/.github/workflows/cla-check.yml @@ -53,7 +53,7 @@ jobs: - name: Comment on PR (unsigned only) if: steps.check.outputs.signed != 'true' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const author = '${{ github.event.pull_request.user.login }}'; @@ -102,7 +102,7 @@ jobs: } - name: Set commit status - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const signed = '${{ steps.check.outputs.signed }}' === 'true'; From 05898641def0b5200efc1acfa8b59acb91484187 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 11:39:43 +0700 Subject: [PATCH 08/13] chore(cora): raise review llm max_tokens to 16384 (#15) * chore(cora): raise review llm max_tokens to 16384 glm-5.3 is a reasoning model; on dependency-lockfile diffs the default 4096-token budget is exhausted during reasoning (finish_reason=length, content=null), which made the CI cora-review fallback SARIF fire on PRs #2/#3. 16384 leaves room for reasoning plus the verdict. Signed-off-by: ajianaz * chore: retrigger CI Signed-off-by: ajianaz --------- Signed-off-by: ajianaz Co-authored-by: ajianaz --- .cora.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cora.yaml b/.cora.yaml index 86c3998..691596f 100644 --- a/.cora.yaml +++ b/.cora.yaml @@ -21,3 +21,6 @@ ignore: - "*.lock" - "vendor/" - "node_modules/" + +llm: + max_tokens: 16384 From 4e2122603299dede34711ad19097eb84c7ffaaab Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 11:53:34 +0700 Subject: [PATCH 09/13] =?UTF-8?q?feat(core):=20keyed=20API=20=E2=80=94=20a?= =?UTF-8?q?dd=5Fkeyed/remove=5Fkeyed/tombstones=20(#10)=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the keyed layer requested for incremental integrators (issue #10, unblocks the cora-code adoption audit): - add_keyed(key, v): insert-or-replace under a stable u64 key (usearch-style), returns the slot index - remove_keyed(key) -> bool: tombstones the slot; storage stays in place so slot indices remain stable until compact() - search_keyed(q, k): top-k by key, tombstones skipped - compact(): rebuilds in place, dropping tombstones (keys preserved, slot indices shift) - key_of/contains_key/slots/tombstones introspection helpers - len()/is_empty() now report live vectors - to_bytes() writes live slots only: tombstones are dropped on disk without disturbing in-memory slot indices. File format unchanged (v1.1); keys are an in-memory concern — persist key->slot mappings in your own metadata store. NEON4 batch scoring is untouched: tombstoned slots are still scored to keep batches dense, then filtered before entering the top-k heap, preserving bit-identical results. TDD: 7 new tests cover keyed search, replace, stability across remove/serialize, compaction, drained indexes, keyed adds after a file load, and dim-mismatch panics. Signed-off-by: ajianaz Co-authored-by: ajianaz --- .../2026-08-26_2e1c0a9_7d4499250c16b5fa.json | 12 - .../2026-08-26_42f996e_b779b0deb582ae64.json | 12 - .../2026-08-26_59a82f3_a4268fd072a3e331.json | 12 - .../2026-08-26_9bdbe82_837357f4ff2d20d0.json | 12 - .../2026-08-26_bcc9e97_f4878bf31645aee2.json | 12 - .../2026-08-26_c385f05_cd6d6fd06187377a.json | 12 - .../2026-08-26_f8d934c_737627a575ca69aa.json | 12 - .gitignore | 1 + README.md | 6 + crates/vecq-core/src/format.rs | 31 +- crates/vecq-core/src/store.rs | 361 +++++++++++++++++- 11 files changed, 375 insertions(+), 108 deletions(-) delete mode 100644 .cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json delete mode 100644 .cora/history/2026-08-26_42f996e_b779b0deb582ae64.json delete mode 100644 .cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json delete mode 100644 .cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json delete mode 100644 .cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json delete mode 100644 .cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json delete mode 100644 .cora/history/2026-08-26_f8d934c_737627a575ca69aa.json diff --git a/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json b/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json deleted file mode 100644 index 50b24b5..0000000 --- a/.cora/history/2026-08-26_2e1c0a9_7d4499250c16b5fa.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T01:38:11.097120597Z", - "commit": "2e1c0a9", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json b/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json deleted file mode 100644 index 9925dda..0000000 --- a/.cora/history/2026-08-26_42f996e_b779b0deb582ae64.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T02:05:12.113423910Z", - "commit": "42f996e", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json b/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json deleted file mode 100644 index f5110df..0000000 --- a/.cora/history/2026-08-26_59a82f3_a4268fd072a3e331.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T01:49:09.263337953Z", - "commit": "59a82f3", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json b/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json deleted file mode 100644 index 564fd64..0000000 --- a/.cora/history/2026-08-26_9bdbe82_837357f4ff2d20d0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T01:54:05.149325300Z", - "commit": "9bdbe82", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json b/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json deleted file mode 100644 index ac02fd8..0000000 --- a/.cora/history/2026-08-26_bcc9e97_f4878bf31645aee2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T01:45:22.515375456Z", - "commit": "bcc9e97", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json b/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json deleted file mode 100644 index 5b03977..0000000 --- a/.cora/history/2026-08-26_c385f05_cd6d6fd06187377a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T01:58:32.239054096Z", - "commit": "c385f05", - "branch": "chore/cora-config", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json b/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json deleted file mode 100644 index c874841..0000000 --- a/.cora/history/2026-08-26_f8d934c_737627a575ca69aa.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "timestamp": "2026-08-26T02:24:28.397836206Z", - "commit": "f8d934c", - "branch": "chore/release-workflow", - "files_reviewed": 0, - "lines_reviewed": null, - "findings": {}, - "categories": {}, - "quality_score": 10.0, - "gate_status": "disabled", - "duration_ms": null -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 407892e..8efba1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ Cargo.lock.bak +.cora/ diff --git a/README.md b/README.md index b7395c6..5afa050 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,13 @@ for v in &vectors { index.add(v); } let hits: Vec<(usize, f32)> = index.search(&query, 10); +// Keyed layer: stable u64 identity + removal for incremental workloads. +index.add_keyed(1001, &doc_vec); +index.remove_keyed(1001); // tombstone; `compact()` reclaims the slot +let keyed_hits: Vec<(u64, f32)> = index.search_keyed(&query, 10); + // Single-file persistence, deterministic across platforms. +// Tombstones are dropped on disk; keys live in your own metadata table. let bytes = index.to_bytes(); let back = VecqIndex::from_bytes(&bytes).unwrap(); assert_eq!(index.search(&query, 10), back.search(&query, 10)); diff --git a/crates/vecq-core/src/format.rs b/crates/vecq-core/src/format.rs index 6616414..9252927 100644 --- a/crates/vecq-core/src/format.rs +++ b/crates/vecq-core/src/format.rs @@ -116,18 +116,34 @@ fn f16_bits_to_f32(h: u16) -> f32 { impl VecqIndex { /// Serialize the index to bytes (format version 1.1, f16 scales). + /// + /// Tombstoned slots are skipped: the output always holds the live vectors + /// in slot order, so a round-trip through bytes has the same effect as + /// [`VecqIndex::compact`] on disk without disturbing in-memory slot + /// indices. Keys are not part of the file format; persist a key→slot + /// table alongside (e.g. in SQLite) if you need keyed access across a + /// reload. pub fn to_bytes(&self) -> Vec { - let mut out = Vec::with_capacity(24 + self.codes.len() + self.scales.len() * 2); + let bpv = self.padded_dim() / 2; + let mut out = Vec::with_capacity(24 + self.live_slots() * bpv + self.live_slots() * 2); out.extend_from_slice(&MAGIC.to_le_bytes()); out.extend_from_slice(&V1_1.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); - out.extend_from_slice(&(self.dim as u32).to_le_bytes()); - out.extend_from_slice(&self.seed.to_le_bytes()); - out.extend_from_slice(&(self.n as u32).to_le_bytes()); - for s in &self.scales { - out.extend_from_slice(&f32_to_f16_bits(*s).to_le_bytes()); + out.extend_from_slice(&(self.dim() as u32).to_le_bytes()); + out.extend_from_slice(&self.seed().to_le_bytes()); + out.extend_from_slice(&(self.len() as u32).to_le_bytes()); + for slot in 0..self.slots() { + if !self.slot_alive(slot) { + continue; + } + out.extend_from_slice(&f32_to_f16_bits(self.slot_scale(slot)).to_le_bytes()); + } + for slot in 0..self.slots() { + if !self.slot_alive(slot) { + continue; + } + out.extend_from_slice(self.slot_codes(slot, bpv)); } - out.extend_from_slice(&self.codes); out } @@ -172,6 +188,7 @@ impl VecqIndex { index.codes = bytes[off..off + count * codes_bytes].to_vec(); index.scales = scales; index.n = count; + index.init_dense(count); Ok(index) } } diff --git a/crates/vecq-core/src/store.rs b/crates/vecq-core/src/store.rs index 23650d1..93268f3 100644 --- a/crates/vecq-core/src/store.rs +++ b/crates/vecq-core/src/store.rs @@ -3,6 +3,7 @@ use crate::lloyd; use crate::rhdh::{padded_dim, Rhdh}; +use std::collections::HashMap; /// A quantized vector database in memory. /// @@ -10,6 +11,14 @@ use crate::rhdh::{padded_dim, Rhdh}; /// (computed after RHDH rotation) plus one f32 correction factor. The score /// against an f32 query is an unbiased estimate of the cosine similarity /// after undoing the per-vector quantization scale. +/// +/// Vectors can be stored anonymously via [`VecqIndex::add`] or under a +/// caller-chosen `u64` key via [`VecqIndex::add_keyed`]. Keyed vectors can be +/// removed in place (tombstoned); tombstoned slots keep their storage but are +/// skipped by searches and dropped by [`VecqIndex::compact`] and by +/// [`VecqIndex::to_bytes`](crate::format). Slot indices stay stable until a +/// compaction, so integrators can treat a slot as a transient handle while +/// keys are the durable identity. pub struct VecqIndex { pub(crate) dim: usize, padded: usize, @@ -17,7 +26,11 @@ pub struct VecqIndex { transform: Rhdh, pub(crate) codes: Vec, // n * padded/2 nibbles, low nibble = dim i*2 pub(crate) scales: Vec, // per-vector dequantization scale - pub(crate) n: usize, + pub(crate) n: usize, // total slots in use (live + tombstoned) + keys: Vec>, // slot -> caller key (keyed slots only) + key_to_slot: HashMap, + alive: Vec, // slot -> not tombstoned + live: usize, // number of non-tombstoned slots } impl VecqIndex { @@ -33,15 +46,31 @@ impl VecqIndex { codes: Vec::new(), scales: Vec::new(), n: 0, + keys: Vec::new(), + key_to_slot: HashMap::new(), + alive: Vec::new(), + live: 0, } } + /// Number of live (searchable) vectors. pub fn len(&self) -> usize { - self.n + self.live } pub fn is_empty(&self) -> bool { - self.n == 0 + self.live == 0 + } + + /// Total slots in use, including tombstoned ones + /// (`slots() == len() + tombstones()`). + pub fn slots(&self) -> usize { + self.n + } + + /// Number of tombstoned slots awaiting [`VecqIndex::compact`]. + pub fn tombstones(&self) -> usize { + self.n - self.live } pub fn dim(&self) -> usize { @@ -57,8 +86,134 @@ impl VecqIndex { self.padded } + // -- crate-internal accessors used by the persistence format --------- + + pub(crate) fn padded_dim(&self) -> usize { + self.padded + } + + pub(crate) fn live_slots(&self) -> usize { + self.live + } + + pub(crate) fn slot_alive(&self, slot: usize) -> bool { + self.alive[slot] + } + + pub(crate) fn slot_scale(&self, slot: usize) -> f32 { + self.scales[slot] + } + + pub(crate) fn slot_codes(&self, slot: usize, bpv: usize) -> &[u8] { + &self.codes[slot * bpv..(slot + 1) * bpv] + } + + /// Mark the index as holding `count` dense (all-live, keyless) slots; + /// used after loading from the file format. + pub(crate) fn init_dense(&mut self, count: usize) { + self.keys = vec![None; count]; + self.alive = vec![true; count]; + self.key_to_slot.clear(); + self.live = count; + } + /// Quantize and add one vector (any norm; normalized internally). - pub fn add(&mut self, v: &[f32]) { + /// + /// Returns the slot index holding the vector (stable until compaction). + pub fn add(&mut self, v: &[f32]) -> usize { + self.append_slot(v, None) + } + + /// Quantize and add one vector under a caller-chosen `u64` key. + /// + /// If `key` already exists, the vector is replaced in place (the slot + /// index is preserved, matching usearch's insert semantics). Otherwise a + /// new slot is appended. Returns the slot index holding the vector. + pub fn add_keyed(&mut self, key: u64, v: &[f32]) -> usize { + if let Some(&slot) = self.key_to_slot.get(&key) { + let scale = self.encode_into(slot * (self.padded / 2), v); + self.scales[slot] = scale; + return slot; + } + self.append_slot(v, Some(key)) + } + + /// Remove a keyed vector. The slot becomes a tombstone: its storage is + /// kept (slot indices stay stable) but searches skip it until + /// [`VecqIndex::compact`]. Returns `false` if the key is unknown. + pub fn remove_keyed(&mut self, key: u64) -> bool { + match self.key_to_slot.remove(&key) { + Some(slot) => { + self.alive[slot] = false; + self.keys[slot] = None; + self.live -= 1; + true + } + None => false, + } + } + + /// Look up the key stored at `slot` (`None` for anonymous slots, + /// tombstones, or out-of-range indices). + pub fn key_of(&self, slot: usize) -> Option { + self.keys.get(slot).copied().flatten() + } + + /// Whether `key` currently identifies a live vector. + pub fn contains_key(&self, key: u64) -> bool { + self.key_to_slot.contains_key(&key) + } + + /// Rebuild the index in place, dropping tombstoned slots. + /// + /// All remaining vectors keep their keys; **slot indices shift** to become + /// dense (0..len). Search results are unchanged. + pub fn compact(&mut self) { + if self.live == self.n { + return; + } + let bpv = self.padded / 2; + let mut codes = Vec::with_capacity(self.live * bpv); + let mut scales = Vec::with_capacity(self.live); + let mut keys = Vec::with_capacity(self.live); + let mut alive = Vec::with_capacity(self.live); + self.key_to_slot.clear(); + for slot in 0..self.n { + if self.alive[slot] { + codes.extend_from_slice(&self.codes[slot * bpv..(slot + 1) * bpv]); + scales.push(self.scales[slot]); + if let Some(key) = self.keys[slot] { + self.key_to_slot.insert(key, keys.len()); + } + keys.push(self.keys[slot]); + alive.push(true); + } + } + self.codes = codes; + self.scales = scales; + self.keys = keys; + self.alive = alive; + self.n = self.live; + } + + /// Append one vector as a new slot; returns the slot index. + fn append_slot(&mut self, v: &[f32], key: Option) -> usize { + let slot = self.n; + let scale = self.encode_into(slot * (self.padded / 2), v); + self.scales.push(scale); + self.keys.push(key); + if let Some(key) = key { + self.key_to_slot.insert(key, slot); + } + self.alive.push(true); + self.n += 1; + self.live += 1; + slot + } + + /// Quantize `v` into the code bytes starting at `base` (extending + /// `codes` when appending); returns the unit-norm correction scale. + fn encode_into(&mut self, base: usize, v: &[f32]) -> f32 { assert_eq!(v.len(), self.dim, "vector dim mismatch"); let norm: f32 = v.iter().map(|x| x * x).sum::().sqrt(); assert!(norm > 0.0, "zero vector"); @@ -69,24 +224,25 @@ impl VecqIndex { // Quantize to 4-bit codes, nibble-packed. let bytes_per_vec = self.padded / 2; - let base = self.codes.len(); - self.codes.resize(base + bytes_per_vec, 0); + if self.codes.len() < base + bytes_per_vec { + self.codes.resize(base + bytes_per_vec, 0); + } let mut sum_sq = 0f32; for (i, &x) in rotated.iter().enumerate() { let code = lloyd::quantize_4bit(x); let b = base + i / 2; - if i % 2 == 0 { - self.codes[b] |= code; + let byte = if i % 2 == 0 { + (self.codes[b] & 0xF0) | code } else { - self.codes[b] |= code << 4; - } + (self.codes[b] & 0x0F) | (code << 4) + }; + self.codes[b] = byte; sum_sq += lloyd::dequantize_4bit(code).powi(2); } // Scale so that the stored vector is unit-norm: dequantized vector q // has norm sqrt(sum_sq); asymmetric scoring multiplies by 1/sqrt(sum_sq). - self.scales.push(1.0 / sum_sq.sqrt()); - self.n += 1; + 1.0 / sum_sq.sqrt() } /// Prepare an f32 query in rotated space (call once per query). @@ -136,17 +292,31 @@ impl VecqIndex { } } - /// Brute-force top-k search. Returns (index, score) sorted by score desc. + /// Brute-force top-k search. Returns (slot index, score) sorted by score + /// desc. Tombstoned slots are skipped. /// /// Uses a bounded min-heap of size k (no O(n log n) sort, no O(n) /// allocation per query): push while the heap is not full, then only /// push-and-pop when the candidate beats the current k-th score. pub fn search(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> { + self.search_slots(q, k) + } + + /// Keyed variant of [`VecqIndex::search`]: returns (key, score) sorted by + /// score desc, restricted to live keyed vectors. + pub fn search_keyed(&self, q: &[f32], k: usize) -> Vec<(u64, f32)> { + self.search_slots(q, k) + .into_iter() + .filter_map(|(slot, s)| self.key_of(slot).map(|key| (key, s))) + .collect() + } + + fn search_slots(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> { use std::cmp::Reverse; use std::collections::BinaryHeap; let pq = self.prepare_query(q); - let k = k.min(self.n).max(1); + let k = k.min(self.live).max(1); #[cfg(target_arch = "aarch64")] let bpv = self.padded / 2; // f32 -> u32 monotonic key (NaN-safe, preserves total order): @@ -174,18 +344,25 @@ impl VecqIndex { let mut idx = 0; #[cfg(target_arch = "aarch64")] { - // Batch 4 vectors per pass: shared q loads + LUT setup. + // Batch 4 vectors per pass: shared q loads + LUT setup. Tombstoned + // slots are still scored (keeping the batch dense) but filtered + // before entering the heap. while idx + 4 <= self.n { let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv]; let raw = unsafe { neon::score_neon4(codes4, q_rot, &pq.lut) }; for (v, &r) in raw.iter().enumerate() { - consider(r * self.scales[idx + v], idx + v, &mut heap); + let si = idx + v; + if self.alive[si] { + consider(r * self.scales[si], si, &mut heap); + } } idx += 4; } } while idx < self.n { - consider(self.score(&pq, idx), idx, &mut heap); + if self.alive[idx] { + consider(self.score(&pq, idx), idx, &mut heap); + } idx += 1; } // Inverse of `key`: undo the sign flip to recover the exact f32 bits. @@ -593,4 +770,154 @@ mod tests { idx.add(&rand_unit(dim, 11)); assert_eq!(idx.codes.len(), 512 / 2); } + + #[test] + fn keyed_add_search_remove() { + let dim = 64; + let mut idx = VecqIndex::new(dim, 5); + for i in 0..50u64 { + idx.add_keyed(1000 + i, &rand_unit(dim, i * 17 + 3)); + } + assert_eq!(idx.len(), 50); + assert!(idx.contains_key(1000)); + assert!(!idx.contains_key(999)); + + let q = rand_unit(dim, 77); + let keyed = idx.search_keyed(&q, 5); + assert_eq!(keyed.len(), 5); + for w in keyed.windows(2) { + assert!(w[0].1 >= w[1].1); + } + // Keys from search_keyed must all exist and match positional results. + let positional = idx.search(&q, 5); + for ((key, ks), (slot, ps)) in keyed.iter().zip(positional.iter()) { + assert_eq!(key, &idx.key_of(*slot).unwrap()); + assert_eq!(ks.to_bits(), ps.to_bits()); + } + + // Remove the top hit: it must vanish from results, others keep scores. + let top_key = keyed[0].0; + assert!(idx.remove_keyed(top_key)); + assert!(!idx.remove_keyed(top_key), "second remove is a no-op"); + assert!(!idx.remove_keyed(12345), "unknown key returns false"); + assert_eq!(idx.len(), 49); + assert_eq!(idx.tombstones(), 1); + let keyed2 = idx.search_keyed(&q, 5); + assert!(!keyed2.iter().any(|(k, _)| *k == top_key)); + for (k, s) in keyed2.iter() { + let old = keyed.iter().find(|(ok, _)| ok == k).map(|(_, os)| *os); + if let Some(os) = old { + assert_eq!(s.to_bits(), os.to_bits(), "key {k} score changed"); + } + } + } + + #[test] + fn keyed_add_same_key_replaces() { + let dim = 32; + let mut idx = VecqIndex::new(dim, 9); + idx.add_keyed(7, &rand_unit(dim, 101)); + idx.add_keyed(7, &rand_unit(dim, 202)); + assert_eq!(idx.len(), 1, "replace must not grow the index"); + assert_eq!(idx.tombstones(), 0); + // The stored vector is the second one: query near it, key 7 wins. + let q = rand_unit(dim, 202); + let res = idx.search_keyed(&q, 1); + assert_eq!(res[0].0, 7); + } + + #[test] + fn keyed_slot_indices_stay_stable_across_remove_and_serialize() { + let dim = 64; + let mut idx = VecqIndex::new(dim, 15); + for i in 0..20u64 { + idx.add_keyed(i, &rand_unit(dim, i + 300)); + } + let q = rand_unit(dim, 404); + let before = idx.search(&q, 20); + // Remove two vectors: remaining slot indices must not shift. + idx.remove_keyed(idx.key_of(before[0].0).unwrap()); + idx.remove_keyed(idx.key_of(before[5].0).unwrap()); + let after = idx.search(&q, 20); + assert_eq!(after.len(), 18); + for (slot, s) in &after { + let old = before.iter().find(|(os, _)| os == slot); + assert!(old.is_some(), "slot {slot} moved after remove"); + assert_eq!(old.unwrap().1.to_bits(), s.to_bits()); + } + // Serializing drops tombstones on disk but must not disturb memory. + let bytes = idx.to_bytes(); + let disk = VecqIndex::from_bytes(&bytes).unwrap(); + assert_eq!(disk.len(), 18); + assert_eq!(idx.search(&q, 20), after, "in-memory results unchanged"); + } + + #[test] + fn compact_drops_tombstones_and_preserves_results() { + let dim = 64; + let mut idx = VecqIndex::new(dim, 21); + for i in 0..40u64 { + idx.add_keyed(10 * i, &rand_unit(dim, i + 61)); + } + for i in 0..20u64 { + assert!(idx.remove_keyed(10 * i)); + } + let q = rand_unit(dim, 123); + let expected = idx.search_keyed(&q, 20); + idx.compact(); + assert_eq!(idx.tombstones(), 0); + assert_eq!(idx.len(), 20); + assert_eq!(idx.search_keyed(&q, 20), expected); + // Round-trip after compact: keys are not persisted by design, so the + // reloaded index is searchable positionally. f16 scales perturb + // scores by <1e-3, so compare order and approximate scores. + let bytes = idx.to_bytes(); + let back = VecqIndex::from_bytes(&bytes).unwrap(); + let reloaded = back.search(&q, 20); + assert_eq!(reloaded.len(), 20); + for ((slot, s), (key, ks)) in reloaded.iter().zip(expected.iter()) { + assert_eq!(idx.key_of(*slot), Some(*key)); + assert!( + (s - ks).abs() < 1e-3, + "key {key} score drifted: {s} vs {ks}" + ); + } + } + + #[test] + fn keyed_search_on_empty_and_drained_index() { + let dim = 32; + let mut idx = VecqIndex::new(dim, 31); + assert!(idx.search_keyed(&rand_unit(dim, 1), 3).is_empty()); + idx.add_keyed(1, &rand_unit(dim, 2)); + idx.add_keyed(2, &rand_unit(dim, 3)); + assert!(idx.remove_keyed(1)); + assert!(idx.remove_keyed(2)); + assert!(idx.is_empty(), "drained index reports empty"); + assert_eq!(idx.tombstones(), 2); + assert!(idx.search_keyed(&rand_unit(dim, 4), 3).is_empty()); + } + + #[test] + fn keyed_index_from_file_supports_keyed_adds() { + let dim = 64; + let mut idx = VecqIndex::new(dim, 41); + for i in 0..10u64 { + idx.add(&rand_unit(dim, i + 700)); + } + let bytes = idx.to_bytes(); + let mut back = VecqIndex::from_bytes(&bytes).unwrap(); + back.add_keyed(555, &rand_unit(dim, 999)); + assert!(back.contains_key(555)); + assert_eq!(back.len(), 11); + let q = rand_unit(dim, 999); + assert_eq!(back.search_keyed(&q, 1)[0].0, 555); + } + + #[test] + #[should_panic(expected = "vector dim mismatch")] + fn keyed_add_dim_mismatch_panics() { + let mut idx = VecqIndex::new(32, 3); + idx.add_keyed(1, &[0.5; 64]); + } } From 8128e3d83d015c47e39986c28b3aec959fb0582b Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 12:17:34 +0700 Subject: [PATCH 10/13] feat(core): x86_64 AVX2 scoring path (parity with NEON, bit-identity tested) (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): x86_64 AVX2 scoring path, bit-identical to scalar (#11) Adds an explicit AVX2 nibble-gather scoring path mirroring the NEON one, selected at runtime with is_x86_feature_detected! (AVX2 is not baseline on x86_64): - score_avx2: 8 lanes = 8 scalar buckets; per 8-byte block, lane j computes q[2b]*lut[lo_b] + q[2b+1]*lut[hi_b] (mul, mul, add — no FMA) and accumulates in block order, then the same pairwise-tree reduction — structurally identical to the scalar loop, so results are bit-identical - score_avx24: 4-vector batch sharing the q deinterleave - LUT gather via vgatherdps (scale 4, nibble indices); q even/odd deinterleave via shuffle_ps + vpermps fixup - search() dispatches to the AVX2 batch when available; score() dispatches per call Bit-identity is enforced by new tests (avx2_matches_scalar_bitwise, avx24_matches_avx2_bitwise, search dispatch == scalar) including a dim-8 case covering the 4-byte tail. The x86_64 CI job runs these on native AVX2 hardware (Rosetta hides AVX2 via CPUID, so local emulated runs cannot exercise the path). docs/BENCHMARK.md: per-architecture scoring-path table; x86_64 numbers explicitly pending native measurement, Rosetta excluded. Signed-off-by: ajianaz * fix(clippy): iterate acc via enumerate in score_avx24 batch loop clippy 1.98 (CI stable) flags the 0..4 indexing loop under needless_range_loop; local 1.89 does not. Same semantics. Signed-off-by: ajianaz --------- Signed-off-by: ajianaz Co-authored-by: ajianaz --- crates/vecq-core/src/store.rs | 262 +++++++++++++++++++++++++++++++++- docs/BENCHMARK.md | 26 +++- 2 files changed, 279 insertions(+), 9 deletions(-) diff --git a/crates/vecq-core/src/store.rs b/crates/vecq-core/src/store.rs index 93268f3..e8ec602 100644 --- a/crates/vecq-core/src/store.rs +++ b/crates/vecq-core/src/store.rs @@ -270,11 +270,13 @@ impl VecqIndex { /// Asymmetric score of vector `idx` against a prepared query. /// Returns estimated cosine similarity in [-1, 1]. /// - /// Dispatches to the explicit NEON path on aarch64 and the fixed - /// 8-bucket scalar path elsewhere. Both use the identical association - /// order (per code byte: mul, mul, add, then add into bucket j; final - /// pairwise tree), so they produce the same f32 bits — guarded by - /// `neon_matches_scalar_bitwise` in tests. + /// Dispatches to the explicit NEON path on aarch64, the explicit AVX2 + /// path on x86_64 when the host supports it (runtime detection), and the + /// fixed 8-bucket scalar path otherwise. All use the identical + /// association order (per code byte: mul, mul, add, then add into bucket + /// j; final pairwise tree), so they produce the same f32 bits — guarded + /// by `neon_matches_scalar_bitwise` / `avx2_matches_scalar_bitwise` in + /// tests. #[inline] pub fn score(&self, pq: &PreparedQuery, idx: usize) -> f32 { let base = idx * (self.padded / 2); @@ -286,7 +288,17 @@ impl VecqIndex { let raw = unsafe { neon::score_neon(codes, q, &pq.lut) }; raw * self.scales[idx] } - #[cfg(not(target_arch = "aarch64"))] + #[cfg(target_arch = "x86_64")] + { + if avx2::available() { + // SAFETY: feature availability checked immediately above. + let raw = unsafe { avx2::score_avx2(codes, q, &pq.lut) }; + raw * self.scales[idx] + } else { + score_scalar(codes, q, &pq.lut) * self.scales[idx] + } + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] { score_scalar(codes, q, &pq.lut) * self.scales[idx] } @@ -317,7 +329,6 @@ impl VecqIndex { let pq = self.prepare_query(q); let k = k.min(self.live).max(1); - #[cfg(target_arch = "aarch64")] let bpv = self.padded / 2; // f32 -> u32 monotonic key (NaN-safe, preserves total order): // flip all bits for negatives, flip sign bit for positives. @@ -341,6 +352,8 @@ impl VecqIndex { }; #[cfg(target_arch = "aarch64")] let q_rot = &pq.rotated[..self.padded]; + #[cfg(target_arch = "x86_64")] + let use_avx2 = avx2::available(); let mut idx = 0; #[cfg(target_arch = "aarch64")] { @@ -359,6 +372,27 @@ impl VecqIndex { idx += 4; } } + #[cfg(target_arch = "x86_64")] + { + if use_avx2 { + // Batch 4 vectors per pass: shared q deinterleave. Tombstoned + // slots are still scored (keeping the batch dense) but + // filtered before entering the heap. + while idx + 4 <= self.n { + let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv]; + // SAFETY: AVX2 availability checked via `use_avx2`. + let raw = + unsafe { avx2::score_avx24(codes4, &pq.rotated[..self.padded], &pq.lut) }; + for (v, &r) in raw.iter().enumerate() { + let si = idx + v; + if self.alive[si] { + consider(r * self.scales[si], si, &mut heap); + } + } + idx += 4; + } + } + } while idx < self.n { if self.alive[idx] { consider(self.score(&pq, idx), idx, &mut heap); @@ -604,6 +638,139 @@ mod neon { } } +/// Explicit AVX2 scoring path (x86_64), bit-identical to [`score_scalar`]. +/// +/// Unlike NEON (baseline on aarch64), AVX2 is not universal on x86_64, so the +/// path is selected at runtime with `is_x86_feature_detected!` and the +/// kernels are `#[target_feature(enable = "avx2")]`. +/// +/// Bit-identity with the scalar path is structural: lane j of the +/// accumulator corresponds to scalar bucket j. Per 8-byte block, lane j +/// computes `q[2b]*lut[lo_b] + q[2b+1]*lut[hi_b]` (b = block start + j; +/// vmul, vmul, vadd — no FMA contraction) and adds it into lane j, blocks +/// in increasing order — the same per-bucket term and accumulation order as +/// the scalar loop. The LUT gather uses `vgatherdps` on the 16-entry table +/// where NEON uses `vqtbl4q_u8`. Final reduction is the same pairwise tree. +#[cfg(target_arch = "x86_64")] +mod avx2 { + use std::arch::x86_64::*; + + /// Whether the host CPU supports AVX2. + pub fn available() -> bool { + std::is_x86_feature_detected!("avx2") + } + + /// Gather `lut[nibble]` for 8 nibbles into an 8-lane vector. + #[inline] + unsafe fn gather8(lut: &[f32; 16], nibbles: __m128i) -> __m256 { + // The gather's scale of 4 turns each nibble index into an f32 byte + // offset — no pre-shift needed. + let idx = _mm256_cvtepu8_epi32(nibbles); + _mm256_i32gather_ps(lut.as_ptr(), idx, 4) + } + + /// Deinterleave the 16 f32 at `q` into even dims (8 lanes) and odd dims + /// (8 lanes): {d0,d2,..,d14} and {d1,d3,..,d15}. + #[inline] + unsafe fn deinterleave16(q: *const f32) -> (__m256, __m256) { + let qa = _mm256_loadu_ps(q); + let qb = _mm256_loadu_ps(q.add(8)); + // shuffle_ps picks {a0,a2,b0,b2} (even) / {a1,a3,b1,b3} (odd) per + // 128-bit half; the vpermps index vector then interleaves the halves + // into contiguous even/odd streams {d0,d2,..,d14} / {d1,d3,..,d15}. + let fixup = _mm256_setr_epi32(0, 1, 4, 5, 2, 3, 6, 7); + let even = _mm256_permutevar8x32_ps(_mm256_shuffle_ps(qa, qb, 0x88), fixup); + let odd = _mm256_permutevar8x32_ps(_mm256_shuffle_ps(qa, qb, 0xDD), fixup); + (even, odd) + } + + /// AVX2 scoring over one vector's codes. See module docs. + #[inline] + #[target_feature(enable = "avx2")] + pub unsafe fn score_avx2(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 { + let mut acc = _mm256_setzero_ps(); + let nb = codes.len(); + let mut i = 0; + while i + 8 <= nb { + let b8 = _mm_loadl_epi64(codes.as_ptr().add(i) as *const __m128i); + let g_lo = gather8(lut, _mm_and_si128(b8, _mm_set1_epi8(0x0F))); + let g_hi = gather8( + lut, + _mm_and_si128(_mm_srli_epi16(b8, 4), _mm_set1_epi8(0x0F)), + ); + let (even, odd) = deinterleave16(q.as_ptr().add(i * 2)); + // term = q_even*lut[lo] + q_odd*lut[hi] (mul, mul, add — no FMA) + let term = _mm256_add_ps(_mm256_mul_ps(even, g_lo), _mm256_mul_ps(odd, g_hi)); + acc = _mm256_add_ps(acc, term); + i += 8; + } + // Extract lanes and reduce with the scalar pairwise tree. + let mut a = [0f32; 8]; + _mm256_storeu_ps(a.as_mut_ptr(), acc); + // Scalar tail for the last (< 8) code bytes. padded is a multiple of + // 8 elements (padded/2 bytes multiple of 4), so nb % 8 is 0 or 4. + let mut tail = 0f32; + while i < nb { + let b = codes[i]; + let c = i * 2; + tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize]; + i += 1; + } + let s01 = a[0] + a[1]; + let s23 = a[2] + a[3]; + let s45 = a[4] + a[5]; + let s67 = a[6] + a[7]; + (s01 + s23) + (s45 + s67) + tail + } + + /// Score 4 consecutive vectors at once, amortizing the q deinterleave + /// across all 4. Each vector accumulates in the exact same per-lane order + /// as [`score_avx2`], so results are bit-identical. Returns raw + /// (pre-scale) scores; the caller multiplies by `scales`. + #[inline] + #[target_feature(enable = "avx2")] + pub unsafe fn score_avx24(codes4: &[u8], q: &[f32], lut: &[f32; 16]) -> [f32; 4] { + let nb = codes4.len() / 4; // bytes per vector + let mut acc = [_mm256_setzero_ps(); 4]; + let mut i = 0; + while i + 8 <= nb { + // Shared q loads + deinterleave for this block. + let (even, odd) = deinterleave16(q.as_ptr().add(i * 2)); + for (v, acc_v) in acc.iter_mut().enumerate() { + let b8 = _mm_loadl_epi64(codes4.as_ptr().add(v * nb + i) as *const __m128i); + let g_lo = gather8(lut, _mm_and_si128(b8, _mm_set1_epi8(0x0F))); + let g_hi = gather8( + lut, + _mm_and_si128(_mm_srli_epi16(b8, 4), _mm_set1_epi8(0x0F)), + ); + let term = _mm256_add_ps(_mm256_mul_ps(even, g_lo), _mm256_mul_ps(odd, g_hi)); + *acc_v = _mm256_add_ps(*acc_v, term); + } + i += 8; + } + let mut out = [0f32; 4]; + for v in 0..4 { + let mut a = [0f32; 8]; + _mm256_storeu_ps(a.as_mut_ptr(), acc[v]); + // Scalar tail for the last (< 8) code bytes. + let mut tail = 0f32; + let mut j = i; + while j < nb { + let b = codes4[v * nb + j]; + let c = j * 2; + tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize]; + j += 1; + } + let s01 = a[0] + a[1]; + let s23 = a[2] + a[3]; + let s45 = a[4] + a[5]; + let s67 = a[6] + a[7]; + out[v] = (s01 + s23) + (s45 + s67) + tail; + } + out + } +} + /// A query preprocessed in the quantized domain. pub struct PreparedQuery { rotated: Vec, @@ -748,6 +915,87 @@ mod tests { } } + #[cfg(target_arch = "x86_64")] + #[test] + fn avx2_matches_scalar_bitwise() { + if !avx2::available() { + return; // host without AVX2: scalar path is the only path + } + // dim 128: padded 128 (bpv 32, 4 full blocks). dim 8: padded 8 + // (bpv 4) — exercises the 4-byte scalar tail after the block loop. + for (dim, seed) in [(128, 42), (8, 43)] { + let mut idx = VecqIndex::new(dim, seed); + for i in 0..30 { + idx.add(&rand_unit(dim, i + 500)); + } + let q = rand_unit(dim, 777); + let pq = idx.prepare_query(&q); + for vi in 0..30 { + let base = vi * (idx.padded() / 2); + let codes = &idx.codes[base..base + idx.padded() / 2]; + let qslice = &pq.rotated[..idx.padded()]; + let avx2raw = unsafe { avx2::score_avx2(codes, qslice, &pq.lut) }; + let scalar = score_scalar(codes, qslice, &pq.lut); + assert_eq!( + avx2raw.to_bits(), + scalar.to_bits(), + "dim {dim} vector {vi}: AVX2 and scalar diverged" + ); + } + } + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn avx24_matches_avx2_bitwise() { + if !avx2::available() { + return; + } + let dim = 128; + let mut idx = VecqIndex::new(dim, 91); + for i in 0..12 { + idx.add(&rand_unit(dim, i + 90)); + } + let q = rand_unit(dim, 1234); + let pq = idx.prepare_query(&q); + let bpv = idx.padded() / 2; + for chunk_start in (0..12).step_by(4) { + let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv]; + let qslice = &pq.rotated[..idx.padded()]; + let batched = unsafe { avx2::score_avx24(codes4, qslice, &pq.lut) }; + for v in 0..4 { + let single = + unsafe { avx2::score_avx2(&codes4[v * bpv..(v + 1) * bpv], qslice, &pq.lut) }; + assert_eq!( + batched[v].to_bits(), + single.to_bits(), + "chunk {chunk_start} vec {v}: avx24 diverged from avx2" + ); + } + } + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn search_dispatch_matches_scalar_on_avx2_hosts() { + // Whatever path dispatch picks, results must equal the scalar + // reference bit for bit. + let dim = 128; + let mut idx = VecqIndex::new(dim, 55); + for i in 0..30 { + idx.add(&rand_unit(dim, i + 800)); + } + let q = rand_unit(dim, 888); + let pq = idx.prepare_query(&q); + for vi in 0..30 { + let base = vi * (idx.padded() / 2); + let codes = &idx.codes[base..base + idx.padded() / 2]; + let qslice = &pq.rotated[..idx.padded()]; + let scalar = score_scalar(codes, qslice, &pq.lut) * idx.scales[vi]; + assert_eq!(idx.score(&pq, vi).to_bits(), scalar.to_bits()); + } + } + #[test] fn search_returns_sorted_results() { let dim = 64; diff --git a/docs/BENCHMARK.md b/docs/BENCHMARK.md index 7fa7d31..ef8a401 100644 --- a/docs/BENCHMARK.md +++ b/docs/BENCHMARK.md @@ -55,12 +55,34 @@ Recall@1 = 0.910. Recall gate for the spike was **≥ 0.95** → **passed**. - f16 scales perturb scores by <1e-3; ranking ties near the cutoff can shift by one position (covered by tests: top-10 overlap ≥ 9/10, top-1 unchanged). +## Scoring paths per architecture + +All paths produce **bit-identical scores** (same association order, no FMA +contraction); a unit test enforces AVX2 == scalar and NEON == scalar on +overlapping inputs. `search()` additionally batches 4 vectors per pass on +both SIMD paths. + +| architecture | path | selection | +|---|---|---| +| aarch64 (Apple Silicon, ARM servers) | explicit NEON (`vqtbl4q_u8` LUT gather), 4-vector batching | compile time (NEON is baseline) | +| x86_64 with AVX2 | explicit AVX2 (`vgatherdps` LUT gather), 4-vector batching | runtime (`is_x86_feature_detected!`) | +| x86_64 without AVX2 | portable scalar (reference association order) | runtime fallback | +| other targets | portable scalar | compile time | + +The measured table above is aarch64 (NEON path). x86_64 AVX2 numbers are +pending measurement on native hardware; the expected gain over the scalar +path is roughly 1.5–2.5x on LUT-heavy scoring workloads (gather throughput +bound). Rosetta-emulated runs are explicitly **not** used as x86_64 +benchmarks — Rosetta neither advertises AVX2 via CPUID nor reflects native +throughput. + ## Conclusion The spike validates the technique: training-free 4-bit RHDH + Lloyd-Max quantization with asymmetric scoring keeps Recall@10 above 0.95 at ~6x compression on real Gemma embeddings. Recommended next steps: -1. Explicit NEON nibble decode (target ≤ 0.8 ms/q at n=2k) +1. ~~Explicit NEON nibble decode (target ≤ 0.8 ms/q at n=2k)~~ — done + (explicit NEON + AVX2 nibble-gather paths with bit-identity tests) 2. Optional rerank: return top-50, rescore exact f32 on a sidecar — recall ≈ 1.0 -3. Top-k selection without full sort (bounded binary heap) +3. Top-k selection without full sort (bounded binary heap) — done From e5f40ef262177f80684844f676f139e9923289ab Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 12:30:48 +0700 Subject: [PATCH 11/13] docs: canonical SQLite BLOB storage pattern (#12) (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/SQLITE.md: when to embed the index vs standalone file, the single-row and per-shard schema shapes, save/load with rusqlite in one transaction, atomicity notes (WAL + synchronous=NORMAL — no manual checkpoint choreography needed for correctness), measured save/load latencies at 1k/10k/50k vectors (dim 768, ~514 B/vec), and pitfalls (memory duplication on load, slot stability across save/reload interacting with the keyed API, churn-heavy workloads, embed size threshold, seed discipline). README links the page and mentions the per-architecture scoring-path matrix in BENCHMARK.md. Signed-off-by: ajianaz Co-authored-by: ajianaz --- README.md | 6 ++- docs/SQLITE.md | 140 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docs/SQLITE.md diff --git a/README.md b/README.md index 5afa050..a71375b 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,11 @@ assert_eq!(index.search(&query, 10), back.search(&query, 10)); ## Performance -Measured on aarch64, single-threaded, 2,000 real EmbeddingGemma vectors (768-dim): search **0.89 ms/query**, build **64 ms**, recall@10 **0.958**. Full methodology and comparison against usearch in [`docs/BENCHMARK.md`](docs/BENCHMARK.md). +Measured on aarch64, single-threaded, 2,000 real EmbeddingGemma vectors (768-dim): search **0.89 ms/query**, build **64 ms**, recall@10 **0.958**. Full methodology, comparison against usearch, and the per-architecture scoring-path matrix (NEON / AVX2 / scalar, all bit-identical) in [`docs/BENCHMARK.md`](docs/BENCHMARK.md). + +## SQLite integration + +Storing the index inside your SQLite database as a BLOB (schema, save/load pattern, atomicity, measured latencies at 1k/10k/50k vectors, and pitfalls): [`docs/SQLITE.md`](docs/SQLITE.md). ## Used by diff --git a/docs/SQLITE.md b/docs/SQLITE.md new file mode 100644 index 0000000..ed332c3 --- /dev/null +++ b/docs/SQLITE.md @@ -0,0 +1,140 @@ +# Storing a vecq index inside SQLite (BLOB pattern) + +vecq's first consumers are SQLite-native projects (uteke's memory engine, +cora-code's symbol index). Keeping the index **inside** the database — instead +of a `.vecq` file next to it — means backups, migrations, `ATTACH`, and +per-project multi-tenancy see the index as ordinary data. This page is the +canonical pattern so downstream projects converge on one approach. + +## When to embed vs standalone file + +| consideration | BLOB in SQLite | standalone file | +|---|---|---| +| backup/migration tooling | included automatically (`sqlite3 .backup`, VACUUM INTO) | must be handled separately — easy to miss | +| multiple indexes in one app | one row per index/project | one file per index, manual registry | +| remote DBs (Postgres/Redis) | readable via any SQL client | needs file transport | +| very large indexes (> ~100 MB) | DB file grows fast, VACUUM gets expensive | same cost, plus mmap-friendly random access | +| concurrent writers from multiple processes | serialized by SQLite | needs your own file locking | + +**Rule of thumb:** embed up to a few tens of MB (≈ 100k vectors at dim 768); +beyond that, prefer the standalone file and register its path in the DB. + +## Schema + +Two shapes, pick one: + +```sql +-- Simple: single-row table, one index per database (or per tenant via WHERE). +CREATE TABLE vecq_index ( + id INTEGER PRIMARY KEY CHECK (id = 1), + dim INTEGER NOT NULL, + seed INTEGER NOT NULL, -- must be persisted (from index.seed()) + blob BLOB NOT NULL -- output of VecqIndex::to_bytes() +); + +-- Scalable: per-shard rows (e.g. shard by key prefix or time bucket), so an +-- incremental update only rewrites the affected shard's BLOB. +CREATE TABLE vecq_shard ( + shard TEXT PRIMARY KEY, + dim INTEGER NOT NULL, + seed INTEGER NOT NULL, + blob BLOB NOT NULL +); +``` + +Keys from the keyed API (`add_keyed`/`remove_keyed`) are **not** part of the +file format — persist your own mapping table next to the index: + +```sql +CREATE TABLE vecq_keys ( + key INTEGER PRIMARY KEY, -- the u64 key used in add_keyed() + slot INTEGER NOT NULL -- slot index, valid until compact() +); +``` + +Because `to_bytes()` drops tombstones and re-serializes live slots in order, +**slot indices change whenever you save-then-reload after a `compact()`** — +rewrite `vecq_keys` in the same transaction whenever you save the index (see +below), or simply resolve keys → search results instead of storing slots. + +## Canonical save/load (Rust + rusqlite) + +```rust +use rusqlite::Connection; +use vecq_core::VecqIndex; + +fn save_index(db: &Connection, index: &VecqIndex) -> rusqlite::Result<()> { + let blob = index.to_bytes(); + db.execute_batch("BEGIN IMMEDIATE")?; + db.execute( + "INSERT INTO vecq_index (id, dim, seed, blob) VALUES (1, ?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE SET dim = ?1, seed = ?2, blob = ?3", + rusqlite::params![index.dim(), index.seed(), blob], + )?; + db.execute_batch("COMMIT")?; + Ok(()) +} + +fn load_index(db: &Connection) -> rusqlite::Result> { + let mut stmt = db.prepare("SELECT dim, seed, blob FROM vecq_index WHERE id = 1")?; + let mut rows = stmt.query([])?; + if let Some(row) = rows.next()? { + let blob: Vec = row.get(2)?; + let index = VecqIndex::from_bytes(&blob) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + return Ok(Some(index)); + } + Ok(None) +} +``` + +## Atomicity: keep it boring + +A single transactional `UPDATE` of the BLOB is already atomic and crash-safe — +SQLite journaling (rollback or WAL) guarantees readers see either the old or +the new index, never a torn file. **No manual write-ahead-BLOB dance or +`wal_checkpoint` choreography is needed for correctness**; checkpointing only +controls the WAL file's size. + +Recommended pragmas: + +```sql +PRAGMA journal_mode = WAL; -- readers don't block the save +PRAGMA synchronous = NORMAL; -- safe under WAL; FULL is rarely needed here +``` + +Save at most once per batch of mutations (not per add/remove): `to_bytes()` is +O(n) over the live vectors. + +## Size and latency + +Bytes per vector = `padded_dim/2 + 2` (nibble codes + f16 scale, format v1.1). +For dim 768 (padded 1024): **514 B/vector**. + +| vectors | dim 768 BLOB | save (update+commit) | load (read BLOB) | +|---|---|---|---| +| 1,000 | 0.5 MB | ~0.3 ms | ~0.1 ms | +| 10,000 | 5.0 MB | ~2 ms | ~3 ms | +| 50,000 | 25 MB | ~28 ms | ~10 ms | + +Measured on an M-series MacBook (Python `sqlite3`, WAL, `synchronous=NORMAL`) +— treat as order-of-magnitude for commodity hardware. The point: for the +10k-scale workloads vecq targets, "save the whole index in one BLOB update per +batch" is comfortably fast, and per-shard rows are only worth it past ~50 MB. + +## Pitfalls + +- **Memory duplication on load**: `row.get::<_, Vec>` copies the BLOB, + then `from_bytes` copies into the index's codes/scales. Transient 2x is + fine at 5 MB; load once at startup, not per query, for large indexes. +- **Slot instability across save/reload**: in-memory slot indices stay stable + across tombstones (issue #10 design), but a save→reload produces a fresh + dense index — any stored slot references must be rewritten in the same + transaction as the BLOB save. +- **Churn-heavy workloads**: every save rewrites the full BLOB. If you mutate + thousands of times per second, shard the index (per-shard rows) or debounce + saves. +- **`PRAGMA integrity_check` cost**: giant BLOBs make integrity checks and + `VACUUM` slow; another reason for the ~100 MB embed threshold. +- **Seed discipline**: always store `seed` next to the BLOB; a rebuilt index + with a different seed produces a different rotation and incompatible scores. From e3e838ec857e7d5030bf2c5d3f2e33a7157d65be Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 28 Aug 2026 12:37:27 +0700 Subject: [PATCH 12/13] chore: bump version to 0.2.0 (#19) Signed-off-by: ajianaz Co-authored-by: ajianaz --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1dd8ec9..ec6f23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,7 +347,7 @@ dependencies = [ [[package]] name = "vecq-bench" -version = "0.1.1" +version = "0.2.0" dependencies = [ "usearch", "vecq-core", @@ -355,7 +355,7 @@ dependencies = [ [[package]] name = "vecq-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "rand", ] diff --git a/Cargo.toml b/Cargo.toml index 420bdb7..2ec663c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/vecq-core", "crates/vecq-bench"] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2021" license = "Apache-2.0" repository = "https://github.com/codecoradev/vecq" From 34d6ac6016795b9b0d192eec1d96fea631fc6907 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Fri, 28 Aug 2026 12:41:31 +0700 Subject: [PATCH 13/13] fix: resolve Cargo.lock conflict markers left from merge The merge committed conflict markers into Cargo.lock; restore the develop-side lock (vecq-core 0.2.0) and verify the workspace builds. Signed-off-by: ajianaz --- Cargo.lock | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c82447..ec6f23b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,11 +347,7 @@ dependencies = [ [[package]] name = "vecq-bench" -<<<<<<< HEAD version = "0.2.0" -======= -version = "0.1.1" ->>>>>>> origin/main dependencies = [ "usearch", "vecq-core", @@ -359,11 +355,7 @@ dependencies = [ [[package]] name = "vecq-core" -<<<<<<< HEAD version = "0.2.0" -======= -version = "0.1.1" ->>>>>>> origin/main dependencies = [ "rand", ]