Skip to content

feat(fsst): training (adaptive sampling, min-count pruning, gain boost, final prune)#293

Merged
dfa1 merged 1 commit into
mainfrom
feat/fsst-training
Jul 20, 2026
Merged

feat(fsst): training (adaptive sampling, min-count pruning, gain boost, final prune)#293
dfa1 merged 1 commit into
mainfrom
feat/fsst-training

Conversation

@dfa1

@dfa1 dfa1 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

PR 3 of #287: ports 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, CompressorBuilder.

Ports the four Rust-reference (spiraldb/fsst) refinements the old writer/FsstEncodingEncoder lacks:

  1. Byte-size-bounded adaptive sampling (~16KB target, <=512-byte chunks) with a growing per-generation sample fraction (8/38/68/98/128 over 128).
  2. Per-generation min-count pruning.
  3. 8x single-byte gain boost (suppresses escapes).
  4. Final cost-based prune (last generation only).

codesSortedByLength() stays pure, wire-agnostic reordering logic — only PR 5's wire adapter interprets it as vortex.fsst order.

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 (matching Compressor.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 explicit Math.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 -DskipTests at root — full reactor SUCCESS
  • Independently re-verified the boundary-overrun fix with a standalone repro

🤖 Generated with Claude Code

…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>
@dfa1
dfa1 merged commit 4158c92 into main Jul 20, 2026
6 checks passed
@dfa1 dfa1 mentioned this pull request Jul 20, 2026
@dfa1
dfa1 deleted the feat/fsst-training branch July 20, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant