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 | ColumnType::Geometry | ColumnType::Regex => ColumnTypeHint::String,
197        ColumnType::Bool
198        | ColumnType::Bytes
199        | ColumnType::Decimal { .. }
200        | ColumnType::Uuid
201        | ColumnType::Ulid => {
202            return ResolvedColumnCodec::Lz4;
203        }
204        ColumnType::Json
205        | ColumnType::Array
206        | ColumnType::Set
207        | ColumnType::Range
208        | ColumnType::Record => {
209            return ResolvedColumnCodec::Lz4;
210        }
211        ColumnType::Duration => ColumnTypeHint::Int64, // i64 microseconds
212        ColumnType::Vector(_) => {
213            return ResolvedColumnCodec::Lz4;
214        }
215        // ColumnType is #[non_exhaustive]; unknown types default to Lz4
216        // general-purpose compression until a dedicated codec is registered.
217        _ => {
218            return ResolvedColumnCodec::Lz4;
219        }
220    };
221    // detect_codec resolves Auto via the type hint and always returns a
222    // concrete codec. Map any unexpected Auto back to a safe default
223    // (Lz4) rather than panicking in library code.
224    nodedb_codec::detect_codec(ColumnCodec::Auto, hint)
225        .try_resolve()
226        .unwrap_or(ResolvedColumnCodec::Lz4)
227}
228
229#[cfg(test)]
230mod tests {
231    use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
232    use nodedb_types::value::Value;
233
234    use super::*;
235    use crate::format::{SegmentFooter, SegmentHeader};
236    use crate::memtable::ColumnarMemtable;
237
238    fn analytics_schema() -> ColumnarSchema {
239        ColumnarSchema::new(vec![
240            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
241            ColumnDef::required("name", ColumnType::String),
242            ColumnDef::nullable("score", ColumnType::Float64),
243        ])
244        .expect("valid")
245    }
246
247    // ── ResolvedColumnCodec integration tests ─────────────────────────────────
248
249    /// The writer resolves Auto to a concrete codec before writing.
250    /// The resulting footer must not contain codec byte 0 (Auto discriminant).
251    #[test]
252    fn auto_codec_resolves_to_concrete_before_write() {
253        let schema = ColumnarSchema::new(vec![
254            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
255            ColumnDef::required("name", ColumnType::String),
256            ColumnDef::nullable("score", ColumnType::Float64),
257        ])
258        .expect("valid");
259
260        let mut mt = ColumnarMemtable::new(&schema);
261        for i in 0..10 {
262            mt.append_row(&[
263                Value::Integer(i),
264                Value::String(format!("item_{i}")),
265                Value::Float(i as f64 * 1.5),
266            ])
267            .expect("append");
268        }
269        let (schema, columns, row_count) = mt.drain();
270        let writer = SegmentWriter::plain();
271        let segment = writer
272            .write_segment(&schema, &columns, row_count, None)
273            .expect("write must succeed");
274
275        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
276
277        // None of the column codecs should be the Auto discriminant (byte 0).
278        // All must be concrete, resolved codecs.
279        for col in &footer.columns {
280            // Auto does not exist on ResolvedColumnCodec so the type itself
281            // guarantees this — but we can also serialize and verify the
282            // discriminant byte is not 0.
283            let encoded = zerompk::to_msgpack_vec(&col.codec).expect("serialize");
284            // msgpack c_enum encodes as a single byte when value < 128.
285            // The last byte in the encoded form holds the discriminant.
286            let discriminant_byte = *encoded.last().expect("non-empty");
287            assert_ne!(
288                discriminant_byte, 0,
289                "column '{}' has Auto discriminant (0) on disk — resolve was skipped",
290                col.name
291            );
292        }
293    }
294
295    /// The writer resolves Auto to a sensible non-trivial codec for Int64 columns.
296    #[test]
297    fn auto_codec_int64_resolves_to_non_raw() {
298        use nodedb_codec::ResolvedColumnCodec;
299
300        let schema = ColumnarSchema::new(vec![
301            ColumnDef::required("val", ColumnType::Int64).with_primary_key(),
302        ])
303        .expect("valid");
304
305        let mut mt = ColumnarMemtable::new(&schema);
306        for i in 0..10 {
307            mt.append_row(&[Value::Integer(i)]).expect("append");
308        }
309        let (schema, columns, row_count) = mt.drain();
310        let writer = SegmentWriter::plain();
311        let segment = writer
312            .write_segment(&schema, &columns, row_count, None)
313            .expect("write");
314        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
315
316        // Auto for Int64 should resolve to a delta/int codec, never Raw or Auto.
317        let codec = footer.columns[0].codec;
318        assert_ne!(
319            codec,
320            ResolvedColumnCodec::Raw,
321            "Int64 should not resolve to Raw"
322        );
323    }
324
325    #[test]
326    fn write_segment_roundtrip() {
327        let schema = analytics_schema();
328        let mut mt = ColumnarMemtable::new(&schema);
329
330        for i in 0..100 {
331            mt.append_row(&[
332                Value::Integer(i),
333                Value::String(format!("user_{i}")),
334                if i % 3 == 0 {
335                    Value::Null
336                } else {
337                    Value::Float(i as f64 * 0.25)
338                },
339            ])
340            .expect("append");
341        }
342
343        let (schema, columns, row_count) = mt.drain();
344        let writer = SegmentWriter::plain();
345        let segment = writer
346            .write_segment(&schema, &columns, row_count, None)
347            .expect("write");
348
349        // Verify header.
350        let header = SegmentHeader::from_bytes(&segment).expect("valid header");
351        assert_eq!(header.magic, *b"NDBS");
352        assert_eq!(header.version_major, 1);
353
354        // Verify footer.
355        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
356        assert_eq!(footer.column_count, 3);
357        assert_eq!(footer.row_count, 100);
358        assert_eq!(footer.profile_tag, PROFILE_PLAIN);
359        assert_eq!(footer.columns.len(), 3);
360
361        // Verify column metadata.
362        assert_eq!(footer.columns[0].name, "id");
363        assert_eq!(footer.columns[1].name, "name");
364        assert_eq!(footer.columns[2].name, "score");
365
366        // Each column should have 1 block (100 rows < BLOCK_SIZE=1024).
367        assert_eq!(footer.columns[0].block_count, 1);
368        assert_eq!(footer.columns[0].block_stats[0].row_count, 100);
369
370        // id: min=0, max=99.
371        assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
372        assert_eq!(footer.columns[0].block_stats[0].max, 99.0);
373        assert_eq!(footer.columns[0].block_stats[0].null_count, 0);
374
375        // score: 34 nulls (every 3rd row), min=0.25 (row 1), max=99*0.25=24.75 (row 99).
376        assert_eq!(footer.columns[2].block_stats[0].null_count, 34);
377    }
378
379    #[test]
380    fn write_segment_multi_block() {
381        let schema =
382            ColumnarSchema::new(vec![ColumnDef::required("x", ColumnType::Int64)]).expect("valid");
383
384        let mut mt = ColumnarMemtable::new(&schema);
385        for i in 0..2500 {
386            mt.append_row(&[Value::Integer(i)]).expect("append");
387        }
388
389        let (schema, columns, row_count) = mt.drain();
390        let writer = SegmentWriter::plain();
391        let segment = writer
392            .write_segment(&schema, &columns, row_count, None)
393            .expect("write");
394
395        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid footer");
396        assert_eq!(footer.row_count, 2500);
397
398        // 2500 rows / 1024 = 3 blocks (1024 + 1024 + 452).
399        assert_eq!(footer.columns[0].block_count, 3);
400        assert_eq!(footer.columns[0].block_stats[0].row_count, 1024);
401        assert_eq!(footer.columns[0].block_stats[1].row_count, 1024);
402        assert_eq!(footer.columns[0].block_stats[2].row_count, 452);
403
404        // Block 0: min=0, max=1023.
405        assert_eq!(footer.columns[0].block_stats[0].min, 0.0);
406        assert_eq!(footer.columns[0].block_stats[0].max, 1023.0);
407        // Block 2: min=2048, max=2499.
408        assert_eq!(footer.columns[0].block_stats[2].min, 2048.0);
409        assert_eq!(footer.columns[0].block_stats[2].max, 2499.0);
410    }
411
412    #[test]
413    fn write_segment_empty_rejected() {
414        let schema = analytics_schema();
415        let mt = ColumnarMemtable::new(&schema);
416        let (schema, columns, row_count) = {
417            let mut m = mt;
418            m.drain()
419        };
420        let writer = SegmentWriter::plain();
421        assert!(matches!(
422            writer.write_segment(&schema, &columns, row_count, None),
423            Err(ColumnarError::EmptyMemtable)
424        ));
425    }
426
427    #[test]
428    fn block_stats_predicate_pushdown() {
429        let schema = analytics_schema();
430        let mut mt = ColumnarMemtable::new(&schema);
431
432        for i in 0..50 {
433            mt.append_row(&[
434                Value::Integer(i + 100),
435                Value::String(format!("item_{i}")),
436                Value::Float(i as f64 + 10.0),
437            ])
438            .expect("append");
439        }
440
441        let (schema, columns, row_count) = mt.drain();
442        let writer = SegmentWriter::plain();
443        let segment = writer
444            .write_segment(&schema, &columns, row_count, None)
445            .expect("write");
446        let footer = SegmentFooter::from_segment_tail(&segment).expect("valid");
447
448        use crate::predicate::ScanPredicate;
449
450        let id_stats = &footer.columns[0].block_stats[0];
451        // id: min=100, max=149.
452        assert!(ScanPredicate::gt(0, 200.0).can_skip_block(id_stats)); // WHERE id > 200 → skip.
453        assert!(!ScanPredicate::gt(0, 120.0).can_skip_block(id_stats)); // WHERE id > 120 → cannot skip.
454        assert!(ScanPredicate::lt(0, 50.0).can_skip_block(id_stats)); // WHERE id < 50 → skip.
455        assert!(ScanPredicate::eq(0, 200.0).can_skip_block(id_stats)); // WHERE id = 200 → skip.
456        assert!(!ScanPredicate::eq(0, 125.0).can_skip_block(id_stats)); // WHERE id = 125 → cannot skip.
457    }
458
459    #[test]
460    fn string_block_stats_zone_map() {
461        // Write a segment with known string values, then verify str_min/str_max.
462        let schema = ColumnarSchema::new(vec![ColumnDef::required("tag", ColumnType::String)])
463            .expect("valid");
464
465        let mut mt = ColumnarMemtable::new(&schema);
466        // Insert > 16 distinct values to trigger bloom filter construction.
467        // Lexicographic order: apple < banana < cherry < date (first/last matter for zone map).
468        let values: Vec<String> = (0..20).map(|i| format!("item_{i:02}")).collect();
469        for name in &values {
470            mt.append_row(&[Value::String(name.clone())])
471                .expect("append");
472        }
473        // Add known boundary values for zone-map assertions.
474        mt.append_row(&[Value::String("apple".into())])
475            .expect("append");
476        mt.append_row(&[Value::String("date".into())])
477            .expect("append");
478
479        let (schema, columns, row_count) = mt.drain();
480        let writer = SegmentWriter::plain();
481        let segment = writer
482            .write_segment(&schema, &columns, row_count, None)
483            .expect("write");
484        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
485
486        let stats = &footer.columns[0].block_stats[0];
487        assert!(stats.str_min.is_some(), "str_min should be populated");
488        assert!(stats.str_max.is_some(), "str_max should be populated");
489        // "apple" is lex smallest, "item_19" is lex largest (> "date").
490        assert_eq!(stats.str_min.as_deref(), Some("apple"));
491        assert_eq!(stats.str_max.as_deref(), Some("item_19"));
492
493        // Bloom filter should be present (>16 distinct values).
494        assert!(
495            stats.bloom.is_some(),
496            "bloom should be populated for >16 distinct values"
497        );
498
499        use crate::predicate::ScanPredicate;
500
501        // WHERE tag = "aaa" → below "apple" → skip.
502        assert!(ScanPredicate::str_eq(0, "aaa").can_skip_block(stats));
503        // WHERE tag = "zzz" → above "item_19" → skip.
504        assert!(ScanPredicate::str_eq(0, "zzz").can_skip_block(stats));
505        // WHERE tag = "date" → in range [apple, item_19], inserted in bloom → cannot skip.
506        assert!(!ScanPredicate::str_eq(0, "date").can_skip_block(stats));
507        // WHERE tag > "item_19" → smax ≤ value → skip.
508        assert!(ScanPredicate::str_gt(0, "item_19").can_skip_block(stats));
509        // WHERE tag < "apple" → smin ≥ value → skip.
510        assert!(ScanPredicate::str_lt(0, "apple").can_skip_block(stats));
511    }
512
513    /// timestamps in the year-2300+ microsecond range (far above 2^53)
514    /// must be recorded losslessly in `min_i64`/`max_i64` and predicate
515    /// pushdown must not false-skip a block that contains the target value.
516    #[test]
517    fn timestamp_large_value_roundtrip() {
518        use crate::predicate::ScanPredicate;
519
520        let schema = ColumnarSchema::new(vec![
521            ColumnDef::required("ts", ColumnType::Timestamp).with_primary_key(),
522        ])
523        .expect("valid schema");
524
525        // Year-2300 in microseconds since epoch.
526        // 2300-01-01T00:00:00Z ≈ 10_413_792_000_000_000 µs
527        // These values are well above 2^53 = 9_007_199_254_740_992.
528        let base: i64 = 10_413_792_000_000_000;
529        let target = base + 500_000; // half a second later
530
531        let mut mt = ColumnarMemtable::new(&schema);
532        for delta in 0..10i64 {
533            mt.append_row(&[Value::Integer(base + delta * 100_000)])
534                .expect("append");
535        }
536
537        let (schema, columns, row_count) = mt.drain();
538        let segment = SegmentWriter::plain()
539            .write_segment(&schema, &columns, row_count, None)
540            .expect("write");
541        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
542
543        let stats = &footer.columns[0].block_stats[0];
544
545        // Exact i64 fields must be populated.
546        assert!(
547            stats.min_i64.is_some(),
548            "min_i64 must be set for timestamp columns"
549        );
550        assert!(
551            stats.max_i64.is_some(),
552            "max_i64 must be set for timestamp columns"
553        );
554        assert_eq!(stats.min_i64.unwrap(), base);
555        assert_eq!(stats.max_i64.unwrap(), base + 9 * 100_000);
556
557        // Predicate: ts = target (base + 500_000) → in [base, base+900_000] → must NOT skip.
558        assert!(
559            !ScanPredicate::eq_i64(0, target).can_skip_block(stats),
560            "must not skip: target={target} is within the block range"
561        );
562
563        // Predicate: ts = base - 1 → below min → must skip.
564        assert!(
565            ScanPredicate::eq_i64(0, base - 1).can_skip_block(stats),
566            "must skip: base-1 is below block min"
567        );
568
569        // The f64 path is broken for these values (min, max, target all round
570        // to the same f64 or nearby indistinguishable values).
571        let min_f64 = base as f64;
572        let target_f64 = target as f64;
573        let max_f64 = (base + 9 * 100_000) as f64;
574        // Verify the f64 representation is unreliable for this range.
575        // (min_f64 == target_f64 if the gap < ULP, which it is at this scale.)
576        let _ = (min_f64, target_f64, max_f64); // suppress unused warnings
577    }
578
579    #[test]
580    fn integer_block_stats_have_exact_i64_fields() {
581        // Verify that Int64 columns also populate min_i64/max_i64.
582        let schema = ColumnarSchema::new(vec![
583            ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
584        ])
585        .expect("valid");
586
587        let mut mt = ColumnarMemtable::new(&schema);
588        for i in 0..5i64 {
589            mt.append_row(&[Value::Integer(i64::MAX - 4 + i)])
590                .expect("append");
591        }
592
593        let (schema, columns, row_count) = mt.drain();
594        let segment = SegmentWriter::plain()
595            .write_segment(&schema, &columns, row_count, None)
596            .expect("write");
597        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
598
599        let stats = &footer.columns[0].block_stats[0];
600        assert_eq!(stats.min_i64, Some(i64::MAX - 4));
601        assert_eq!(stats.max_i64, Some(i64::MAX));
602
603        // eq_i64 must not skip for a value in the middle.
604        use crate::predicate::ScanPredicate;
605        assert!(!ScanPredicate::eq_i64(0, i64::MAX - 2).can_skip_block(stats));
606        // eq_i64 must skip for a value below min.
607        assert!(ScanPredicate::eq_i64(0, i64::MAX - 10).can_skip_block(stats));
608    }
609
610    #[test]
611    fn string_block_stats_bloom_rejects_absent_value() {
612        let schema = ColumnarSchema::new(vec![ColumnDef::required("label", ColumnType::String)])
613            .expect("valid");
614
615        let mut mt = ColumnarMemtable::new(&schema);
616        // Insert > 16 distinct values to trigger bloom construction.
617        let values: Vec<String> = (0..20).map(|i| format!("val_{i:02}")).collect();
618        for name in &values {
619            mt.append_row(&[Value::String(name.clone())])
620                .expect("append");
621        }
622        // Add known values for bloom assertions.
623        mt.append_row(&[Value::String("alpha".into())])
624            .expect("append");
625        mt.append_row(&[Value::String("beta".into())])
626            .expect("append");
627        mt.append_row(&[Value::String("gamma".into())])
628            .expect("append");
629
630        let (schema, columns, row_count) = mt.drain();
631        let segment = SegmentWriter::plain()
632            .write_segment(&schema, &columns, row_count, None)
633            .expect("write");
634        let footer = SegmentFooter::from_segment_tail(&segment).expect("footer");
635        let stats = &footer.columns[0].block_stats[0];
636
637        use crate::predicate::{ScanPredicate, bloom_may_contain};
638
639        let bloom = stats
640            .bloom
641            .as_ref()
642            .expect("bloom present for >16 distinct");
643        assert!(bloom_may_contain(bloom, "alpha"));
644        assert!(bloom_may_contain(bloom, "beta"));
645        assert!(bloom_may_contain(bloom, "gamma"));
646
647        // "delta" was not inserted. If bloom says absent, the predicate skips.
648        let delta_absent = !bloom_may_contain(bloom, "delta");
649        if delta_absent {
650            // "delta" is in [alpha, val_19] range → only bloom can skip this.
651            assert!(ScanPredicate::str_eq(0, "delta").can_skip_block(stats));
652        }
653    }
654}