0108 minhash ships both permutation families - CyrilB1531/lodestar GitHub Wiki
0108 โ MinHash ships both permutation families, and neither replaces the other
Status: accepted ยท Date: 2026-09-11
Context
#645 was opened while doing
#643, which took datasketch from 1.6.5 to
2.0.0. That release named three permutation schemes and changed the default:
_VALID_SCHEMES = (_SCHEME_AFFINE32, _SCHEME_AFFINE64, _SCHEME_LEGACY)
...
scheme = _SCHEME_AFFINE32 # the new default
Lodestar.Text.Similarity.MinHash computes what 2.0.0 now calls legacy, and #643 kept the
corpus still by naming it โ scheme="legacy" reproduces the frozen coefficients exactly. That was
the right move for a dependency bump and it settled nothing about the package.
What it left is a claim in docs/equivalence.md that is no longer true as written:
Exact parity, bit for bit: a signature is a list of hashes, so any difference means the algorithm diverged.
A caller who computes a MinHash with a current datasketch today gets affine32, and this
package could not produce it. So the promise held only against a spelling the reference no longer
defaults to, and the page did not say so.
What was measured
On an AMD Ryzen 7 8700G (16 cores), 24 tokens per document, BenchmarkDotNet:
| hashing | minimise, legacy |
minimise, affine32 |
end to end | |
|---|---|---|---|---|
| 64 permutations | 9 372 ns | 1 297 ns | 575 ns | โ6.8% |
| 128 permutations | 9 300 ns | 2 576 ns | 1 159 ns | โ11.9% |
The second family costs less than half the first in the loop that runs once per token per
permutation โ no division by a prime, just a multiply that wraps. And it barely moves what a
caller pays, because SHA-1 hashing is 74% to 78% of MinHash.Signature and neither family
touches it. That is the same finding bench/README.md ยง26 recorded for the GPU kernel, arriving
from the other side: the minimisation is not where the time is.
affine32 is not merely "the same thing without the modulo". It applies the
MurmurHash3 finalizer to each hash first โ
fixed constants, a bijection on [0, 2^32) โ so that a weakly hashed input cannot ride its own
structure through an affine map, which the prime modulus used to absorb. Reproducing it bit for
bit means reproducing that too.
Lodestar.Text already had that finalizer, privately, inside its internal MurmurHash3 โ same
shifts, same two constants, because scikit-learn's feature hashing needs the same algorithm. The
first draft of this work wrote a second copy in MinHash. There is one in the assembly now and
MinHash calls it; Lodestar.Gpu keeps its own, necessarily, which is the next section.
Decision
Both families ship, and Legacy stays the default.
Replacing it was the option a 2ร figure invites, and 6.8% to 11.9% is not what it buys. MinHash
is public API in a shipped package, signatures are persisted, and every one of them would stop
comparing to a freshly computed one โ for a gain the hashing pass swallows.
Keeping only Legacy was the other option, and it is what leaves the parity claim quietly false.
The package exists to match the reference; the reference moved its default; following it is in
thesis rather than scope creep.
The scheme travels with the coefficients, on
MinHashPermutations, not on MinHash or the call. They are chosen together: an affine32 multiplier is odd and fits 32 bits where a legacy
one spans 61. A pair that cannot mean what it says is refused at construction โ
decision 0072 makes the coefficients an input, so unlike
the reference, which generates and therefore trusts them, this has a caller to protect. An even
multiplier collapses the value range instead of permuting it, and shows up as an estimate that is
merely wrong.
The corpus grows a second block rather than moving one. tests/oracles/text_similarity.json
keeps every value it had and gains an affine32 object beside them: 370 lines added, none changed.
affine64 is not offered. The reference pairs it with sha1_hash64 rather than
sha1_hash32, so it is a second parity surface โ a different hash and a different width โ and not
a third value of this enum. It waits for a use, not for a release.
What the satellite tier costs here, visibly
Lodestar.Gpu carries no edge to any Lodestar package
(decision 0076,
0102), so it declares its own
MinHashScheme beside its own MersennePrime and mask. The duplication is two members, and it is
visible to a caller:
using Lodestar.Gpu.Compute;
using Lodestar.Text.Similarity; // 'MinHashScheme' is an ambiguous reference
That caller needs a using alias. Creating an edge to remove it would put a dependency on the
satellite tier for an enum, which is the thing the tier exists to refuse โ so the alias is the
price, named here rather than discovered.
What is not paid twice is the coefficient refusal. The first draft wrote those thirty lines
into both packages on the same "no edge" reasoning, and SonarCloud counted them: 61 of 565 new
lines, 10.8% duplication on new code against a gate that allows 3. An enum of two members is
worth spelling twice; a validation loop is not. It lives in src/Shared/MinHashCoefficients.cs
now, compiled into each assembly under Lodestar.Internal the way Guard and StringCompat
already are โ shared source, which is exactly the mechanism this repository has for wanting one
copy without wanting an edge. The satellite tier's rule is about what a consumer restores, and
shared source changes nothing a consumer restores.
The finalizer runs inside the kernel, as the shared tile fills: once per token per group
rather than once per token per thread. Mixing before upload would be cheaper still and would make
a DeviceTokenHashes belong to one family, which is what residency exists not to do.
Consequences
Lodestar.Text0.6.0 โ 0.7.0 andLodestar.Gpu0.1.0 โ 0.2.0: new public surface, no behaviour changed for anyone who names no scheme.docs/equivalence.md's MinHash row says which family it claims parity with, and that the other is available โ the claim narrows to something true rather than staying broad and false.- The oracle now freezes two families from one reference version. A future
datasketchthat moves its default again costs a third block, not a rewrite, and the shape is set here. bench/README.mdยง26's split is what makes this a small decision rather than a large one. If the hashing ever moves to the accelerator โ ยง26 names it as the obvious next kernel โ the permutation arithmetic becomes a larger share of the total and this measurement is worth retaking, not re-deriving.