feat(fsst): training (adaptive sampling, min-count pruning, gain boost, final prune)#293
Merged
Conversation
…t, final prune) PR 3 of #287: port the FSST paper's bottom-up training (Algorithm 3) into the standalone fsst module, on top of PR 2's branch-free Matcher. Adds Sample, TrainingGeneration, Compressor, and CompressorBuilder plus tests. CompressorBuilder.train(byte[][]) runs five generations from an empty table: each compress-counts a growing fraction of one fixed Sample with the current table, then rebuilds from the top-255 candidates by gain. Deterministic given a seed. Compressor is the immutable trained artifact (packedSymbol/symbolLength accessors, compress, toDecompressor, and the wire-order-agnostic codesSortedByLength permutation). Ports the four Rust-reference (spiraldb/fsst) refinements the old writer/FsstEncodingEncoder lacks, per the approved plan: 1. Byte-size-bounded adaptive sampling (~16KB target, <=512-byte chunks) with a per-generation growing sample fraction (8/38/68/98/128 over 128). Replaces the old string-count-bounded, always-full-sample scheme, which pulled in far more training bytes than useful on long-string columns for no gain. Early generations stay cheap while the table is still small and low-quality. 2. Per-generation min-count pruning: a candidate below max(1, 5*num/128) is dropped before its gain is computed (floor 1 on the final generation). The old code ranked every observed candidate, letting sampling noise compete for slots. 3. 8x single-byte gain boost: length-1 candidates are over-valued before ranking so a frequent byte is not crowded out by a marginally-higher-gain multi-byte candidate. A tabled single byte replaces a 2-byte escape on every occurrence; the old plain count*length scheme had exactly this gap. 4. Final cost-based prune: on the last generation only, a candidate keeps its slot only if its real (un-boosted) gain exceeds length+1 — a genuine cost/benefit check the old code never applied. codesSortedByLength stays pure, generic reordering (multi-byte ascending, then single-byte last); only PR 5's wire adapter interprets it as vortex.fsst order. End-of-input match bound: PR 2's branch-free Matcher has no notion of the input end (that is what makes it branch-free), and Compressor.loadWord zero-pads bytes past end. A trained symbol with trailing zero bytes could therefore spuriously satisfy the LossyPerfectHashTable masked compare against the padding and report a match longer than the real remaining input, emitting one code that decodes to more bytes than were compressed — silent tail corruption on any NUL-containing or binary row (this encoder also accepts DType.Binary). Both callers (Compressor.compress and TrainingGeneration.matchLengthAt) now reject a match reaching past end and fall back to an escape / length-1, matching the old FsstEncodingEncoder's explicit maxLen clamp. This is the scalar-path fix the paper's Section 5.2 names (the terminator-byte trick is an AVX512-kernel optimization, out of scope here). Covered by a round-trip regression test. Deviation from the plan's suggested class breakdown: none of substance. The plan listed Sample/TrainingGeneration/Compressor/CompressorBuilder and all four exist with those responsibilities. The shared byte[]->little-endian word loader lives as a package-private Compressor#loadWord (both compress and compress-count need it) rather than a separate helper class, keeping the module surface small. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 3 of #287: ports the FSST paper's bottom-up training (Algorithm 3) into the standalone
fsstmodule, on top of PR 2's branch-freeMatcher. AddsSample,TrainingGeneration,Compressor,CompressorBuilder.Ports the four Rust-reference (
spiraldb/fsst) refinements the oldwriter/FsstEncodingEncoderlacks:<=512-byte chunks) with a growing per-generation sample fraction (8/38/68/98/128 over 128).codesSortedByLength()stays pure, wire-agnostic reordering logic — only PR 5's wire adapter interprets it asvortex.fsstorder.Found and fixed during review, before this PR was pushed: a real data-corruption bug where the branch-free
Matcher(no notion of input-end) could spuriously match a trained symbol past the true end of a short input, if the symbol's trailing bytes happened to be zero (matchingCompressor.loadWord's zero-padding). Confirmed with a concrete repro: compressing 3 real bytes then decompressing produced 8 bytes with 5 bytes of garbage appended. Fixed at both call sites (Compressor.compress,TrainingGeneration.matchLengthAt) by rejecting a match whose length would overrun the real remaining input — matches the paper's own guidance for the scalar (non-AVX512) path, and the safety property the old encoder already had via its explicitMath.min(MAX_SYMBOL_LENGTH, input.length - pos)clamp. Covered by a new regression test; independently re-verified.Test plan
./mvnw test -pl fsst -am— 43 tests, 0 failures./mvnw verify -pl fsst -am— checkstyle clean./mvnw javadoc:javadoc -pl fsst— zero output./mvnw verify -DskipTestsat root — full reactor SUCCESS🤖 Generated with Claude Code