Skip to main content

rudb_encoding/
multi.rs

1//! Columns encoded together instead of one at a time.
2//!
3//! `spec/06-compression.md` section 6.4 calls this multi-column compression and says it is the
4//! mechanism that has no equivalent in DuckDB. The idea is that columns are not independent, so
5//! encoding them independently throws away real redundancy. On ClickBench `hits` the obvious case
6//! is `URL` and `Referer`, which are both URLs drawn from the same universe, and the less obvious
7//! one is that `URL`, `Referer`, `Title` and the referer derived columns are all the same alphabet
8//! and could share one symbol table.
9//!
10//! ## The three strategies
11//!
12//! `INDEPENDENT` is each column encoded on its own by [`crate::string`]. It is the baseline the
13//! other two have to beat, and it is what a group falls back to when they do not.
14//!
15//! `SHARED_TABLE` is one FSST symbol table trained on a sample of all the columns, with every
16//! column front coded and the leftovers compressed against it. Storing one 255 symbol table rather
17//! than six saves almost nothing by itself. The effect that matters is that a table trained on the
18//! union has more evidence per symbol, so it compresses each column better than a table trained on
19//! that column alone would, and the columns that gain most are the small ones that never had enough
20//! bytes to train on.
21//!
22//! The front coding and the matching are there because `INDEPENDENT` is the baseline and
23//! `INDEPENDENT` can do both, so a shared table that could not would be losing to a baseline it was
24//! never measured against. On the generated URLs in the tests here the front coding gap was a
25//! factor of two and the matching gap was nearly another one. Front coding a column whose
26//! neighbours share nothing costs a run of zeros and matching a column with no repeats costs one
27//! token, both of which the integer chunk stores in a few bytes, so nothing has to decide whether
28//! to do either.
29//!
30//! `SHARED_DICT` is one dictionary holding the union of the values, with every column becoming an
31//! array of codes into it. A value that appears in three columns is stored once rather than three
32//! times. The dictionary is itself a string column, so it goes back through the string chooser and
33//! comes out FSST compressed, which means a shared dictionary is also a shared symbol table.
34//!
35//! ## What decides
36//!
37//! Here, measuring all three and keeping the smallest, for the same reason [`crate::string`] does
38//! it that way: M1 is measuring what the format can do rather than how fast a writer can decide.
39//! A write path cannot afford this. Section 6.4 says detection is by sampling pairs over a global
40//! sample at table level, recorded in the catalog as hints, with each row group checking only the
41//! hinted pairs. [`dictionary_groups`] is that pruning step, and it runs on sketches rather than on
42//! data, so the 5,460 pairs of a 105 column table cost a Jaccard estimate each rather than a pass
43//! over the column.
44//!
45//! ## What is not here
46//!
47//! Correlation encodings, which are the third form in section 6.4: column B stored as a function of
48//! column A, either as a per dictionary entry lookup for a functional dependency or as B minus f(A)
49//! for a numeric one. [`crate::sketch::dependence`] is the detection half of that and the encoding
50//! half is its own piece of work.
51//!
52//! Global dictionaries, which are section 6.5 and are a dictionary across the whole table rather
53//! than across a group of columns in one chunk. The two compose, and the reason they are separate
54//! is that a global dictionary needs an incremental builder that can spill, which is open question
55//! five and is the thing most likely to make the idea impractical.
56
57use rudb_common::{Error, Result};
58
59use crate::fsst::SymbolTable;
60use crate::integer;
61use crate::lz;
62use crate::reader::Reader;
63use crate::sketch::Sketch;
64use crate::string;
65
66/// The least a column contributes to a shared sample, however small it is next to the rest of the
67/// group. Enough to see an alphabet, not enough to matter against the 64 KB the group gets.
68const FLOOR_SAMPLE_BYTES: usize = 2 * 1024;
69
70/// How a column group is encoded. The discriminant is the tag byte and is part of the format.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Strategy {
73    /// Each column encoded on its own.
74    Independent = 0,
75    /// One symbol table over all of them.
76    SharedTable = 1,
77    /// One dictionary over the union of their values.
78    SharedDict = 2,
79}
80
81impl Strategy {
82    fn tag(self) -> u8 {
83        self as u8
84    }
85
86    fn from_tag(tag: u8) -> Result<Self> {
87        match tag {
88            0 => Ok(Self::Independent),
89            1 => Ok(Self::SharedTable),
90            2 => Ok(Self::SharedDict),
91            other => Err(Error::internal(format!("unknown column group tag {other}"))),
92        }
93    }
94
95    /// The name that goes in a report.
96    #[must_use]
97    pub fn name(self) -> &'static str {
98        match self {
99            Self::Independent => "INDEPENDENT",
100            Self::SharedTable => "SHARED_TABLE",
101            Self::SharedDict => "SHARED_DICT",
102        }
103    }
104}
105
106/// Encodes a group of string columns together, choosing whatever comes out smallest.
107///
108/// The columns do not have to be the same length. Nothing here pairs values up by row, so a group
109/// is a set of columns that share an alphabet or a value universe rather than a set of columns from
110/// the same table.
111///
112/// # Errors
113///
114/// If the group holds more than `u32::MAX` columns or a column holds more than `u32::MAX` values,
115/// or if an encoding produces something its own decoder would not accept.
116pub fn encode_group(columns: &[&[&[u8]]]) -> Result<Vec<u8>> {
117    let mut best: Option<Vec<u8>> = None;
118    for strategy in [Strategy::Independent, Strategy::SharedTable, Strategy::SharedDict] {
119        let Some(bytes) = encode_as(strategy, columns)? else {
120            continue;
121        };
122        if best.as_ref().is_none_or(|current| bytes.len() < current.len()) {
123            best = Some(bytes);
124        }
125    }
126    best.ok_or_else(|| Error::internal("no strategy applied to the column group"))
127}
128
129/// Decodes a group written by [`encode_group`].
130///
131/// # Errors
132///
133/// If the bytes are truncated, carry an unknown tag, or describe a group whose parts disagree.
134pub fn decode_group(bytes: &[u8]) -> Result<Vec<Vec<Vec<u8>>>> {
135    let mut reader = Reader::new(bytes);
136    let columns = decode_at(&mut reader)?;
137    if reader.remaining() != 0 {
138        return Err(Error::internal(format!(
139            "{} bytes left over after decoding a column group",
140            reader.remaining()
141        )));
142    }
143    Ok(columns)
144}
145
146/// The size of every strategy that applies, which is the measurement section 6.4 is asking for.
147///
148/// # Errors
149///
150/// As [`encode_group`].
151pub fn strategy_sizes(columns: &[&[&[u8]]]) -> Result<Vec<(Strategy, usize)>> {
152    let mut sizes = Vec::new();
153    for strategy in [Strategy::Independent, Strategy::SharedTable, Strategy::SharedDict] {
154        if let Some(bytes) = encode_as(strategy, columns)? {
155            sizes.push((strategy, bytes.len()));
156        }
157    }
158    Ok(sizes)
159}
160
161/// The shape a group was encoded as, as a line of text.
162///
163/// # Errors
164///
165/// As [`decode_group`].
166pub fn describe(bytes: &[u8]) -> Result<String> {
167    let mut reader = Reader::new(bytes);
168    describe_at(&mut reader)
169}
170
171/// Which columns should be considered for a shared dictionary, from one sketch per column.
172///
173/// Two columns are put in the same group when their estimated Jaccard similarity is at least
174/// `threshold`, and grouping is transitive: if A overlaps B and B overlaps C then all three end up
175/// together, even if A and C do not overlap each other. That is deliberate. A dictionary is a set
176/// union, so a chain of overlapping columns still stores fewer values once than separately, and
177/// insisting that every pair in a group overlaps would turn this into a clique problem for no
178/// benefit.
179///
180/// Every column appears in exactly one group, and a column that overlaps nothing is a group of one.
181/// The groups come back in the order of their lowest column index, and the columns inside a group
182/// in index order, so the result does not depend on the order the pairs were tested in.
183///
184/// This is a pruning step and not a decision. Two columns can overlap heavily and still be better
185/// off apart, which is why what comes out of here goes to [`strategy_sizes`] rather than straight
186/// into a writer.
187///
188/// # Errors
189///
190/// If the sketches were not all built at the same k, since then no estimate over a pair means
191/// anything.
192pub fn dictionary_groups(sketches: &[Sketch], threshold: f64) -> Result<Vec<Vec<usize>>> {
193    let mut parent: Vec<usize> = (0..sketches.len()).collect();
194    for left in 0..sketches.len() {
195        for right in (left + 1)..sketches.len() {
196            if sketches[left].jaccard(&sketches[right])? >= threshold {
197                let (a, b) = (find(&mut parent, left), find(&mut parent, right));
198                if a != b {
199                    // The lower index wins, so the group's root is its first column and the output
200                    // order is a function of the columns rather than of the loop.
201                    let (low, high) = if a < b { (a, b) } else { (b, a) };
202                    parent[high] = low;
203                }
204            }
205        }
206    }
207    let mut groups: Vec<Vec<usize>> = Vec::new();
208    let mut roots: Vec<usize> = Vec::new();
209    for column in 0..sketches.len() {
210        let root = find(&mut parent, column);
211        match roots.iter().position(|seen| *seen == root) {
212            Some(at) => groups[at].push(column),
213            None => {
214                roots.push(root);
215                groups.push(vec![column]);
216            }
217        }
218    }
219    Ok(groups)
220}
221
222fn find(parent: &mut [usize], mut node: usize) -> usize {
223    while parent[node] != node {
224        parent[node] = parent[parent[node]];
225        node = parent[node];
226    }
227    node
228}
229
230fn encode_as(strategy: Strategy, columns: &[&[&[u8]]]) -> Result<Option<Vec<u8>>> {
231    let mut out = vec![strategy.tag()];
232    put_u32(&mut out, u32::try_from(columns.len()).map_err(|_| too_many(columns.len()))?);
233    match strategy {
234        Strategy::Independent => {
235            for column in columns {
236                out.extend_from_slice(&string::encode(column)?);
237            }
238        }
239        Strategy::SharedTable => {
240            if columns.len() < 2 {
241                return Ok(None);
242            }
243            // Front coded, then matched, and the table trained on what is left after both. The
244            // rule this follows is that `INDEPENDENT` is the baseline and a shared table has to
245            // beat whatever the baseline can do, so every transform the baseline gets this path
246            // gets too. Front coding was the first of those and the gap it closed on the generated
247            // URLs here was a factor of two. The matcher is the second and #575 is why: it finds
248            // repeats at a distance, a 255 symbol table of at most eight bytes each does not, and
249            // without it a shared table loses to an independent column by nearly two times on
250            // twenty thousand URLs no matter how good the table is.
251            let coded: Vec<(Vec<i64>, Vec<&[u8]>)> =
252                columns.iter().map(|column| string::front_code(column)).collect();
253            let joined: Vec<Vec<u8>> = coded
254                .iter()
255                .map(|(_, suffixes)| {
256                    let mut buffer = Vec::with_capacity(suffixes.iter().map(|s| s.len()).sum());
257                    for suffix in suffixes {
258                        buffer.extend_from_slice(suffix);
259                    }
260                    buffer
261                })
262                .collect();
263            let tokens: Vec<lz::Tokens<'_>> =
264                joined.iter().map(|bytes| lz::tokens_of(bytes)).collect();
265            let literals: Vec<&[&[u8]]> =
266                tokens.iter().map(|token| token.literals.as_slice()).collect();
267            let table = SymbolTable::train(&shared_sample(&literals));
268            if table.is_empty() {
269                return Ok(None);
270            }
271            table.serialize(&mut out);
272            for (index, (prefixes, suffixes)) in coded.iter().enumerate() {
273                put_u32(
274                    &mut out,
275                    u32::try_from(prefixes.len()).map_err(|_| too_many(prefixes.len()))?,
276                );
277                out.extend_from_slice(&integer::encode(prefixes)?);
278                let sizes: Vec<i64> = suffixes.iter().map(|value| value.len() as i64).collect();
279                out.extend_from_slice(&integer::encode(&sizes)?);
280                out.extend_from_slice(&integer::encode(&tokens[index].lengths)?);
281                out.extend_from_slice(&integer::encode(&tokens[index].offsets)?);
282                let mut compressed = Vec::new();
283                let mut lengths = Vec::with_capacity(tokens[index].literals.len());
284                for run in &tokens[index].literals {
285                    let before = compressed.len();
286                    table.compress(run, &mut compressed);
287                    lengths.push((compressed.len() - before) as i64);
288                }
289                out.extend_from_slice(&integer::encode(&lengths)?);
290                out.extend_from_slice(&compressed);
291            }
292        }
293        Strategy::SharedDict => {
294            if columns.len() < 2 {
295                return Ok(None);
296            }
297            let dictionary = union_values(columns);
298            let total: usize = columns.iter().map(|column| column.len()).sum();
299            // A dictionary holding as many values as the columns do stores everything once and adds
300            // an index on top, so it cannot win and is not worth the encode.
301            if dictionary.is_empty() || dictionary.len() >= total {
302                return Ok(None);
303            }
304            let entries: Vec<&[u8]> = dictionary.iter().map(Vec::as_slice).collect();
305            out.extend_from_slice(&string::encode(&entries)?);
306            for column in columns {
307                let codes = codes_over(column, &dictionary);
308                out.extend_from_slice(&integer::encode(&codes)?);
309            }
310        }
311    }
312    Ok(Some(out))
313}
314
315fn decode_at(reader: &mut Reader<'_>) -> Result<Vec<Vec<Vec<u8>>>> {
316    let strategy = Strategy::from_tag(reader.u8()?)?;
317    let count = reader.u32()? as usize;
318    let mut columns = Vec::with_capacity(count.min(1024));
319    match strategy {
320        Strategy::Independent => {
321            for _ in 0..count {
322                let (values, used) = string::decode_prefix(reader.rest())?;
323                reader.skip(used)?;
324                columns.push(values);
325            }
326        }
327        Strategy::SharedTable => {
328            let (table, used) = SymbolTable::deserialize(reader.rest())?;
329            reader.skip(used)?;
330            for _ in 0..count {
331                let rows = reader.u32()? as usize;
332                let (prefixes, used) = integer::decode_prefix(reader.rest())?;
333                reader.skip(used)?;
334                let (sizes, used) = integer::decode_prefix(reader.rest())?;
335                reader.skip(used)?;
336                let (matched, used) = integer::decode_prefix(reader.rest())?;
337                reader.skip(used)?;
338                let (offsets, used) = integer::decode_prefix(reader.rest())?;
339                reader.skip(used)?;
340                let (lengths, used) = integer::decode_prefix(reader.rest())?;
341                reader.skip(used)?;
342                if sizes.len() != rows || prefixes.len() != rows {
343                    return Err(Error::internal(format!(
344                        "a column says it holds {rows} values and has {} prefixes and {} sizes",
345                        prefixes.len(),
346                        sizes.len()
347                    )));
348                }
349                let mut literals = Vec::with_capacity(lengths.len());
350                for length in lengths {
351                    let length = usize::try_from(length)
352                        .map_err(|_| Error::internal("a negative compressed length"))?;
353                    let compressed = reader.bytes(length)?;
354                    let mut run = Vec::new();
355                    table.decompress(compressed, &mut run)?;
356                    literals.push(run);
357                }
358                let mut total = 0usize;
359                for size in &sizes {
360                    total += usize::try_from(*size)
361                        .map_err(|_| Error::internal("a negative value length"))?;
362                }
363                let bytes = lz::rebuild(&literals, &matched, &offsets, total)?;
364                if bytes.len() != total {
365                    return Err(Error::internal(format!(
366                        "a matched column group rebuilt {} bytes and its values need {total}",
367                        bytes.len()
368                    )));
369                }
370                let mut suffixes = Vec::with_capacity(rows);
371                let mut at = 0;
372                for size in &sizes {
373                    let width = usize::try_from(*size).unwrap_or(0);
374                    suffixes.push(bytes[at..at + width].to_vec());
375                    at += width;
376                }
377                columns.push(string::front_decode(&prefixes, suffixes)?);
378            }
379        }
380        Strategy::SharedDict => {
381            let (dictionary, used) = string::decode_prefix(reader.rest())?;
382            reader.skip(used)?;
383            for _ in 0..count {
384                let (codes, used) = integer::decode_prefix(reader.rest())?;
385                reader.skip(used)?;
386                let mut values = Vec::with_capacity(codes.len());
387                for code in codes {
388                    let entry = usize::try_from(code)
389                        .ok()
390                        .and_then(|index| dictionary.get(index))
391                        .ok_or_else(|| {
392                            Error::internal(format!("code {code} is not in the shared dictionary"))
393                        })?;
394                    values.push(entry.clone());
395                }
396                columns.push(values);
397            }
398        }
399    }
400    Ok(columns)
401}
402
403fn describe_at(reader: &mut Reader<'_>) -> Result<String> {
404    let strategy = Strategy::from_tag(reader.u8()?)?;
405    let count = reader.u32()? as usize;
406    let mut parts = Vec::with_capacity(count.min(1024));
407    let head = match strategy {
408        Strategy::Independent => {
409            for _ in 0..count {
410                let (text, used) = string::describe_prefix(reader.rest())?;
411                reader.skip(used)?;
412                parts.push(text);
413            }
414            "INDEPENDENT".to_string()
415        }
416        Strategy::SharedTable => {
417            let (table, used) = SymbolTable::deserialize(reader.rest())?;
418            reader.skip(used)?;
419            for _ in 0..count {
420                let rows = reader.u32()? as usize;
421                let (prefixes, used) = integer::describe_prefix(reader.rest())?;
422                reader.skip(used)?;
423                let (sizes, used) = integer::describe_prefix(reader.rest())?;
424                reader.skip(used)?;
425                if rows == 0 && !sizes.is_empty() {
426                    return Err(Error::internal("a column group disagrees with itself"));
427                }
428                let (matched, used) = integer::describe_prefix(reader.rest())?;
429                reader.skip(used)?;
430                let (offsets, used) = integer::describe_prefix(reader.rest())?;
431                reader.skip(used)?;
432                // The same chunk twice, once for its shape and once for the lengths themselves,
433                // which is how the describe knows how far past the payload to step.
434                let (text, _) = integer::describe_prefix(reader.rest())?;
435                let (lengths, used) = integer::decode_prefix(reader.rest())?;
436                reader.skip(used)?;
437                let bytes: i64 = lengths.iter().sum();
438                reader.skip(usize::try_from(bytes).map_err(|_| {
439                    Error::internal("a column group has a negative compressed size")
440                })?)?;
441                parts.push(format!("FRONT({prefixes}, LZ({sizes}, {matched}, {offsets}, {text}))"));
442            }
443            format!("SHARED_TABLE[{}]", table.len())
444        }
445        Strategy::SharedDict => {
446            let (text, used) = string::describe_prefix(reader.rest())?;
447            reader.skip(used)?;
448            for _ in 0..count {
449                let (codes, used) = integer::describe_prefix(reader.rest())?;
450                reader.skip(used)?;
451                parts.push(codes);
452            }
453            format!("SHARED_DICT({text})")
454        }
455    };
456    Ok(format!("{head}({})", parts.join(", ")))
457}
458
459/// A sample of all the columns together, with the byte budget shared out in proportion to how big
460/// the columns are and a floor so that a small column still gets looked at.
461///
462/// The total budget is the same one a single column gets, because a shared table that trained on
463/// six times the sample would win partly on the sample size and the report would credit sharing
464/// with something sharing did not do.
465///
466/// Splitting that budget evenly is the obvious thing and it is wrong. A group of one column of
467/// 20,000 values and one of 40 is dominated by the first, and giving the first half the budget
468/// costs it more than the second gains: measured on exactly that pair, an even split made the
469/// shared table 220,755 bytes against 218,099 for encoding the two columns independently, so
470/// sharing lost. In proportion, the dominant column keeps nearly all of the budget and its table is
471/// nearly the table it would have had alone, while the floor is what buys the small column a table
472/// trained on far more evidence than its own 40 values could provide.
473fn shared_sample<'a>(columns: &[&[&'a [u8]]]) -> Vec<&'a [u8]> {
474    let sizes: Vec<usize> =
475        columns.iter().map(|column| column.iter().map(|value| value.len()).sum()).collect();
476    let total: usize = sizes.iter().sum();
477    let floor = FLOOR_SAMPLE_BYTES;
478    let mut sample = Vec::new();
479    for (column, bytes) in columns.iter().zip(&sizes) {
480        let share = if total == 0 {
481            floor
482        } else {
483            (string::SAMPLE_BYTES as u128 * *bytes as u128 / total as u128) as usize
484        };
485        sample.extend(string::sample_bytes_of(column, share.max(floor)));
486    }
487    sample
488}
489
490/// The distinct values across all the columns, sorted, which is the shared dictionary.
491fn union_values(columns: &[&[&[u8]]]) -> Vec<Vec<u8>> {
492    let mut values: Vec<Vec<u8>> =
493        columns.iter().flat_map(|column| column.iter().map(|value| value.to_vec())).collect();
494    values.sort_unstable();
495    values.dedup();
496    values
497}
498
499fn codes_over(values: &[&[u8]], dictionary: &[Vec<u8>]) -> Vec<i64> {
500    values
501        .iter()
502        .map(|value| {
503            dictionary
504                .binary_search_by(|entry| entry.as_slice().cmp(value))
505                .expect("the dictionary is the union of the columns in this group")
506                as i64
507        })
508        .collect()
509}
510
511fn too_many(count: usize) -> Error {
512    Error::internal(format!("a column group of {count} is larger than the format allows"))
513}
514
515fn put_u32(out: &mut Vec<u8>, value: u32) {
516    out.extend_from_slice(&value.to_le_bytes());
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    /// URLs from one host, so two columns built from different hosts share an alphabet and no
524    /// values at all.
525    fn urls(host: &str, count: usize, from: usize) -> Vec<Vec<u8>> {
526        let paths = ["/index.html", "/catalog/item", "/search", "/user/profile/settings"];
527        (from..from + count)
528            .map(|index| {
529                let path = paths[(index / 3) % paths.len()];
530                format!("http://{host}{path}?session={}&ref=google", index * 7).into_bytes()
531            })
532            .collect()
533    }
534
535    fn borrow(values: &[Vec<u8>]) -> Vec<&[u8]> {
536        values.iter().map(Vec::as_slice).collect()
537    }
538
539    fn group<'a>(columns: &'a [Vec<&'a [u8]>]) -> Vec<&'a [&'a [u8]]> {
540        columns.iter().map(Vec::as_slice).collect()
541    }
542
543    fn round_trip(columns: &[&[&[u8]]]) -> Vec<u8> {
544        let bytes = encode_group(columns).unwrap();
545        let back = decode_group(&bytes).unwrap();
546        assert_eq!(back.len(), columns.len());
547        for (decoded, original) in back.iter().zip(columns) {
548            assert_eq!(decoded.len(), original.len(), "{}", describe(&bytes).unwrap());
549            for (left, right) in decoded.iter().zip(*original) {
550                assert_eq!(left.as_slice(), *right, "{}", describe(&bytes).unwrap());
551            }
552        }
553        bytes
554    }
555
556    fn strategy_of(bytes: &[u8]) -> Strategy {
557        Strategy::from_tag(bytes[0]).unwrap()
558    }
559
560    fn size_of(sizes: &[(Strategy, usize)], strategy: Strategy) -> usize {
561        sizes
562            .iter()
563            .find(|(kind, _)| *kind == strategy)
564            .map(|(_, size)| *size)
565            .unwrap_or_else(|| panic!("{} did not apply", strategy.name()))
566    }
567
568    #[test]
569    fn two_columns_from_the_same_universe_share_a_dictionary() {
570        // The `URL` and `Referer` case. Both columns are URLs and most of the values in one are in
571        // the other, so the union is stored once instead of the intersection being stored twice.
572        let left = urls("www.example.com", 20_000, 0);
573        let right = urls("www.example.com", 20_000, 5_000);
574        let columns = [borrow(&left), borrow(&right)];
575        let group = group(&columns);
576        let bytes = round_trip(&group);
577        assert_eq!(strategy_of(&bytes), Strategy::SharedDict);
578        let sizes = strategy_sizes(&group).unwrap();
579        let independent = size_of(&sizes, Strategy::Independent);
580        // Three quarters of the values are in both columns, and the saving is a third rather than
581        // a half because the codes still cost something once the values stop being repeated.
582        assert!(
583            bytes.len() * 4 < independent * 3,
584            "{} against {independent} independent",
585            bytes.len()
586        );
587    }
588
589    #[test]
590    fn two_columns_of_the_same_alphabet_share_a_symbol_table() {
591        // No value appears in both columns, so a dictionary has nothing to share. The alphabet is
592        // the same, which is what a symbol table can still share.
593        //
594        // Four thousand values a column, and that number is doing work. It used to be a thousand,
595        // on the reading that sharing wins on small columns because a 255 symbol table is a fixed
596        // cost two columns of that size pay twice, and stops winning once each column can train a
597        // table that fits it better than a joint one. Adding the matcher to both paths inverted
598        // that, which is worth recording because the old reading sounded obviously right:
599        //
600        // ```text
601        //    100  shared     1507  independent     1406
602        //    500  shared     4109  independent     3946
603        //   1000  shared     6195  independent     5187
604        //   2000  shared    11574  independent    11932
605        //   4000  shared    21389  independent    21789
606        // ```
607        //
608        // What changed is what the table is trained on. It used to see front coded suffixes, which
609        // are most of the column, so a table trained on two columns of a thousand values had plenty
610        // to work with. It now sees only the literal runs the matcher did not cover, which on a
611        // small column is not much, and the independent path can send those runs back through the
612        // whole string cascade and pick something other than FSST for them while this path cannot.
613        // So sharing now pays where there are enough literals for a joint table to be worth more
614        // than that freedom, and that is the larger column rather than the smaller one.
615        let left = urls("www.example.com", 4_000, 0);
616        let right = urls("news.other.example.org", 4_000, 500_000);
617        let columns = [borrow(&left), borrow(&right)];
618        let group = group(&columns);
619        let bytes = round_trip(&group);
620        let sizes = strategy_sizes(&group).unwrap();
621        let shared = size_of(&sizes, Strategy::SharedTable);
622        let independent = size_of(&sizes, Strategy::Independent);
623        assert!(shared < independent, "{shared} against {independent} independent");
624        assert_eq!(strategy_of(&bytes), Strategy::SharedTable);
625    }
626
627    #[test]
628    fn a_small_column_gains_most_from_a_shared_table() {
629        // The reason sharing a table is worth more than the bytes of the table. A column of 40
630        // values has nothing to train on, and a table trained on the group is a table that has.
631        let big = urls("www.example.com", 20_000, 0);
632        let small = urls("www.example.com", 40, 900_000);
633        let columns = [borrow(&big), borrow(&small)];
634        let sizes = strategy_sizes(&group(&columns)).unwrap();
635        let shared = size_of(&sizes, Strategy::SharedTable);
636        let independent = size_of(&sizes, Strategy::Independent);
637        assert!(shared < independent, "group {shared} against {independent} apart");
638    }
639
640    #[test]
641    fn unrelated_columns_are_left_alone() {
642        let urls = urls("www.example.com", 4_000, 0);
643        let numbers: Vec<Vec<u8>> = (0..4_000)
644            .map(|index| format!("{:016x}", index * 2_654_435_761u64).into_bytes())
645            .collect();
646        let columns = [borrow(&urls), borrow(&numbers)];
647        let group = group(&columns);
648        let bytes = round_trip(&group);
649        assert_eq!(strategy_of(&bytes), Strategy::Independent);
650    }
651
652    #[test]
653    fn a_group_of_one_is_the_column_on_its_own() {
654        let column = urls("www.example.com", 2_000, 0);
655        let columns = [borrow(&column)];
656        let bytes = round_trip(&group(&columns));
657        assert_eq!(strategy_of(&bytes), Strategy::Independent);
658        assert_eq!(bytes.len(), 5 + string::encode(&borrow(&column)).unwrap().len());
659    }
660
661    #[test]
662    fn an_empty_group_round_trips() {
663        let bytes = round_trip(&[]);
664        assert_eq!(decode_group(&bytes).unwrap().len(), 0);
665    }
666
667    #[test]
668    fn columns_do_not_have_to_be_the_same_length() {
669        let left = urls("www.example.com", 3_000, 0);
670        let right = urls("www.example.com", 700, 1_000);
671        let columns = [borrow(&left), borrow(&right)];
672        round_trip(&group(&columns));
673    }
674
675    #[test]
676    fn an_empty_column_in_a_group_round_trips() {
677        let left = urls("www.example.com", 1_000, 0);
678        let empty: Vec<Vec<u8>> = Vec::new();
679        let columns = [borrow(&left), borrow(&empty)];
680        round_trip(&group(&columns));
681    }
682
683    #[test]
684    fn every_strategy_that_applies_decodes_to_the_input() {
685        let left = urls("www.example.com", 3_000, 0);
686        let right = urls("www.example.com", 3_000, 1_000);
687        let columns = [borrow(&left), borrow(&right)];
688        let group = group(&columns);
689        for strategy in [Strategy::Independent, Strategy::SharedTable, Strategy::SharedDict] {
690            let bytes = encode_as(strategy, &group).unwrap().unwrap();
691            let back = decode_group(&bytes).unwrap();
692            assert_eq!(back[0].len(), left.len(), "{}", strategy.name());
693            assert_eq!(back[1][7], right[7], "{}", strategy.name());
694        }
695    }
696
697    #[test]
698    fn the_chooser_picks_the_smallest_strategy() {
699        let left = urls("www.example.com", 2_000, 0);
700        let right = urls("www.example.com", 2_000, 1_000);
701        let columns = [borrow(&left), borrow(&right)];
702        let group = group(&columns);
703        let chosen = encode_group(&group).unwrap();
704        for (_, size) in strategy_sizes(&group).unwrap() {
705            assert!(chosen.len() <= size);
706        }
707    }
708
709    #[test]
710    fn describe_says_what_every_column_came_out_as() {
711        let left = urls("www.example.com", 2_000, 0);
712        let right = urls("www.example.com", 2_000, 1_000);
713        let columns = [borrow(&left), borrow(&right)];
714        let group = group(&columns);
715        for strategy in [Strategy::Independent, Strategy::SharedTable, Strategy::SharedDict] {
716            let bytes = encode_as(strategy, &group).unwrap().unwrap();
717            let shape = describe(&bytes).unwrap();
718            assert!(shape.starts_with(strategy.name()), "{shape}");
719            assert!(shape.contains(", "), "{shape}");
720        }
721    }
722
723    #[test]
724    fn a_truncated_group_is_an_error_and_not_a_panic() {
725        let left = urls("www.example.com", 40, 0);
726        let right = urls("www.example.com", 40, 20);
727        let columns = [borrow(&left), borrow(&right)];
728        let group = group(&columns);
729        for strategy in [Strategy::Independent, Strategy::SharedTable, Strategy::SharedDict] {
730            let bytes = encode_as(strategy, &group).unwrap().unwrap();
731            for len in 0..bytes.len() {
732                assert!(
733                    decode_group(&bytes[..len]).is_err(),
734                    "{} decoded at {len} bytes",
735                    strategy.name()
736                );
737            }
738        }
739    }
740
741    #[test]
742    fn trailing_bytes_are_an_error() {
743        let column = urls("www.example.com", 10, 0);
744        let columns = [borrow(&column)];
745        let mut bytes = encode_group(&group(&columns)).unwrap();
746        bytes.push(0);
747        let error = decode_group(&bytes).unwrap_err();
748        assert!(error.message().contains("left over"), "{error}");
749    }
750
751    #[test]
752    fn an_unknown_tag_is_an_error() {
753        let error = decode_group(&[9, 0, 0, 0, 0]).unwrap_err();
754        assert!(error.message().contains("unknown column group tag"), "{error}");
755    }
756
757    #[test]
758    fn a_code_outside_the_shared_dictionary_is_an_error() {
759        let mut bytes = vec![Strategy::SharedDict.tag()];
760        put_u32(&mut bytes, 1);
761        bytes.extend_from_slice(&string::encode(&[b"one".as_slice()]).unwrap());
762        bytes.extend_from_slice(&integer::encode(&[4]).unwrap());
763        let error = decode_group(&bytes).unwrap_err();
764        assert!(error.message().contains("not in the shared dictionary"), "{error}");
765    }
766
767    #[test]
768    fn overlapping_columns_are_grouped_and_the_rest_are_not() {
769        let first = urls("www.example.com", 20_000, 0);
770        let second = urls("news.other.example.org", 20_000, 0);
771        let third = urls("www.example.com", 20_000, 4_000);
772        let fourth = urls("news.other.example.org", 20_000, 4_000);
773        let sketches: Vec<Sketch> = [&first, &second, &third, &fourth]
774            .iter()
775            .map(|column| Sketch::of(&borrow(column)))
776            .collect();
777        let groups = dictionary_groups(&sketches, 0.5).unwrap();
778        assert_eq!(groups, vec![vec![0, 2], vec![1, 3]]);
779    }
780
781    #[test]
782    fn a_column_that_overlaps_nothing_is_a_group_of_one() {
783        let sketches: Vec<Sketch> = (0..4)
784            .map(|index| Sketch::of(&borrow(&urls("www.example.com", 5_000, index * 100_000))))
785            .collect();
786        let groups = dictionary_groups(&sketches, 0.5).unwrap();
787        assert_eq!(groups, vec![vec![0], vec![1], vec![2], vec![3]]);
788    }
789
790    #[test]
791    fn grouping_is_transitive_and_does_not_need_every_pair_to_overlap() {
792        // A overlaps B by half, B overlaps C by half, A and C not at all. They still go together,
793        // because a dictionary is a union and a chain of overlaps still stores fewer values once
794        // than three sets store separately.
795        let a = urls("www.example.com", 20_000, 0);
796        let b = urls("www.example.com", 20_000, 10_000);
797        let c = urls("www.example.com", 20_000, 20_000);
798        let sketches: Vec<Sketch> =
799            [&a, &b, &c].iter().map(|column| Sketch::of(&borrow(column))).collect();
800        assert!(sketches[0].jaccard(&sketches[2]).unwrap() < 0.01);
801        let groups = dictionary_groups(&sketches, 0.3).unwrap();
802        assert_eq!(groups, vec![vec![0, 1, 2]]);
803    }
804
805    #[test]
806    fn grouping_needs_sketches_of_the_same_size() {
807        let sketches = [Sketch::new(16).unwrap(), Sketch::new(32).unwrap()];
808        assert!(dictionary_groups(&sketches, 0.5).is_err());
809    }
810}