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