Skip to main content

ailake_file/
writer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use ailake_core::{AilakeResult, Centroid, RowId, VectorStoragePolicy};
3use ailake_index::{
4    HnswBuilder, HnswConfig, HnswSerializer, IvfPqCodebook, IvfPqConfig, IvfPqIndex,
5    IvfPqSerializer,
6};
7use ailake_parquet::ParquetVectorWriter;
8use ailake_vec::compute_centroid_and_radius;
9use arrow_array::RecordBatch;
10use bytes::{BufMut, Bytes, BytesMut};
11
12use crate::footer::{
13    parquet_footer_start, AilakeHeader, AilakeTrailer, DistanceMetric, Precision,
14    AILAKE_FORMAT_VERSION, AILK_FTS_HEADER_SIZE, AILK_FTS_MAGIC, FLAG_INDEX_IVF_PQ, HEADER_SIZE,
15    KV_FTS_OFFSET, TRAILER_SIZE,
16};
17
18/// Which index algorithm to embed in the AILK section.
19#[derive(Debug, Clone)]
20pub enum IndexType {
21    /// HNSW (default). Best recall for in-memory workloads.
22    Hnsw(HnswConfig),
23    /// IVF-PQ. Best for S3: 10-100x smaller index, sequential inverted-list reads.
24    IvfPq(IvfPqConfig),
25    /// Detect hardware at write time and pick the best index automatically.
26    ///
27    /// Chooses IVF-PQ when a GPU or ≥8 CPU cores are available AND the dataset
28    /// has ≥5 000 vectors. Falls back to HNSW otherwise (local/low-power hardware).
29    Auto,
30}
31
32impl Default for IndexType {
33    fn default() -> Self {
34        IndexType::Hnsw(HnswConfig::default())
35    }
36}
37
38/// One vector column to embed in a multi-column write.
39pub struct VectorColumnBatch<'a> {
40    pub policy: &'a VectorStoragePolicy,
41    pub embeddings: &'a [Vec<f32>],
42}
43
44pub struct AilakeFileWriter {
45    policy: VectorStoragePolicy,
46    index_type: IndexType,
47    /// Pre-trained shared codebook. When set, skips k-means for IVF-PQ builds.
48    shared_codebook: Option<std::sync::Arc<IvfPqCodebook>>,
49    /// When set, builds and embeds a Tantivy FTS index in the AILK_FTS section.
50    fts_config: Option<ailake_fts::FtsConfig>,
51    /// Pre-built FTS blob (e.g., from compaction). Takes priority over `fts_config`.
52    prebuilt_fts_blob: Option<Vec<u8>>,
53}
54
55impl AilakeFileWriter {
56    pub fn new(policy: VectorStoragePolicy) -> Self {
57        Self {
58            policy,
59            index_type: IndexType::default(),
60            shared_codebook: None,
61            fts_config: None,
62            prebuilt_fts_blob: None,
63        }
64    }
65
66    /// Attach a Tantivy FTS config. An `AILK_FTS` section will be built and embedded.
67    pub fn with_fts(mut self, config: ailake_fts::FtsConfig) -> Self {
68        self.fts_config = Some(config);
69        self
70    }
71
72    /// Supply a pre-built FTS blob (e.g., from compaction). Takes priority over `with_fts`.
73    pub fn with_prebuilt_fts_blob(mut self, blob: Vec<u8>) -> Self {
74        self.prebuilt_fts_blob = Some(blob);
75        self
76    }
77
78    /// Use a pre-trained IVF-PQ codebook instead of running k-means.
79    /// Shards built from the same codebook produce comparable ADC distances.
80    pub fn with_shared_ivf_codebook(mut self, codebook: std::sync::Arc<IvfPqCodebook>) -> Self {
81        self.shared_codebook = Some(codebook);
82        self
83    }
84
85    pub fn with_hnsw_config(mut self, config: HnswConfig) -> Self {
86        self.index_type = IndexType::Hnsw(config);
87        self
88    }
89
90    pub fn with_ivf_pq(mut self, config: IvfPqConfig) -> Self {
91        self.index_type = IndexType::IvfPq(config);
92        self
93    }
94
95    pub fn with_index_type(mut self, index_type: IndexType) -> Self {
96        self.index_type = index_type;
97        self
98    }
99
100    /// Use `IndexType::Auto`: detect GPU / CPU cores at write time and pick the
101    /// best index. Equivalent to `.with_index_type(IndexType::Auto)`.
102    pub fn with_auto_index(mut self) -> Self {
103        self.index_type = IndexType::Auto;
104        self
105    }
106
107    /// Write `batch` + `embeddings` using a **pre-built** `HnswIndex`.
108    ///
109    /// Skips the O(N log N) HNSW construction — the caller is responsible for
110    /// building and populating the index. The index must contain exactly
111    /// `embeddings.len()` nodes with RowIds `0..N` matching `embeddings[0..N]`
112    /// in order.
113    ///
114    /// Used by incremental compaction to reuse the dominant file's existing
115    /// index and only insert vectors from smaller files.
116    pub fn write_with_prebuilt_hnsw(
117        &self,
118        batch: &RecordBatch,
119        embeddings: &[Vec<f32>],
120        hnsw: &ailake_index::HnswIndex,
121    ) -> AilakeResult<Bytes> {
122        use ailake_core::AilakeError;
123
124        let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
125
126        // Pass 1: write Parquet without KV to measure the row-group section size.
127        let (parquet_v1, record_count) = parquet_writer.write_batch(batch, embeddings)?;
128        let footer_start = parquet_footer_start(&parquet_v1)?;
129
130        let index_bytes = HnswSerializer::to_bytes(hnsw)?;
131        let ailk_section = build_ailk_section_from_index_bytes(
132            &self.policy,
133            embeddings,
134            record_count,
135            footer_start as u64,
136            &index_bytes,
137            0u16, // flags=0: HNSW (not IVF-PQ)
138        )?;
139
140        let kv_val = footer_start.to_string();
141        let kv_refs: &[(&str, &str)] = &[("ailake.footer_offset", kv_val.as_str())];
142
143        // Pass 2: write Parquet with AILK offset KV embedded.
144        let (parquet_v2, _) = parquet_writer.write_batch_with_kv(batch, embeddings, kv_refs)?;
145        let footer_start_v2 = parquet_footer_start(&parquet_v2).map_err(|e| {
146            AilakeError::Parquet(format!(
147                "footer_start unstable in write_with_prebuilt_hnsw: {e}"
148            ))
149        })?;
150        debug_assert_eq!(
151            footer_start, footer_start_v2,
152            "footer_start must be stable across KV injection"
153        );
154
155        let footer_len_v2 = parquet_v2.len() - footer_start_v2;
156        let mut out = BytesMut::with_capacity(footer_start + ailk_section.len() + footer_len_v2);
157        out.put_slice(&parquet_v1[..footer_start]);
158        drop(parquet_v1);
159        out.put(ailk_section);
160        out.put_slice(&parquet_v2[footer_start_v2..]);
161        Ok(out.freeze())
162    }
163
164    /// Write RecordBatch + embeddings as plain Parquet, with no AILK section.
165    ///
166    /// Used by `TableWriter::write_batch_deferred()` to persist data immediately
167    /// while the HNSW index is built asynchronously in the background.
168    /// The resulting file is a valid Parquet readable by any standard reader,
169    /// but `AilakeFileReader::is_ailake_file()` returns false until the HNSW
170    /// section is appended by the background indexing task.
171    pub fn write_parquet_only(
172        &self,
173        batch: &RecordBatch,
174        embeddings: &[Vec<f32>],
175    ) -> AilakeResult<Bytes> {
176        let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
177        let (bytes, _) = parquet_writer.write_batch(batch, embeddings)?;
178        Ok(bytes)
179    }
180
181    /// Write RecordBatch + embeddings into a single AI-Lake file.
182    ///
183    /// Layout:
184    ///   [PAR1][row groups][AILK header+centroid+HNSW+trailer][Parquet footer][footer_len][PAR1]
185    ///
186    /// Standard Parquet readers find PAR1 at the end, read the footer, skip directly to row
187    /// group offsets. The AILK section sits between row groups and footer and is never touched.
188    /// AI-Lake readers find the AILK section via `ailake.footer_offset` in the Parquet footer KV.
189    pub fn write(&self, batch: &RecordBatch, embeddings: &[Vec<f32>]) -> AilakeResult<Bytes> {
190        let col = VectorColumnBatch {
191            policy: &self.policy,
192            embeddings,
193        };
194        self.write_multi(batch, &[col])
195    }
196
197    /// Single-pass streaming write — no `ailake.footer_offset` KV injection.
198    ///
199    /// Produces a valid AI-Lake file in a **single Parquet write pass** without any
200    /// seek or footer rewrite. Safe for append-only destinations (HDFS strict mode,
201    /// piped stdout, write-once distributed filesystems).
202    ///
203    /// Readers bootstrap the AILK section from the `AilakeTrailer` (the 24 bytes
204    /// immediately preceding the Parquet footer) instead of from the Parquet KV
205    /// metadata. Both bootstrap paths are supported by `AilakeFileReader`.
206    ///
207    /// Trade-off vs `write()`:
208    /// - One Parquet write instead of two — saves CPU + memory for large batches.
209    /// - On S3, the AILK offset is derived from bytes already fetched in the
210    ///   footer range-GET (trailer is the 24 bytes before the footer). No extra GET.
211    pub fn write_single_pass(
212        &self,
213        batch: &RecordBatch,
214        embeddings: &[Vec<f32>],
215    ) -> AilakeResult<Bytes> {
216        let col = VectorColumnBatch {
217            policy: &self.policy,
218            embeddings,
219        };
220        self.write_multi_single_pass(batch, &[col])
221    }
222
223    /// Multi-column variant of `write_single_pass`.
224    pub fn write_multi_single_pass(
225        &self,
226        batch: &RecordBatch,
227        columns: &[VectorColumnBatch<'_>],
228    ) -> AilakeResult<Bytes> {
229        use ailake_core::AilakeError;
230
231        if columns.is_empty() {
232            return Err(AilakeError::InvalidArgument(
233                "write_multi_single_pass requires at least one vector column".into(),
234            ));
235        }
236
237        let primary = &columns[0];
238        let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
239
240        // Single Parquet write — no KV injection for ailake.footer_offset.
241        // Extra KV still present (dim, metric, record_count, etc.) for Iceberg compat.
242        let (parquet_bytes, record_count) =
243            parquet_writer.write_batch(batch, primary.embeddings)?;
244        let footer_start = parquet_footer_start(&parquet_bytes)?;
245
246        // Build all AILK sections. Offsets are relative to the final assembled file,
247        // where AILK sections start at `footer_start` (right after row groups).
248        let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
249        let mut current_offset = footer_start as u64;
250        for col in columns.iter() {
251            let section = build_ailk_section(
252                col.policy,
253                col.embeddings,
254                record_count,
255                current_offset,
256                &self.index_type,
257                self.shared_codebook.as_deref(),
258            )?;
259            current_offset += section.len() as u64;
260            ailk_sections.push(section);
261        }
262
263        // Assemble: [row groups] + [AILK sections] + [original Parquet footer].
264        // No footer rewrite needed — reader uses AilakeTrailer for bootstrap.
265        let total_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
266        let mut out = BytesMut::with_capacity(parquet_bytes.len() + total_ailk);
267        out.put_slice(&parquet_bytes[..footer_start]);
268        for section in ailk_sections {
269            out.put(section);
270        }
271        out.put_slice(&parquet_bytes[footer_start..]);
272
273        Ok(out.freeze())
274    }
275
276    /// Write RecordBatch + multiple vector columns into a single AI-Lake file.
277    ///
278    /// Each column gets its own AILK section appended sequentially before the Parquet footer.
279    /// Offsets are recorded in Parquet KV metadata:
280    ///   - Primary (first) column: `ailake.footer_offset`
281    ///   - Additional columns: `ailake.{column_name}.footer_offset`
282    ///
283    /// Readers use the column-specific KV key to locate the right AILK section.
284    pub fn write_multi(
285        &self,
286        batch: &RecordBatch,
287        columns: &[VectorColumnBatch<'_>],
288    ) -> AilakeResult<Bytes> {
289        use ailake_core::AilakeError;
290
291        if columns.is_empty() {
292            return Err(AilakeError::InvalidArgument(
293                "write_multi requires at least one vector column".into(),
294            ));
295        }
296
297        let primary = &columns[0];
298        let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
299
300        // Pass 1 — write Parquet without KV to measure the data section size.
301        let (parquet_v1, record_count) = parquet_writer.write_batch(batch, primary.embeddings)?;
302        let footer_start = parquet_footer_start(&parquet_v1)?;
303
304        // Build all AILK sections sequentially; track running absolute offset.
305        let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
306        let mut kv_owned: Vec<(String, String)> = Vec::with_capacity(columns.len());
307        let mut current_offset = footer_start as u64;
308
309        for (i, col) in columns.iter().enumerate() {
310            let section = build_ailk_section(
311                col.policy,
312                col.embeddings,
313                record_count,
314                current_offset,
315                &self.index_type,
316                self.shared_codebook.as_deref(),
317            )?;
318            let kv_key = if i == 0 {
319                "ailake.footer_offset".to_string()
320            } else {
321                format!("ailake.{}.footer_offset", col.policy.column_name)
322            };
323            kv_owned.push((kv_key, current_offset.to_string()));
324            current_offset += section.len() as u64;
325            ailk_sections.push(section);
326        }
327
328        // Build optional AILK_FTS section.
329        // Pre-built blob takes priority; otherwise build from batch if fts_config is set.
330        let fts_blob: Option<Vec<u8>> = self.prebuilt_fts_blob.clone().or_else(|| {
331            self.fts_config
332                .as_ref()
333                .and_then(|cfg| ailake_fts::build_fts_blob_from_batch(cfg, batch).ok())
334        });
335        let fts_section: Option<Bytes> = fts_blob.map(|blob| {
336            // AILK_FTS header: magic(4) | version(2 LE) | reserved(2) | blob_len(8 LE)
337            let blob_len = blob.len() as u64;
338            let mut sec = BytesMut::with_capacity(AILK_FTS_HEADER_SIZE + blob.len());
339            sec.put_slice(&AILK_FTS_MAGIC);
340            sec.put_slice(&1u16.to_le_bytes()); // version
341            sec.put_slice(&0u16.to_le_bytes()); // reserved
342            sec.put_slice(&blob_len.to_le_bytes());
343            sec.put_slice(&blob);
344            sec.freeze()
345        });
346
347        if let Some(ref sec) = fts_section {
348            let fts_abs_offset = current_offset; // right after all vector AILK sections
349            kv_owned.push((KV_FTS_OFFSET.to_string(), fts_abs_offset.to_string()));
350            let _ = sec; // current_offset update not needed; no more sections after FTS
351        }
352
353        // Pass 2 — write Parquet with all AILK offset KVs embedded.
354        // Only the Parquet footer changes between pass 1 and pass 2 (KV metadata
355        // is stored in the footer thrift, not in row groups). Row group offsets and
356        // payloads are byte-for-byte identical, so footer_start is stable across
357        // both passes. We reuse pass-1's row groups and take only the footer from pass 2.
358        let kv_refs: Vec<(&str, &str)> = kv_owned
359            .iter()
360            .map(|(k, v)| (k.as_str(), v.as_str()))
361            .collect();
362        let (parquet_v2, _) =
363            parquet_writer.write_batch_with_kv(batch, primary.embeddings, &kv_refs)?;
364        let footer_start_v2 = parquet_footer_start(&parquet_v2)?;
365        debug_assert_eq!(
366            footer_start, footer_start_v2,
367            "footer_start must be stable across KV injection (row groups unchanged)"
368        );
369
370        // Splice: [row groups from v1] + [vector AILK sections] + [AILK_FTS section] + [footer from v2]
371        let total_vector_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
372        let total_fts: usize = fts_section.as_ref().map_or(0, |s| s.len());
373        let footer_len_v2 = parquet_v2.len() - footer_start_v2;
374        let total = footer_start + total_vector_ailk + total_fts + footer_len_v2;
375        let mut out = BytesMut::with_capacity(total);
376        out.put_slice(&parquet_v1[..footer_start]);
377        drop(parquet_v1);
378        for section in ailk_sections {
379            out.put(section);
380        }
381        if let Some(fts_sec) = fts_section {
382            out.put(fts_sec);
383        }
384        out.put_slice(&parquet_v2[footer_start_v2..]);
385
386        Ok(out.freeze())
387    }
388}
389
390/// Build a complete AILK section using **pre-serialized** index bytes.
391/// Same layout as `build_ailk_section` but skips the index build step.
392fn build_ailk_section_from_index_bytes(
393    policy: &VectorStoragePolicy,
394    embeddings: &[Vec<f32>],
395    record_count: u64,
396    ailk_abs_offset: u64,
397    index_bytes: &[u8],
398    flags: u16,
399) -> AilakeResult<Bytes> {
400    let norm_storage: Vec<Vec<f32>>;
401    let (emb_for_centroid, centroid_metric) =
402        if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
403            norm_storage = embeddings
404                .iter()
405                .map(|v| ailake_vec::normalize_l2(v))
406                .collect();
407            (
408                norm_storage.as_slice(),
409                ailake_core::VectorMetric::NormalizedCosine,
410            )
411        } else {
412            (embeddings, policy.metric)
413        };
414
415    let centroid = compute_centroid_and_radius(emb_for_centroid, centroid_metric);
416    let centroid_bytes = encode_centroid(&centroid);
417
418    let centroid_offset = HEADER_SIZE as u64;
419    let centroid_len = centroid_bytes.len() as u64;
420    let index_offset_in_ailk = centroid_offset + centroid_len;
421    let index_len = index_bytes.len() as u64;
422    let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
423
424    let header = AilakeHeader {
425        format_version: AILAKE_FORMAT_VERSION,
426        flags,
427        dim: policy.dim,
428        precision: Precision::from(policy.precision),
429        distance_metric: DistanceMetric::from(policy.metric),
430        record_count,
431        centroid_offset,
432        centroid_len,
433        hnsw_offset: index_offset_in_ailk,
434        hnsw_len: index_len,
435    };
436    let trailer = AilakeTrailer {
437        footer_offset: ailk_abs_offset,
438        footer_len: ailk_total_len,
439        format_version: AILAKE_FORMAT_VERSION,
440        flags,
441    };
442
443    let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
444    buf.put_slice(&header.to_bytes());
445    buf.put_slice(&centroid_bytes);
446    buf.put_slice(index_bytes);
447    buf.put_slice(&trailer.to_bytes());
448    Ok(buf.freeze())
449}
450
451/// Build a complete AILK section (header + centroid + index + trailer) for one vector column.
452fn build_ailk_section(
453    policy: &VectorStoragePolicy,
454    embeddings: &[Vec<f32>],
455    record_count: u64,
456    ailk_abs_offset: u64,
457    index_type: &IndexType,
458    shared_codebook: Option<&IvfPqCodebook>,
459) -> AilakeResult<Bytes> {
460    // Normalize to unit L2 when pre_normalize is set.
461    // Enables the NormalizedCosine fast path: 1-dot(a,b) instead of full cosine.
462    let norm_storage: Vec<Vec<f32>>;
463    let (embeddings, hnsw_metric) =
464        if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
465            norm_storage = embeddings
466                .iter()
467                .map(|v| ailake_vec::normalize_l2(v))
468                .collect();
469            (
470                norm_storage.as_slice(),
471                ailake_core::VectorMetric::NormalizedCosine,
472            )
473        } else {
474            (embeddings, policy.metric)
475        };
476
477    let centroid: Centroid = compute_centroid_and_radius(embeddings, hnsw_metric);
478    let centroid_bytes = encode_centroid(&centroid);
479
480    // Resolve Auto to a concrete variant before matching.
481    let resolved: IndexType;
482    let index_type = if matches!(index_type, IndexType::Auto) {
483        let profile = ailake_index::HardwareProfile::detect();
484        resolved = if profile.recommend_ivf_pq(embeddings.len()) {
485            IndexType::IvfPq(ailake_index::IvfPqConfig::for_dataset(
486                policy.dim as usize,
487                embeddings.len(),
488            ))
489        } else {
490            IndexType::Hnsw(ailake_index::HnswConfig::default())
491        };
492        &resolved
493    } else {
494        index_type
495    };
496
497    let (index_bytes, flags) = match index_type {
498        IndexType::Hnsw(hnsw_config) => {
499            // Policy-level M/ef_construction override the IndexType defaults when set.
500            let config = HnswConfig {
501                m: policy.hnsw_m.map(|v| v as usize).unwrap_or(hnsw_config.m),
502                ef_construction: policy
503                    .hnsw_ef_construction
504                    .map(|v| v as usize)
505                    .unwrap_or(hnsw_config.ef_construction),
506                max_elements: hnsw_config.max_elements,
507            };
508            let mut builder = HnswBuilder::new(policy.dim, hnsw_metric, config);
509            for (i, v) in embeddings.iter().enumerate() {
510                builder.insert(RowId::new(i as u64), v.clone());
511            }
512            let index = builder.build();
513            (HnswSerializer::to_bytes(&index)?, 0u16)
514        }
515        IndexType::IvfPq(ivf_config) => {
516            let row_ids: Vec<RowId> = (0..embeddings.len() as u64).map(RowId::new).collect();
517            let index = if let Some(cb) = shared_codebook {
518                IvfPqIndex::build_with_codebook(&row_ids, embeddings, cb)?
519            } else {
520                ailake_index::IvfPqIndex::train(
521                    &row_ids,
522                    embeddings,
523                    policy.metric,
524                    ivf_config.clone(),
525                )?
526            };
527            (IvfPqSerializer::to_bytes(&index)?, FLAG_INDEX_IVF_PQ)
528        }
529        IndexType::Auto => unreachable!("Auto resolved above"),
530    };
531
532    let centroid_offset = HEADER_SIZE as u64;
533    let centroid_len = centroid_bytes.len() as u64;
534    let index_offset_in_ailk = centroid_offset + centroid_len;
535    let index_len = index_bytes.len() as u64;
536    let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
537
538    let header = AilakeHeader {
539        format_version: AILAKE_FORMAT_VERSION,
540        flags,
541        dim: policy.dim,
542        precision: Precision::from(policy.precision),
543        distance_metric: DistanceMetric::from(policy.metric),
544        record_count,
545        centroid_offset,
546        centroid_len,
547        hnsw_offset: index_offset_in_ailk,
548        hnsw_len: index_len,
549    };
550    let trailer = AilakeTrailer {
551        footer_offset: ailk_abs_offset,
552        footer_len: ailk_total_len,
553        format_version: AILAKE_FORMAT_VERSION,
554        flags,
555    };
556
557    let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
558    buf.put_slice(&header.to_bytes());
559    buf.put_slice(&centroid_bytes);
560    buf.put_slice(&index_bytes);
561    buf.put_slice(&trailer.to_bytes());
562    Ok(buf.freeze())
563}
564
565fn encode_centroid(c: &Centroid) -> Vec<u8> {
566    let mut bytes = Vec::with_capacity(c.values.len() * 4 + 4);
567    for &v in &c.values {
568        bytes.extend_from_slice(&v.to_le_bytes());
569    }
570    bytes.extend_from_slice(&c.radius.to_le_bytes());
571    bytes
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use ailake_core::{VectorMetric, VectorPrecision};
578    use arrow_array::{Int32Array, RecordBatch};
579    use arrow_schema::{DataType, Field, Schema};
580    use std::sync::Arc;
581
582    fn make_policy(dim: u32) -> VectorStoragePolicy {
583        VectorStoragePolicy {
584            column_name: "embedding".to_string(),
585            dim,
586            metric: VectorMetric::Cosine,
587            precision: VectorPrecision::F16,
588            pq: None,
589            keep_raw_for_reranking: true,
590            pre_normalize: false,
591            hnsw_m: None,
592            hnsw_ef_construction: None,
593            ivf_residual: false,
594            embedding_model: None,
595            modality: None,
596            partition_by: None,
597            partition_value: None,
598            partition_column_type: None,
599            partition_fields: vec![],
600        }
601    }
602
603    #[test]
604    fn write_single_pass_valid_parquet_and_ailk() {
605        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
606        let batch =
607            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
608        let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
609
610        let writer = AilakeFileWriter::new(make_policy(4));
611        let file = writer.write_single_pass(&batch, &embs).unwrap();
612
613        // Must be valid Parquet envelope
614        assert_eq!(&file[..4], b"PAR1");
615        assert_eq!(&file[file.len() - 4..], b"PAR1");
616        // AILK magic present
617        assert!(file.windows(4).any(|w| w == b"AILK"));
618    }
619
620    #[test]
621    fn write_single_pass_reader_bootstrap_from_trailer() {
622        use crate::reader::AilakeFileReader;
623
624        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
625        let batch =
626            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![10, 20, 30]))])
627                .unwrap();
628        let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
629
630        let writer = AilakeFileWriter::new(make_policy(4));
631        let file_bytes = writer.write_single_pass(&batch, &embs).unwrap();
632
633        // Single-pass files have NO ailake.footer_offset in Parquet KV.
634        // AilakeFileReader must bootstrap from AilakeTrailer instead.
635        let reader = AilakeFileReader::new(file_bytes, "embedding", 4);
636        assert!(
637            reader.is_ailake_file(),
638            "single-pass file must be recognised as AI-Lake file via trailer bootstrap"
639        );
640        let header = reader.read_header().expect("must read AILK header");
641        assert_eq!(header.dim, 4);
642        assert_eq!(header.record_count, 3);
643    }
644
645    #[test]
646    fn write_and_write_single_pass_same_index() {
647        // Both write paths must produce an index that returns the same nearest neighbour
648        // for a fixed query — verifying that single-pass doesn't corrupt the HNSW.
649        use crate::reader::AilakeFileReader;
650
651        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
652        let batch = RecordBatch::try_new(
653            schema,
654            vec![Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5]))],
655        )
656        .unwrap();
657        let embs: Vec<Vec<f32>> = vec![
658            vec![1.0, 0.0, 0.0, 0.0],
659            vec![0.0, 1.0, 0.0, 0.0],
660            vec![0.0, 0.0, 1.0, 0.0],
661            vec![0.0, 0.0, 0.0, 1.0],
662            vec![0.5, 0.5, 0.0, 0.0],
663        ];
664        let policy = make_policy(4);
665        let writer = AilakeFileWriter::new(policy);
666
667        let bytes_two_pass = writer.write(&batch, &embs).unwrap();
668        let bytes_single_pass = writer.write_single_pass(&batch, &embs).unwrap();
669
670        let query = vec![1.0f32, 0.0, 0.0, 0.0];
671
672        let reader_tp = AilakeFileReader::new(bytes_two_pass, "embedding", 4);
673        let reader_sp = AilakeFileReader::new(bytes_single_pass, "embedding", 4);
674
675        let idx_tp = reader_tp.load_index().unwrap();
676        let idx_sp = reader_sp.load_index().unwrap();
677
678        let res_tp = idx_tp.search(&query, 1, 50);
679        let res_sp = idx_sp.search(&query, 1, 50);
680
681        assert_eq!(res_tp[0].0, res_sp[0].0, "nearest neighbour must match");
682    }
683
684    #[test]
685    fn write_ends_with_par1() {
686        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
687        let batch =
688            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
689        let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
690
691        let writer = AilakeFileWriter::new(make_policy(4));
692        let file = writer.write(&batch, &embs).unwrap();
693
694        assert_eq!(&file[file.len() - 4..], b"PAR1");
695        assert_eq!(&file[..4], b"PAR1");
696        assert!(file.windows(4).any(|w| w == b"AILK"));
697    }
698
699    #[test]
700    fn write_multi_two_columns() {
701        use ailake_core::{VectorMetric, VectorPrecision};
702
703        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
704        let batch =
705            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
706
707        let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
708        let ctx_embs: Vec<Vec<f32>> = (0..3).map(|i| vec![0.0, i as f32, 0.0, 0.0]).collect();
709
710        let policy1 = make_policy(4);
711        let policy2 = VectorStoragePolicy {
712            column_name: "context_embedding".to_string(),
713            dim: 4,
714            metric: VectorMetric::Cosine,
715            precision: VectorPrecision::F16,
716            pq: None,
717            keep_raw_for_reranking: true,
718            pre_normalize: false,
719            hnsw_m: None,
720            hnsw_ef_construction: None,
721            ivf_residual: false,
722            embedding_model: None,
723            modality: None,
724            partition_by: None,
725            partition_value: None,
726            partition_column_type: None,
727            partition_fields: vec![],
728        };
729
730        let writer = AilakeFileWriter::new(policy1.clone());
731        let file = writer
732            .write_multi(
733                &batch,
734                &[
735                    VectorColumnBatch {
736                        policy: &policy1,
737                        embeddings: &embs,
738                    },
739                    VectorColumnBatch {
740                        policy: &policy2,
741                        embeddings: &ctx_embs,
742                    },
743                ],
744            )
745            .unwrap();
746
747        // Valid Parquet envelope
748        assert_eq!(&file[..4], b"PAR1");
749        assert_eq!(&file[file.len() - 4..], b"PAR1");
750        // Two AILK sections — magic appears at least twice
751        let ailk_count = file.windows(4).filter(|w| *w == b"AILK").count();
752        assert!(
753            ailk_count >= 2,
754            "expected >= 2 AILK markers, got {ailk_count}"
755        );
756    }
757}