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