Skip to main content

nodedb_columnar/writer/
segment_writer.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `SegmentWriter` and codec selection for compressed columnar segments.
4
5use nodedb_codec::{ColumnCodec, ColumnTypeHint, ResolvedColumnCodec};
6use nodedb_mem::ScopedMemory;
7use nodedb_types::columnar::{ColumnType, ColumnarSchema};
8
9use crate::error::ColumnarError;
10use crate::format::{ColumnMeta, HEADER_SIZE, SegmentFooter, SegmentHeader};
11use crate::memtable::ColumnData;
12
13use super::block::{block_layout, encode_column_blocks};
14use super::encode::compute_schema_hash;
15
16/// Profile tag values for the segment footer.
17pub const PROFILE_PLAIN: u8 = 0;
18pub const PROFILE_TIMESERIES: u8 = 1;
19pub const PROFILE_SPATIAL: u8 = 2;
20
21/// Writes a drained memtable into a complete segment byte buffer.
22///
23/// The segment is self-contained: header identifies the format, column
24/// blocks store compressed data, and the footer enables random access to
25/// any column without scanning the entire file.
26pub struct SegmentWriter {
27    profile_tag: u8,
28    /// Tracks working-buffer allocations against the bound database, tenant, and engine budget.
29    memory: ScopedMemory,
30}
31
32impl SegmentWriter {
33    /// Create a writer for the given profile, tracking allocations against `memory`.
34    pub fn new(profile_tag: u8, memory: ScopedMemory) -> Self {
35        Self {
36            profile_tag,
37            memory,
38        }
39    }
40
41    /// Encode a drained memtable into a segment byte buffer.
42    ///
43    /// `schema` is the column schema, `columns` are the drained column data,
44    /// `row_count` is the total number of rows.
45    ///
46    /// When `kek` is `Some`, the assembled plaintext segment is wrapped in an
47    /// AES-256-GCM encrypted `SEGC` envelope before being returned. When
48    /// `None`, the raw `NDBS` segment bytes are returned.
49    pub fn write_segment(
50        &self,
51        schema: &ColumnarSchema,
52        columns: &[ColumnData],
53        row_count: usize,
54        kek: Option<&nodedb_wal::crypto::WalEncryptionKey>,
55    ) -> Result<Vec<u8>, ColumnarError> {
56        if row_count == 0 {
57            return Err(ColumnarError::EmptyMemtable);
58        }
59        if columns.len() != schema.columns.len() {
60            return Err(ColumnarError::SchemaMismatch {
61                expected: schema.columns.len(),
62                got: columns.len(),
63            });
64        }
65
66        let mut buf = Vec::new();
67
68        // 1. Write header.
69        buf.extend_from_slice(&SegmentHeader::current().to_bytes());
70
71        // 2. Encode each column's blocks.
72        let _metas_guard = self
73            .memory
74            .reserve(columns.len() * std::mem::size_of::<ColumnMeta>())?;
75        let mut column_metas = Vec::with_capacity(columns.len());
76
77        for (i, (col_def, col_data)) in schema.columns.iter().zip(columns.iter()).enumerate() {
78            let col_start = buf.len() as u64;
79
80            // Select codec for this column type.
81            let codec = select_codec_for_profile(&col_def.column_type, self.profile_tag);
82
83            // Encode blocks.
84            let block_stats = encode_column_blocks(
85                &mut buf,
86                &col_def.name,
87                col_data,
88                codec,
89                row_count,
90                &self.memory,
91            )?;
92
93            let col_end = buf.len() as u64;
94
95            // For DictEncoded columns, the codec stored in meta is DeltaFastLanesLz4 (IDs),
96            // and the dictionary strings are stored in the meta for reader reconstruction.
97            let (effective_codec, dictionary) = match col_data {
98                ColumnData::DictEncoded { dictionary, .. } => (
99                    ResolvedColumnCodec::DeltaFastLanesLz4,
100                    Some(dictionary.clone()),
101                ),
102                _ => (codec, None),
103            };
104
105            column_metas.push(ColumnMeta {
106                name: col_def.name.clone(),
107                offset: col_start - HEADER_SIZE as u64,
108                length: col_end - col_start,
109                codec: effective_codec,
110                layout: block_layout(col_data),
111                block_count: block_stats.len() as u32,
112                block_stats,
113                dictionary,
114            });
115
116            let _ = i; // Satisfy clippy about unused index.
117        }
118
119        // 3. Compute schema hash (simple hash of column names + types).
120        let schema_hash = compute_schema_hash(schema);
121
122        // 4. Write footer.
123        let footer = SegmentFooter {
124            schema_hash,
125            column_count: schema.columns.len() as u32,
126            row_count: row_count as u64,
127            profile_tag: self.profile_tag,
128            columns: column_metas,
129        };
130        let footer_bytes = footer.to_bytes()?;
131        buf.extend_from_slice(&footer_bytes);
132
133        // Optionally wrap the plaintext segment in an AES-256-GCM SEGC envelope.
134        if let Some(key) = kek {
135            return crate::encrypt::encrypt_segment(key, &buf);
136        }
137
138        Ok(buf)
139    }
140}
141
142/// Select the best codec for a column type, with profile-aware overrides.
143///
144/// For timeseries profiles (tag=1), Float64 metric columns use Gorilla XOR
145/// encoding when the data is monotonic/slowly-changing. For other profiles,
146/// the standard auto-detection pipeline applies.
147///
148/// Always returns a `ResolvedColumnCodec` — `Auto` is never returned.
149pub fn select_codec_for_profile(col_type: &ColumnType, profile_tag: u8) -> ResolvedColumnCodec {
150    // Timeseries profile: prefer Gorilla for Float64 metrics.
151    if profile_tag == PROFILE_TIMESERIES && matches!(col_type, ColumnType::Float64) {
152        return ResolvedColumnCodec::Gorilla;
153    }
154    // Timeseries profile: delta-of-delta for timestamps (both naive and tz-aware).
155    if profile_tag == PROFILE_TIMESERIES
156        && matches!(col_type, ColumnType::Timestamp | ColumnType::Timestamptz)
157    {
158        return ResolvedColumnCodec::DeltaFastLanesLz4;
159    }
160    select_codec(col_type)
161}
162
163/// Select the best codec for a column type using nodedb-codec's auto-detection.
164///
165/// Always returns a `ResolvedColumnCodec` — `Auto` is consumed here and
166/// never forwarded downstream.
167fn select_codec(col_type: &ColumnType) -> ResolvedColumnCodec {
168    let hint = match col_type {
169        ColumnType::Int64 => ColumnTypeHint::Int64,
170        ColumnType::Float64 => ColumnTypeHint::Float64,
171        ColumnType::Timestamp | ColumnType::Timestamptz | ColumnType::SystemTimestamp => {
172            ColumnTypeHint::Timestamp
173        }
174        ColumnType::String
175        | ColumnType::Geometry
176        | ColumnType::Regex
177        | ColumnType::SparseVector => ColumnTypeHint::String,
178        ColumnType::Bool
179        | ColumnType::Bytes
180        | ColumnType::Decimal { .. }
181        | ColumnType::Uuid
182        | ColumnType::Ulid => {
183            return ResolvedColumnCodec::Lz4;
184        }
185        ColumnType::Json
186        | ColumnType::Array
187        | ColumnType::Set
188        | ColumnType::Range
189        | ColumnType::Record => {
190            return ResolvedColumnCodec::Lz4;
191        }
192        ColumnType::Duration => ColumnTypeHint::Int64, // i64 microseconds
193        ColumnType::Vector(_) => {
194            return ResolvedColumnCodec::Lz4;
195        }
196        // ColumnType is #[non_exhaustive]; unknown types default to Lz4
197        // general-purpose compression until a dedicated codec is registered.
198        _ => {
199            return ResolvedColumnCodec::Lz4;
200        }
201    };
202    // detect_codec resolves Auto via the type hint and always returns a
203    // concrete codec. Map any unexpected Auto back to a safe default
204    // (Lz4) rather than panicking in library code.
205    nodedb_codec::detect_codec(ColumnCodec::Auto, hint)
206        .try_resolve()
207        .unwrap_or(ResolvedColumnCodec::Lz4)
208}
209
210#[cfg(test)]
211mod tests {
212    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
213    use nodedb_types::value::Value;
214
215    use super::*;
216    use crate::format::{SegmentFooter, SegmentHeader};
217    use crate::memtable::ColumnarMemtable;
218    use crate::test_support::test_memory;
219
220    fn analytics_schema() -> ColumnarSchema {
221        ColumnarSchema::new(vec![
222            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
223            ColumnDef::required("name", ColumnType::String),
224            ColumnDef::nullable("score", ColumnType::Float64),
225        ])
226        .expect("valid")
227    }
228
229    // ── ResolvedColumnCodec integration tests ─────────────────────────────────
230
231    /// The writer resolves Auto to a concrete codec before writing.
232    /// The resulting footer must not contain codec byte 0 (Auto discriminant).
233    #[test]
234    fn auto_codec_resolves_to_concrete_before_write() {
235        let schema = ColumnarSchema::new(vec![
236            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
237            ColumnDef::required("name", ColumnType::String),
238            ColumnDef::nullable("score", ColumnType::Float64),
239        ])
240        .expect("valid");
241
242        let mut mt = ColumnarMemtable::new(&schema);
243        for i in 0..10 {
244            mt.append_row(&[
245                Value::Integer(i),
246                Value::String(format!("item_{i}")),
247                Value::Float(i as f64 * 1.5),
248            ])
249            .expect("append");
250        }
251        let (schema, columns, row_count) = mt.drain();
252        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
253        let segment = writer
254            .write_segment(&schema, &columns, row_count, None)
255            .expect("write must succeed");
256
257        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
258
259        // None of the column codecs should be the Auto discriminant (byte 0).
260        // All must be concrete, resolved codecs.
261        for col in &footer.columns {
262            // Auto does not exist on ResolvedColumnCodec so the type itself
263            // guarantees this — but we can also serialize and verify the
264            // discriminant byte is not 0.
265            let encoded = zerompk::to_msgpack_vec(&col.codec).expect("serialize");
266            // msgpack c_enum encodes as a single byte when value < 128.
267            // The last byte in the encoded form holds the discriminant.
268            let discriminant_byte = *encoded.last().expect("non-empty");
269            assert_ne!(
270                discriminant_byte, 0,
271                "column '{}' has Auto discriminant (0) on disk — resolve was skipped",
272                col.name
273            );
274        }
275    }
276
277    /// The writer resolves Auto to a sensible non-trivial codec for Int64 columns.
278    #[test]
279    fn auto_codec_int64_resolves_to_non_raw() {
280        use nodedb_codec::ResolvedColumnCodec;
281
282        let schema = ColumnarSchema::new(vec![
283            ColumnDef::required("val", ColumnType::Int64).with_primary_key(),
284        ])
285        .expect("valid");
286
287        let mut mt = ColumnarMemtable::new(&schema);
288        for i in 0..10 {
289            mt.append_row(&[Value::Integer(i)]).expect("append");
290        }
291        let (schema, columns, row_count) = mt.drain();
292        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
293        let segment = writer
294            .write_segment(&schema, &columns, row_count, None)
295            .expect("write");
296        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
297
298        // Auto for Int64 should resolve to a delta/int codec, never Raw or Auto.
299        let codec = footer.columns[0].codec;
300        assert_ne!(
301            codec,
302            ResolvedColumnCodec::Raw,
303            "Int64 should not resolve to Raw"
304        );
305    }
306
307    #[test]
308    fn write_segment_roundtrip() {
309        let schema = analytics_schema();
310        let mut mt = ColumnarMemtable::new(&schema);
311
312        for i in 0..100 {
313            mt.append_row(&[
314                Value::Integer(i),
315                Value::String(format!("user_{i}")),
316                if i % 3 == 0 {
317                    Value::Null
318                } else {
319                    Value::Float(i as f64 * 0.25)
320                },
321            ])
322            .expect("append");
323        }
324
325        let (schema, columns, row_count) = mt.drain();
326        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
327        let segment = writer
328            .write_segment(&schema, &columns, row_count, None)
329            .expect("write");
330
331        // Verify header.
332        let header = SegmentHeader::from_bytes(&segment).expect("valid header");
333        assert_eq!(header.magic, *b"NDBS");
334        assert_eq!(header.version_major, 1);
335
336        // Verify footer.
337        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
338        assert_eq!(footer.column_count, 3);
339        assert_eq!(footer.row_count, 100);
340        assert_eq!(footer.profile_tag, PROFILE_PLAIN);
341        assert_eq!(footer.columns.len(), 3);
342
343        // Verify column metadata.
344        assert_eq!(footer.columns[0].name, "id");
345        assert_eq!(footer.columns[1].name, "name");
346        assert_eq!(footer.columns[2].name, "score");
347
348        // Each column should have 1 block (100 rows < BLOCK_SIZE=1024).
349        assert_eq!(footer.columns[0].block_count, 1);
350        assert_eq!(footer.columns[0].block_stats[0].row_count, 100);
351
352        // id: min=0, max=99.
353        assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
354        assert_eq!(footer.columns[0].block_stats[0].max, 99.0);
355        assert_eq!(footer.columns[0].block_stats[0].null_count, 0);
356
357        // score: 34 nulls (every 3rd row), min=0.25 (row 1), max=99*0.25=24.75 (row 99).
358        assert_eq!(footer.columns[2].block_stats[0].null_count, 34);
359    }
360
361    #[test]
362    fn write_segment_multi_block() {
363        let schema =
364            ColumnarSchema::new(vec![ColumnDef::required("x", ColumnType::Int64)]).expect("valid");
365
366        let mut mt = ColumnarMemtable::new(&schema);
367        for i in 0..2500 {
368            mt.append_row(&[Value::Integer(i)]).expect("append");
369        }
370
371        let (schema, columns, row_count) = mt.drain();
372        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
373        let segment = writer
374            .write_segment(&schema, &columns, row_count, None)
375            .expect("write");
376
377        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
378        assert_eq!(footer.row_count, 2500);
379
380        // 2500 rows / 1024 = 3 blocks (1024 + 1024 + 452).
381        assert_eq!(footer.columns[0].block_count, 3);
382        assert_eq!(footer.columns[0].block_stats[0].row_count, 1024);
383        assert_eq!(footer.columns[0].block_stats[1].row_count, 1024);
384        assert_eq!(footer.columns[0].block_stats[2].row_count, 452);
385
386        // Block 0: min=0, max=1023.
387        assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
388        assert_eq!(footer.columns[0].block_stats[0].max, 1023.0);
389        // Block 2: min=2048, max=2499.
390        assert_eq!(footer.columns[0].block_stats[2].min, 2048.0);
391        assert_eq!(footer.columns[0].block_stats[2].max, 2499.0);
392    }
393
394    #[test]
395    fn write_segment_empty_rejected() {
396        let schema = analytics_schema();
397        let mt = ColumnarMemtable::new(&schema);
398        let (schema, columns, row_count) = {
399            let mut m = mt;
400            m.drain()
401        };
402        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
403        assert!(matches!(
404            writer.write_segment(&schema, &columns, row_count, None),
405            Err(ColumnarError::EmptyMemtable)
406        ));
407    }
408
409    #[test]
410    fn block_stats_predicate_pushdown() {
411        let schema = analytics_schema();
412        let mut mt = ColumnarMemtable::new(&schema);
413
414        for i in 0..50 {
415            mt.append_row(&[
416                Value::Integer(i + 100),
417                Value::String(format!("item_{i}")),
418                Value::Float(i as f64 + 10.0),
419            ])
420            .expect("append");
421        }
422
423        let (schema, columns, row_count) = mt.drain();
424        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
425        let segment = writer
426            .write_segment(&schema, &columns, row_count, None)
427            .expect("write");
428        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid");
429
430        use crate::predicate::ScanPredicate;
431
432        let id_stats = &footer.columns[0].block_stats[0];
433        // id: min=100, max=149.
434        assert!(ScanPredicate::gt(0, 200.0).can_skip_block(id_stats)); // WHERE id > 200 → skip.
435        assert!(!ScanPredicate::gt(0, 120.0).can_skip_block(id_stats)); // WHERE id > 120 → cannot skip.
436        assert!(ScanPredicate::lt(0, 50.0).can_skip_block(id_stats)); // WHERE id < 50 → skip.
437        assert!(ScanPredicate::eq(0, 200.0).can_skip_block(id_stats)); // WHERE id = 200 → skip.
438        assert!(!ScanPredicate::eq(0, 125.0).can_skip_block(id_stats)); // WHERE id = 125 → cannot skip.
439    }
440
441    #[test]
442    fn string_block_stats_zone_map() {
443        // Write a segment with known string values, then verify str_min/str_max.
444        let schema = ColumnarSchema::new(vec![ColumnDef::required("tag", ColumnType::String)])
445            .expect("valid");
446
447        let mut mt = ColumnarMemtable::new(&schema);
448        // Insert > 16 distinct values to trigger bloom filter construction.
449        // Lexicographic order: apple < banana < cherry < date (first/last matter for zone map).
450        let values: Vec<String> = (0..20).map(|i| format!("item_{i:02}")).collect();
451        for name in &values {
452            mt.append_row(&[Value::String(name.clone())])
453                .expect("append");
454        }
455        // Add known boundary values for zone-map assertions.
456        mt.append_row(&[Value::String("apple".into())])
457            .expect("append");
458        mt.append_row(&[Value::String("date".into())])
459            .expect("append");
460
461        let (schema, columns, row_count) = mt.drain();
462        let writer = SegmentWriter::new(PROFILE_PLAIN, test_memory());
463        let segment = writer
464            .write_segment(&schema, &columns, row_count, None)
465            .expect("write");
466        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
467
468        let stats = &footer.columns[0].block_stats[0];
469        assert!(stats.str_min.is_some(), "str_min should be populated");
470        assert!(stats.str_max.is_some(), "str_max should be populated");
471        // "apple" is lex smallest, "item_19" is lex largest (> "date").
472        assert_eq!(stats.str_min.as_deref(), Some("apple"));
473        assert_eq!(stats.str_max.as_deref(), Some("item_19"));
474
475        // Bloom filter should be present (>16 distinct values).
476        assert!(
477            stats.bloom.is_some(),
478            "bloom should be populated for >16 distinct values"
479        );
480
481        use crate::predicate::ScanPredicate;
482
483        // WHERE tag = "aaa" → below "apple" → skip.
484        assert!(ScanPredicate::str_eq(0, "aaa").can_skip_block(stats));
485        // WHERE tag = "zzz" → above "item_19" → skip.
486        assert!(ScanPredicate::str_eq(0, "zzz").can_skip_block(stats));
487        // WHERE tag = "date" → in range [apple, item_19], inserted in bloom → cannot skip.
488        assert!(!ScanPredicate::str_eq(0, "date").can_skip_block(stats));
489        // WHERE tag > "item_19" → smax ≤ value → skip.
490        assert!(ScanPredicate::str_gt(0, "item_19").can_skip_block(stats));
491        // WHERE tag < "apple" → smin ≥ value → skip.
492        assert!(ScanPredicate::str_lt(0, "apple").can_skip_block(stats));
493    }
494
495    /// timestamps in the year-2300+ microsecond range (far above 2^53)
496    /// must be recorded losslessly in `min_i64`/`max_i64` and predicate
497    /// pushdown must not false-skip a block that contains the target value.
498    #[test]
499    fn timestamp_large_value_roundtrip() {
500        use crate::predicate::ScanPredicate;
501
502        let schema = ColumnarSchema::new(vec![
503            ColumnDef::required("ts", ColumnType::Timestamp).with_primary_key(),
504        ])
505        .expect("valid schema");
506
507        // Year-2300 in microseconds since epoch.
508        // 2300-01-01T00:00:00Z ≈ 10_413_792_000_000_000 µs
509        // These values are well above 2^53 = 9_007_199_254_740_992.
510        let base: i64 = 10_413_792_000_000_000;
511        let target = base + 500_000; // half a second later
512
513        let mut mt = ColumnarMemtable::new(&schema);
514        for delta in 0..10i64 {
515            mt.append_row(&[Value::Integer(base + delta * 100_000)])
516                .expect("append");
517        }
518
519        let (schema, columns, row_count) = mt.drain();
520        let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
521            .write_segment(&schema, &columns, row_count, None)
522            .expect("write");
523        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
524
525        let stats = &footer.columns[0].block_stats[0];
526
527        // Exact i64 fields must be populated.
528        assert!(
529            stats.min_i64.is_some(),
530            "min_i64 must be set for timestamp columns"
531        );
532        assert!(
533            stats.max_i64.is_some(),
534            "max_i64 must be set for timestamp columns"
535        );
536        assert_eq!(stats.min_i64.unwrap(), base);
537        assert_eq!(stats.max_i64.unwrap(), base + 9 * 100_000);
538
539        // Predicate: ts = target (base + 500_000) → in [base, base+900_000] → must NOT skip.
540        assert!(
541            !ScanPredicate::eq_i64(0, target).can_skip_block(stats),
542            "must not skip: target={target} is within the block range"
543        );
544
545        // Predicate: ts = base - 1 → below min → must skip.
546        assert!(
547            ScanPredicate::eq_i64(0, base - 1).can_skip_block(stats),
548            "must skip: base-1 is below block min"
549        );
550
551        // The f64 path is broken for these values (min, max, target all round
552        // to the same f64 or nearby indistinguishable values).
553        let min_f64 = base as f64;
554        let target_f64 = target as f64;
555        let max_f64 = (base + 9 * 100_000) as f64;
556        // Verify the f64 representation is unreliable for this range.
557        // (min_f64 == target_f64 if the gap < ULP, which it is at this scale.)
558        let _ = (min_f64, target_f64, max_f64); // suppress unused warnings
559    }
560
561    #[test]
562    fn integer_block_stats_have_exact_i64_fields() {
563        // Verify that Int64 columns also populate min_i64/max_i64.
564        let schema = ColumnarSchema::new(vec![
565            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
566        ])
567        .expect("valid");
568
569        let mut mt = ColumnarMemtable::new(&schema);
570        for i in 0..5i64 {
571            mt.append_row(&[Value::Integer(i64::MAX - 4 + i)])
572                .expect("append");
573        }
574
575        let (schema, columns, row_count) = mt.drain();
576        let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
577            .write_segment(&schema, &columns, row_count, None)
578            .expect("write");
579        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
580
581        let stats = &footer.columns[0].block_stats[0];
582        assert_eq!(stats.min_i64, Some(i64::MAX - 4));
583        assert_eq!(stats.max_i64, Some(i64::MAX));
584
585        // eq_i64 must not skip for a value in the middle.
586        use crate::predicate::ScanPredicate;
587        assert!(!ScanPredicate::eq_i64(0, i64::MAX - 2).can_skip_block(stats));
588        // eq_i64 must skip for a value below min.
589        assert!(ScanPredicate::eq_i64(0, i64::MAX - 10).can_skip_block(stats));
590    }
591
592    #[test]
593    fn string_block_stats_bloom_rejects_absent_value() {
594        let schema = ColumnarSchema::new(vec![ColumnDef::required("label", ColumnType::String)])
595            .expect("valid");
596
597        let mut mt = ColumnarMemtable::new(&schema);
598        // Insert > 16 distinct values to trigger bloom construction.
599        let values: Vec<String> = (0..20).map(|i| format!("val_{i:02}")).collect();
600        for name in &values {
601            mt.append_row(&[Value::String(name.clone())])
602                .expect("append");
603        }
604        // Add known values for bloom assertions.
605        mt.append_row(&[Value::String("alpha".into())])
606            .expect("append");
607        mt.append_row(&[Value::String("beta".into())])
608            .expect("append");
609        mt.append_row(&[Value::String("gamma".into())])
610            .expect("append");
611
612        let (schema, columns, row_count) = mt.drain();
613        let segment = SegmentWriter::new(PROFILE_PLAIN, test_memory())
614            .write_segment(&schema, &columns, row_count, None)
615            .expect("write");
616        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
617        let stats = &footer.columns[0].block_stats[0];
618
619        use crate::predicate::{ScanPredicate, bloom_may_contain};
620
621        let bloom = stats
622            .bloom
623            .as_ref()
624            .expect("bloom present for >16 distinct");
625        assert!(bloom_may_contain(bloom, "alpha"));
626        assert!(bloom_may_contain(bloom, "beta"));
627        assert!(bloom_may_contain(bloom, "gamma"));
628
629        // "delta" was not inserted. If bloom says absent, the predicate skips.
630        let delta_absent = !bloom_may_contain(bloom, "delta");
631        if delta_absent {
632            // "delta" is in [alpha, val_19] range → only bloom can skip this.
633            assert!(ScanPredicate::str_eq(0, "delta").can_skip_block(stats));
634        }
635    }
636}