Found by bug-hunt wave 11 (recall-quality lens). Severity: high for sparse/bag-of-words workloads; dense embeddings unaffected.
turbovec/src/rotation.rs:92: block_size(dim) is the largest power-of-two divisor of dim, and the Walsh-Hadamard mixes only within B-sized blocks (2 rounds, rotation.rs:30-48). When B is 8 or 16, the transform cannot Gaussianize inputs that actually need mixing — and recall falls off a cliff between adjacent dims.
Recipe: n=20,000, 10-nonzero random sparse (N(0,1) values), L2-normalized, 300 held-out queries, seeds 0 and 1. Metric matches benchmarks/suite/recall_*.py (control: real openai-1536 n=100k 2-bit → R@1 0.886 vs published 0.891; GloVe d=200 4-bit → 0.840 vs 0.8498).
| dim |
B |
bits |
R@1 (s0 / s1) |
R10@10 |
| 1024 |
1024 |
2 |
0.883 / 0.850 |
0.919 / 0.915 |
| 1016 |
8 |
2 |
0.453 / 0.427 |
0.557 / 0.547 |
| 1032 |
8 |
2 |
0.393 / 0.410 |
0.533 / 0.553 |
| 1040 |
16 |
2 |
0.780 / 0.760 |
0.839 / 0.833 |
| 1088 |
64 |
2 |
0.883 / 0.860 |
0.923 / 0.918 |
| 2048 |
2048 |
2 |
0.913 / 0.917 |
0.942 / 0.944 |
| 2056 |
8 |
2 |
0.270 / 0.263 |
0.453 / 0.449 |
Monotone recovery in B, reproducible across seeds, and the gap widens with dim (−47pp at 1032, −65pp at 2056). Present at 4-bit too (d=2048 R@1 0.990 vs d=2056 0.707). Also reproduced on a tf-idf-like recipe (nz=20, |N(0,1)|): d=1024 0.847 vs d=1032 0.483 at 2-bit.
Scope: dense real embeddings are fine — openai-1536 truncated to 1528/1032/520/264 (all B=8) and nytimes-256→248 all matched their power-of-2 neighbours within ±1.5pp. This bites sparse / bag-of-words / SPLADE-style / hashed-feature vectors — exactly what the rotation exists for.
Undocumented: rotation.rs internally acknowledges "weak-block dims (B = 8)", but no user-facing doc (README, docs/api.md) warns that dim choice affects recall. Someone picking dim 1000, 1032, 2056 or 3000 with sparse features silently loses half their recall; padding to 1024/1088 fixes it. At minimum this needs a doc warning; better would be a constructor warning or more mixing rounds when B is small.
🤖 Generated with Claude Code
Found by bug-hunt wave 11 (recall-quality lens). Severity: high for sparse/bag-of-words workloads; dense embeddings unaffected.
turbovec/src/rotation.rs:92:block_size(dim)is the largest power-of-two divisor ofdim, and the Walsh-Hadamard mixes only withinB-sized blocks (2 rounds, rotation.rs:30-48). WhenBis 8 or 16, the transform cannot Gaussianize inputs that actually need mixing — and recall falls off a cliff between adjacent dims.Recipe: n=20,000, 10-nonzero random sparse (N(0,1) values), L2-normalized, 300 held-out queries, seeds 0 and 1. Metric matches benchmarks/suite/recall_*.py (control: real openai-1536 n=100k 2-bit → R@1 0.886 vs published 0.891; GloVe d=200 4-bit → 0.840 vs 0.8498).
Monotone recovery in B, reproducible across seeds, and the gap widens with dim (−47pp at 1032, −65pp at 2056). Present at 4-bit too (d=2048 R@1 0.990 vs d=2056 0.707). Also reproduced on a tf-idf-like recipe (nz=20, |N(0,1)|): d=1024 0.847 vs d=1032 0.483 at 2-bit.
Scope: dense real embeddings are fine — openai-1536 truncated to 1528/1032/520/264 (all B=8) and nytimes-256→248 all matched their power-of-2 neighbours within ±1.5pp. This bites sparse / bag-of-words / SPLADE-style / hashed-feature vectors — exactly what the rotation exists for.
Undocumented: rotation.rs internally acknowledges "weak-block dims (B = 8)", but no user-facing doc (README, docs/api.md) warns that dim choice affects recall. Someone picking dim 1000, 1032, 2056 or 3000 with sparse features silently loses half their recall; padding to 1024/1088 fixes it. At minimum this needs a doc warning; better would be a constructor warning or more mixing rounds when B is small.
🤖 Generated with Claude Code