Go implementation of OnPair, a compression algorithm designed for efficient random access on sequences of short strings.
OnPair is a field-level compression algorithm designed for workloads requiring fast random access to individual strings in large collections. The compression process consists of two distinct phases:
- Training Phase: A longest prefix matching strategy is used to parse the input and identify frequent adjacent token pairs. When the frequency of a pair exceeds a predefined threshold, a new token is created to represent the merged pair. This continues until the dictionary is full or the input data is exhausted. The dictionary supports up to 65,536 tokens.
- Parsing Phase: Once the dictionary is constructed, each string is compressed independently into a sequence of token IDs by greedily applying longest prefix matching.
OnPair16 is a variant that limits dictionary entries to a maximum length of 16 bytes. This constraint enables further optimizations in both longest prefix matching and decoding.
go get github.com/seiflotfy/onpairpackage main
import (
"fmt"
"github.com/seiflotfy/onpair"
)
func main() {
trainRows := []string{
"user_000001",
"user_000002",
"user_000003",
"admin_001",
"user_000004",
}
model, err := onpair.TrainModel(trainRows, onpair.WithMaxTokenLength(16))
if err != nil {
panic(err)
}
archive, err := model.Encode(trainRows)
if err != nil {
panic(err)
}
for i := 0; i < archive.Rows(); i++ {
row, err := archive.AppendRow(nil, i)
if err != nil {
panic(err)
}
fmt.Printf("row %d: %s\n", i, string(row))
}
}enc := onpair.NewEncoder(
onpair.WithMaxTokenLength(16), // optional
onpair.WithMaxTokenID(4095), // optional smaller dictionary cap
onpair.WithTokenBitWidth(12), // optional packed 12-bit token stream
onpair.WithTrainingSampleBytes(8*1024*1024), // optional larger training sample
onpair.WithTemplateStratifiedSampling(2048), // optional template-based stratified sampling
)
archive, err := enc.Encode([]string{"user_001", "user_002", "admin_001"})
if err != nil {
panic(err)
}WithTokenBitWidth(12) uses packed 12-bit token IDs in archive storage and
automatically limits dictionary IDs to 4095.
The serialized compressed_data stage now auto-selects the smallest among raw,
flate(raw), byte-codebook+escape, and flate(codebook) for both 12-bit and
16-bit token streams.
WithTemplateStratifiedSampling uses a lightweight template normalization
heuristic for sample balancing; it is not a full log-template parser.
row, err := archive.AppendRow(nil, 42) // row index 42 only
if err != nil {
panic(err)
}
fmt.Println(string(row))buf := make([]byte, 256)
n, err := archive.DecompressString(0, buf) // returns ErrShortBuffer if too small
if err != nil {
panic(err)
}
fmt.Println(string(buf[:n]))DecompressString and DecompressAllChecked treat buffer bytes beyond the
returned length as scratch space: the fast decode path may overwrite them
(never past len(buf)). Use buf[:n] only.
all := make([]byte, 4096)
n, err := archive.DecompressAllChecked(all)
if err != nil {
panic(err)
}
_ = all[:n]Equality, prefix, and substring queries run directly over the token streams,
without decoding rows back to bytes. The dictionary is sorted at training
time, so a Searcher validates once at construction and queries cannot fail
on malformed data:
s, err := archive.Searcher()
if err != nil {
panic(err)
}
equal := s.RowsEqualTo([]byte("user_000002")) // row indices exactly equal
prefixed := s.RowsStartingWith([]byte("user_")) // row indices with prefix
containing, err := s.RowsContaining([]byte("_00")) // row indices with substringArchives serialized by versions that predate dictionary sorting fail
Searcher() with ErrDictionaryNotSearchable; re-encode them to search.
file, err := os.Create("archive.bin")
if err != nil {
panic(err)
}
defer file.Close()
if _, err := archive.WriteTo(file); err != nil {
panic(err)
}
loaded := &onpair.Archive{}
if _, err := file.Seek(0, io.SeekStart); err != nil {
panic(err)
}
if _, err := loaded.ReadFrom(file); err != nil {
panic(err)
}model, err := onpair.TrainModel(trainRows, onpair.WithMaxTokenLength(16))
if err != nil { /* ... */ }
archive, err := model.Encode(queryRows)
if err != nil { /* ... */ }
out, err := archive.AppendRow(nil, 0)
if err != nil { /* ... */ }
_ = outNewEncoder(opts ...Option) *EncoderNewModel(opts ...Option) *ModelTrainModel(strings []string, opts ...Option) (*Model, error)WithThreshold(t uint16) OptionWithMaxTokenLength(n int) OptionWithMaxTokenID(maxID uint16) OptionWithTokenBitWidth(bits uint8) Option(12or16, default16)WithTrainingSampleBytes(n int) Option(default1 MiB)WithTemplateStratifiedSampling(maxClusters int) OptionWithParallelism(n int) Option(compress with up tongoroutines on inputs over ~1 MiB; byte-identical output,n <= 0meansGOMAXPROCS. Encoding is single-goroutine unless this is set)
(*Encoder).Encode(strings []string) (*Archive, error)(single-shot train+encode)(*Model).Train(strings []string) error(*Model).Encode(strings []string) (*Archive, error)(*Model).Trained() bool(*Archive).Rows() int(*Archive).DecodedLen(index int) (int, error)(*Archive).AppendRow(dst []byte, index int) ([]byte, error)(*Archive).AppendAll(dst []byte) ([]byte, error)(*Archive).DecompressString(index int, buffer []byte) (int, error)(*Archive).DecompressAllChecked(buffer []byte) (int, error)
(*Archive).Searcher() (*Searcher, error)(*Searcher).Tokenize(text []byte) []uint16(*Searcher).RowsEqualTo(needle []byte) []int(*Searcher).RowsStartingWith(prefix []byte) []int(*Searcher).RowsContaining(pattern []byte) ([]int, error)
(*Archive).WriteTo(w io.Writer) (int64, error)(*Archive).ReadFrom(r io.Reader) (int64, error)
git clone https://github.com/seiflotfy/onpair
cd onpair
# Run tests
go test ./...
# Run benchmarks
go test -bench=. ./...This project is licensed under the MIT License - see the LICENSE file for details.
Portions are derived from third-party projects; see THIRD_PARTY_LICENSES for their notices.