Skip to main content

hermes_core/dsl/sdl/
mod.rs

1//! Schema Definition Language (SDL) for Hermes
2//!
3//! A simple, readable format for defining index schemas using pest parser.
4//!
5//! # Example SDL
6//!
7//! ```text
8//! # Article index schema
9//! index articles {
10//!     # Primary text field for full-text search
11//!     field title: text [indexed, stored]
12//!
13//!     # Body content - indexed but not stored (save space)
14//!     field body: text [indexed]
15//!
16//!     # Author name
17//!     field author: text [indexed, stored]
18//!
19//!     # Publication timestamp
20//!     field published_at: i64 [indexed, stored]
21//!
22//!     # View count
23//!     field views: u64 [indexed, stored]
24//!
25//!     # Rating score
26//!     field rating: f64 [indexed, stored]
27//!
28//!     # Raw content hash (not indexed, just stored)
29//!     field content_hash: bytes [stored]
30//!
31//!     # Dense vector with the production IVF-PQ index
32//!     field embedding: dense_vector<768> [indexed<ivf_tq, routing: hnsw, nprobe: 64>]
33//!
34//! }
35//! ```
36//!
37//! # Dense Vector Index Configuration
38//!
39//! Index-related parameters for dense vectors are specified in `indexed<...>`:
40//! - `ivf_tq` - index type
41//! - `centroids: "path"` - path to pre-trained centroids file
42//! - `nprobe: N` - number of clusters to probe (default: 64)
43
44use pest::Parser;
45use pest_derive::Parser;
46
47use super::query_field_router::{QueryRouterRule, RoutingMode};
48use super::schema::{DenseVectorQuantization, FieldType, Schema, SchemaBuilder};
49use crate::Result;
50use crate::error::Error;
51
52#[derive(Parser)]
53#[grammar = "dsl/sdl/sdl.pest"]
54pub struct SdlParser;
55
56use super::schema::{BinaryDenseVectorConfig, DenseVectorConfig};
57use crate::structures::{
58    IndexSize, QueryWeighting, SparseFormat, SparseQueryConfig, SparseVectorConfig,
59    WeightQuantization,
60};
61
62/// Parsed field definition
63#[derive(Debug, Clone)]
64pub struct FieldDef {
65    pub name: String,
66    pub field_type: FieldType,
67    pub indexed: bool,
68    pub stored: bool,
69    /// Tokenizer name for text fields (e.g., "simple", "en_stem", "german")
70    pub tokenizer: Option<String>,
71    /// Whether this field can have multiple values (serialized as array in JSON)
72    pub multi: bool,
73    /// Position tracking mode for phrase queries and multi-field element tracking
74    pub positions: Option<super::schema::PositionMode>,
75    /// Configuration for sparse vector fields
76    pub sparse_vector_config: Option<SparseVectorConfig>,
77    /// Configuration for dense vector fields
78    pub dense_vector_config: Option<DenseVectorConfig>,
79    /// Configuration for binary dense vector fields
80    pub binary_dense_vector_config: Option<BinaryDenseVectorConfig>,
81    /// Whether this field has columnar fast-field storage
82    pub fast: bool,
83    /// Whether this field is a primary key (unique constraint)
84    pub primary: bool,
85    /// Whether build-time document reordering (BP) is enabled for BMP fields
86    pub reorder: bool,
87    /// BM25 k1 of a text field (`indexed<k1: ...>`), `None` = default
88    pub bm25_k1: Option<f32>,
89    /// BM25 b of a text field (`indexed<b: ...>`), `None` = default
90    pub bm25_b: Option<f32>,
91    /// Chunked text field (`indexed<chunked>`): each value is its own BM25 unit
92    pub chunked: bool,
93}
94
95/// Parsed index definition
96#[derive(Debug, Clone)]
97pub struct IndexDef {
98    pub name: String,
99    pub fields: Vec<FieldDef>,
100    pub default_fields: Vec<String>,
101    /// Query router rules for routing queries to specific fields
102    pub query_routers: Vec<QueryRouterRule>,
103    /// BP-reorder `reorder`-attributed BMP fields inside merges
104    /// (index-level `reorder_on_merge: true`). Absent = disabled.
105    pub reorder_on_merge: bool,
106}
107
108impl IndexDef {
109    /// Convert to a Schema
110    pub fn to_schema(&self) -> Schema {
111        let mut builder = SchemaBuilder::default();
112
113        for field in &self.fields {
114            let f = match field.field_type {
115                FieldType::Text => {
116                    let tokenizer = field.tokenizer.as_deref().unwrap_or("simple");
117                    builder.add_text_field_with_tokenizer(
118                        &field.name,
119                        field.indexed,
120                        field.stored,
121                        tokenizer,
122                    )
123                }
124                FieldType::U64 => builder.add_u64_field(&field.name, field.indexed, field.stored),
125                FieldType::I64 => builder.add_i64_field(&field.name, field.indexed, field.stored),
126                FieldType::F64 => builder.add_f64_field(&field.name, field.indexed, field.stored),
127                FieldType::Bytes => builder.add_bytes_field(&field.name, field.stored),
128                FieldType::Json => builder.add_json_field(&field.name, field.stored),
129                FieldType::SparseVector => {
130                    if let Some(config) = &field.sparse_vector_config {
131                        builder.add_sparse_vector_field_with_config(
132                            &field.name,
133                            field.indexed,
134                            field.stored,
135                            config.clone(),
136                        )
137                    } else {
138                        builder.add_sparse_vector_field(&field.name, field.indexed, field.stored)
139                    }
140                }
141                FieldType::DenseVector => {
142                    // Dense vector dimension must be specified via config
143                    let config = field
144                        .dense_vector_config
145                        .as_ref()
146                        .expect("DenseVector field requires dimension to be specified");
147                    builder.add_dense_vector_field_with_config(
148                        &field.name,
149                        field.indexed,
150                        field.stored,
151                        config.clone(),
152                    )
153                }
154                FieldType::BinaryDenseVector => {
155                    let config = field
156                        .binary_dense_vector_config
157                        .as_ref()
158                        .expect("BinaryDenseVector field requires dimension to be specified");
159                    builder.add_binary_dense_vector_field_with_config(
160                        &field.name,
161                        field.indexed,
162                        field.stored,
163                        config.clone(),
164                    )
165                }
166            };
167            if field.multi {
168                builder.set_multi(f, true);
169            }
170            if field.fast {
171                builder.set_fast(f, true);
172            }
173            if field.primary {
174                builder.set_primary_key(f);
175            }
176            if field.reorder {
177                builder.set_reorder(f, true);
178            }
179            if field.chunked {
180                builder.set_chunked(f, true);
181            }
182            if field.bm25_k1.is_some() || field.bm25_b.is_some() {
183                builder.set_bm25_params(f, field.bm25_k1, field.bm25_b);
184            }
185            // Set positions: explicit > auto (ordinal for multi vectors)
186            let positions = field.positions.or({
187                // Auto-set ordinal positions for multi-valued vector fields
188                if field.multi
189                    && matches!(
190                        field.field_type,
191                        FieldType::SparseVector
192                            | FieldType::DenseVector
193                            | FieldType::BinaryDenseVector
194                    )
195                {
196                    Some(super::schema::PositionMode::Ordinal)
197                } else {
198                    None
199                }
200            });
201            if let Some(mode) = positions {
202                builder.set_positions(f, mode);
203            }
204        }
205
206        // Set default fields if specified
207        if !self.default_fields.is_empty() {
208            builder.set_default_fields(self.default_fields.clone());
209        }
210
211        // Set query routers if specified
212        if !self.query_routers.is_empty() {
213            builder.set_query_routers(self.query_routers.clone());
214        }
215
216        builder.set_index_name(self.name.clone());
217
218        if self.reorder_on_merge {
219            if self.fields.iter().any(|f| f.reorder) {
220                builder.set_reorder_on_merge(true);
221            } else {
222                // Fail loud: the option would silently do nothing without at
223                // least one `reorder`-attributed field.
224                log::warn!(
225                    "index '{}': reorder_on_merge is set but no field has the `reorder` attribute — merges will not reorder anything",
226                    self.name,
227                );
228                builder.set_reorder_on_merge(true);
229            }
230        }
231
232        builder.build()
233    }
234
235    /// Create a QueryFieldRouter from the query router rules
236    ///
237    /// Returns None if there are no query router rules defined.
238    /// Returns Err if any regex pattern is invalid.
239    pub fn to_query_router(&self) -> Result<Option<super::query_field_router::QueryFieldRouter>> {
240        if self.query_routers.is_empty() {
241            return Ok(None);
242        }
243
244        super::query_field_router::QueryFieldRouter::from_rules(&self.query_routers)
245            .map(Some)
246            .map_err(Error::Schema)
247    }
248}
249
250/// Parse field type from string
251fn parse_field_type(type_str: &str) -> Result<FieldType> {
252    match type_str {
253        "text" | "string" | "str" => Ok(FieldType::Text),
254        "u64" | "uint" | "unsigned" => Ok(FieldType::U64),
255        "i64" | "int" | "integer" => Ok(FieldType::I64),
256        "f64" | "float" | "double" => Ok(FieldType::F64),
257        "bytes" | "binary" | "blob" => Ok(FieldType::Bytes),
258        "json" => Ok(FieldType::Json),
259        "sparse_vector" => Ok(FieldType::SparseVector),
260        "dense_vector" | "vector" => Ok(FieldType::DenseVector),
261        "binary_dense_vector" | "binary_vector" => Ok(FieldType::BinaryDenseVector),
262        _ => Err(Error::Schema(format!("Unknown field type: {}", type_str))),
263    }
264}
265
266/// Index configuration parsed from indexed<...> attribute
267#[derive(Debug, Clone, Default)]
268enum SoarDirective {
269    /// No `soar:` keyword was present. IVF-TQ resolves this to its selective
270    /// default after the final index type is known.
271    #[default]
272    Unspecified,
273    /// `soar: off` was explicitly requested.
274    Disabled,
275    /// An explicit selective/full/aggressive preset.
276    Enabled(crate::structures::SoarConfig),
277}
278
279#[derive(Debug, Clone, Default)]
280struct IndexConfig {
281    index_type: Option<super::schema::VectorIndexType>,
282    num_clusters: Option<usize>,
283    target_vectors: Option<u64>,
284    tree_levels: Option<u8>,
285    nprobe: Option<usize>,
286    ivf_routing: Option<super::schema::IvfRoutingMode>,
287    soar: SoarDirective,
288    binary_index_type: Option<super::schema::BinaryIndexType>,
289    // Sparse vector index params
290    sparse_format: Option<SparseFormat>,
291    quantization: Option<WeightQuantization>,
292    weight_threshold: Option<f32>,
293    block_size: Option<usize>,
294    bmp_block_size: Option<u32>,
295    bmp_grid_bits: Option<u8>,
296    pruning: Option<f32>,
297    min_terms: Option<usize>,
298    doc_mass: Option<f32>,
299    // Sparse vector query-time config
300    query_tokenizer: Option<String>,
301    query_weighting: Option<QueryWeighting>,
302    query_weight_threshold: Option<f32>,
303    query_max_dims: Option<usize>,
304    query_pruning: Option<f32>,
305    query_min_query_dims: Option<usize>,
306    query_lsp_gamma: Option<usize>,
307    // BMP fixed dims (vocabulary size) and max weight scale
308    dims: Option<u32>,
309    max_weight: Option<f32>,
310    // Position tracking mode for phrase queries
311    positions: Option<super::schema::PositionMode>,
312    // Chunked text field: every value is its own BM25 unit
313    chunked: bool,
314    // BM25 parameters of a text field
315    bm25_k1: Option<f32>,
316    bm25_b: Option<f32>,
317}
318
319/// Parsed attributes from SDL field definition
320struct ParsedAttributes {
321    indexed: bool,
322    stored: bool,
323    multi: bool,
324    fast: bool,
325    primary: bool,
326    reorder: bool,
327    index_config: Option<IndexConfig>,
328}
329
330/// Parse attributes from pest pair
331fn parse_attributes(pair: pest::iterators::Pair<Rule>) -> Result<ParsedAttributes> {
332    let mut attrs = ParsedAttributes {
333        indexed: false,
334        stored: false,
335        multi: false,
336        fast: false,
337        primary: false,
338        reorder: false,
339        index_config: None,
340    };
341
342    for attr in pair.into_inner() {
343        if attr.as_rule() == Rule::attribute {
344            let mut found_config = false;
345            for inner in attr.clone().into_inner() {
346                match inner.as_rule() {
347                    Rule::indexed_with_config => {
348                        attrs.indexed = true;
349                        attrs.index_config = Some(parse_index_config(inner)?);
350                        found_config = true;
351                        break;
352                    }
353                    Rule::stored_with_config => {
354                        attrs.stored = true;
355                        attrs.multi = true; // stored<multi>
356                        found_config = true;
357                        break;
358                    }
359                    _ => {}
360                }
361            }
362            if !found_config {
363                match attr.as_str() {
364                    "indexed" => attrs.indexed = true,
365                    "stored" => attrs.stored = true,
366                    "fast" => attrs.fast = true,
367                    "primary" => attrs.primary = true,
368                    "reorder" => attrs.reorder = true,
369                    _ => {}
370                }
371            }
372        }
373    }
374
375    Ok(attrs)
376}
377
378/// Parse index configuration from indexed<...> attribute
379fn parse_index_config(pair: pest::iterators::Pair<Rule>) -> Result<IndexConfig> {
380    let mut config = IndexConfig::default();
381
382    // indexed_with_config = { "indexed" ~ "<" ~ index_config_params ~ ">" }
383    // index_config_params = { index_config_param ~ ("," ~ index_config_param)* }
384    // index_config_param = { index_type_kwarg | centroids_kwarg | codebook_kwarg | nprobe_kwarg | index_type_spec }
385
386    for inner in pair.into_inner() {
387        if inner.as_rule() == Rule::index_config_params {
388            for param in inner.into_inner() {
389                if param.as_rule() == Rule::index_config_param {
390                    for p in param.into_inner() {
391                        parse_single_index_config_param(&mut config, p)?;
392                    }
393                }
394            }
395        }
396    }
397
398    Ok(config)
399}
400
401/// Parse a single index config parameter
402fn parse_single_index_config_param(
403    config: &mut IndexConfig,
404    p: pest::iterators::Pair<Rule>,
405) -> Result<()> {
406    use super::schema::VectorIndexType;
407
408    match p.as_rule() {
409        Rule::index_type_spec => match p.as_str() {
410            "flat" => {
411                config.index_type = Some(VectorIndexType::Flat);
412                config.binary_index_type = Some(super::schema::BinaryIndexType::Flat);
413            }
414            "ivf" => config.binary_index_type = Some(super::schema::BinaryIndexType::Ivf),
415            "ivf_pq" => config.index_type = Some(VectorIndexType::IvfPq),
416            "ivf_tq" => config.index_type = Some(VectorIndexType::IvfTq),
417            "scann" => {
418                config.index_type = Some(VectorIndexType::Scann);
419                config.binary_index_type = Some(super::schema::BinaryIndexType::Scann);
420            }
421            "tq" => config.index_type = Some(VectorIndexType::Tq),
422            _ => {}
423        },
424        Rule::index_type_kwarg => {
425            // index_type_kwarg = { "index" ~ ":" ~ index_type_spec }
426            if let Some(t) = p.into_inner().next() {
427                match t.as_str() {
428                    "flat" => {
429                        config.index_type = Some(VectorIndexType::Flat);
430                        config.binary_index_type = Some(super::schema::BinaryIndexType::Flat);
431                    }
432                    "ivf" => config.binary_index_type = Some(super::schema::BinaryIndexType::Ivf),
433                    "ivf_pq" => config.index_type = Some(VectorIndexType::IvfPq),
434                    "ivf_tq" => config.index_type = Some(VectorIndexType::IvfTq),
435                    "scann" => {
436                        config.index_type = Some(VectorIndexType::Scann);
437                        config.binary_index_type = Some(super::schema::BinaryIndexType::Scann);
438                    }
439                    "tq" => config.index_type = Some(VectorIndexType::Tq),
440                    _ => {}
441                }
442            }
443        }
444        Rule::num_clusters_kwarg => {
445            // num_clusters_kwarg = { "num_clusters" ~ ":" ~ num_clusters_spec }
446            if let Some(n) = p.into_inner().next() {
447                config.num_clusters = Some(n.as_str().parse().map_err(|_| {
448                    Error::Schema(format!(
449                        "num_clusters '{}' does not fit on this platform",
450                        n.as_str()
451                    ))
452                })?);
453            }
454        }
455        Rule::target_vectors_kwarg => {
456            if let Some(value) = p.into_inner().next() {
457                config.target_vectors = Some(value.as_str().parse().map_err(|_| {
458                    Error::Schema(format!(
459                        "target_vectors '{}' does not fit in an unsigned 64-bit integer",
460                        value.as_str()
461                    ))
462                })?);
463            }
464        }
465        Rule::nprobe_kwarg => {
466            // nprobe_kwarg = { "nprobe" ~ ":" ~ nprobe_spec }
467            if let Some(n) = p.into_inner().next() {
468                config.nprobe = Some(n.as_str().parse().map_err(|_| {
469                    Error::Schema(format!(
470                        "nprobe '{}' does not fit on this platform",
471                        n.as_str()
472                    ))
473                })?);
474            }
475        }
476        Rule::tree_levels_kwarg => {
477            if let Some(value) = p.into_inner().next() {
478                config.tree_levels = Some(value.as_str().parse().map_err(|_| {
479                    Error::Schema(format!(
480                        "tree_levels '{}' does not fit in an unsigned 8-bit integer",
481                        value.as_str()
482                    ))
483                })?);
484            }
485        }
486        Rule::routing_kwarg => {
487            if let Some(value) = p.into_inner().next() {
488                config.ivf_routing = Some(match value.as_str() {
489                    "flat" => super::schema::IvfRoutingMode::Flat,
490                    "two_level" => super::schema::IvfRoutingMode::TwoLevel,
491                    "hnsw" => super::schema::IvfRoutingMode::Hnsw,
492                    _ => super::schema::IvfRoutingMode::Auto,
493                });
494            }
495        }
496        Rule::soar_kwarg => {
497            // soar_kwarg = { "soar" ~ ":" ~ soar_spec }
498            if let Some(s) = p.into_inner().next() {
499                use crate::structures::SoarConfig;
500                config.soar = match s.as_str() {
501                    "selective" => SoarDirective::Enabled(SoarConfig::new()),
502                    "full" => SoarDirective::Enabled(SoarConfig::full()),
503                    "aggressive" => SoarDirective::Enabled(SoarConfig::aggressive()),
504                    _ => SoarDirective::Disabled, // "off"
505                };
506            }
507        }
508        Rule::quantization_kwarg => {
509            // quantization_kwarg = { "quantization" ~ ":" ~ quantization_spec }
510            if let Some(q) = p.into_inner().next() {
511                config.quantization = Some(match q.as_str() {
512                    "float32" | "f32" => WeightQuantization::Float32,
513                    "float16" | "f16" => WeightQuantization::Float16,
514                    "uint8" | "u8" => WeightQuantization::UInt8,
515                    "uint4" | "u4" => WeightQuantization::UInt4,
516                    _ => WeightQuantization::default(),
517                });
518            }
519        }
520        Rule::weight_threshold_kwarg => {
521            // weight_threshold_kwarg = { "weight_threshold" ~ ":" ~ weight_threshold_spec }
522            if let Some(t) = p.into_inner().next() {
523                config.weight_threshold = Some(t.as_str().parse().unwrap_or_else(|_| {
524                    log::warn!(
525                        "Invalid weight_threshold value '{}', using default 0.0",
526                        t.as_str()
527                    );
528                    0.0
529                }));
530            }
531        }
532        Rule::block_size_kwarg => {
533            // block_size_kwarg = { "block_size" ~ ":" ~ block_size_spec }
534            if let Some(n) = p.into_inner().next() {
535                config.block_size = Some(n.as_str().parse().unwrap_or_else(|_| {
536                    log::warn!(
537                        "Invalid block_size value '{}', using default 128",
538                        n.as_str()
539                    );
540                    128
541                }));
542            }
543        }
544        Rule::bmp_grid_bits_kwarg => {
545            // bmp_grid_bits_kwarg = { "bmp_grid_bits" ~ ":" ~ bits_spec }
546            if let Some(n) = p.into_inner().next() {
547                config.bmp_grid_bits = Some(n.as_str().parse().unwrap_or_else(|_| {
548                    log::warn!(
549                        "Invalid bmp_grid_bits value '{}', using default {}",
550                        n.as_str(),
551                        SparseVectorConfig::DEFAULT_BMP_GRID_BITS,
552                    );
553                    SparseVectorConfig::DEFAULT_BMP_GRID_BITS
554                }));
555            }
556        }
557        Rule::bmp_block_size_kwarg => {
558            // bmp_block_size_kwarg = { "bmp_block_size" ~ ":" ~ block_size_spec }
559            if let Some(n) = p.into_inner().next() {
560                config.bmp_block_size = Some(n.as_str().parse().unwrap_or_else(|_| {
561                    log::warn!(
562                        "Invalid bmp_block_size value '{}', using default {}",
563                        n.as_str(),
564                        SparseVectorConfig::DEFAULT_BMP_BLOCK_SIZE,
565                    );
566                    SparseVectorConfig::DEFAULT_BMP_BLOCK_SIZE
567                }));
568            }
569        }
570        Rule::pruning_kwarg => {
571            // pruning_kwarg = { "pruning" ~ ":" ~ pruning_spec }
572            if let Some(f) = p.into_inner().next() {
573                config.pruning = Some(f.as_str().parse().unwrap_or_else(|_| {
574                    log::warn!("Invalid pruning value '{}', using default 1.0", f.as_str());
575                    1.0
576                }));
577            }
578        }
579        Rule::doc_mass_kwarg => {
580            // doc_mass_kwarg = { "doc_mass" ~ ":" ~ pruning_spec }
581            if let Some(f) = p.into_inner().next() {
582                config.doc_mass = Some(f.as_str().parse().unwrap_or_else(|_| {
583                    log::warn!("Invalid doc_mass value '{}', using 1.0 (off)", f.as_str());
584                    1.0
585                }));
586            }
587        }
588        Rule::min_terms_kwarg => {
589            if let Some(n) = p.into_inner().next() {
590                config.min_terms = Some(n.as_str().parse().unwrap_or_else(|_| {
591                    log::warn!("Invalid min_terms value '{}', using default 4", n.as_str());
592                    4
593                }));
594            }
595        }
596        Rule::sparse_format_kwarg => {
597            // sparse_format_kwarg = { "format" ~ ":" ~ sparse_format_spec }
598            if let Some(f) = p.into_inner().next() {
599                config.sparse_format = Some(match f.as_str() {
600                    "bmp" => SparseFormat::Bmp,
601                    "maxscore" => SparseFormat::MaxScore,
602                    _ => SparseFormat::default(),
603                });
604            }
605        }
606        Rule::sparse_dims_kwarg => {
607            if let Some(n) = p.into_inner().next() {
608                config.dims = Some(n.as_str().parse().unwrap_or_else(|_| {
609                    log::warn!("Invalid dims value '{}', using default 105879", n.as_str());
610                    105879
611                }));
612            }
613        }
614        Rule::sparse_max_weight_kwarg => {
615            if let Some(f) = p.into_inner().next() {
616                config.max_weight = Some(f.as_str().parse().unwrap_or_else(|_| {
617                    log::warn!(
618                        "Invalid max_weight value '{}', using default 5.0",
619                        f.as_str()
620                    );
621                    5.0
622                }));
623            }
624        }
625        Rule::query_config_block => {
626            // query_config_block = { "query" ~ "<" ~ query_config_params ~ ">" }
627            parse_query_config_block(config, p);
628        }
629        Rule::positions_kwarg => {
630            // positions_kwarg = { "positions" | "ordinal" | "token_position" }
631            use super::schema::PositionMode;
632            config.positions = Some(match p.as_str() {
633                "ordinal" => PositionMode::Ordinal,
634                "token_position" => PositionMode::TokenPosition,
635                _ => PositionMode::Full, // "positions" or any other value defaults to Full
636            });
637        }
638        Rule::chunked_kwarg => {
639            config.chunked = true;
640        }
641        Rule::bm25_k1_kwarg => {
642            if let Some(v) = p.into_inner().next() {
643                config.bm25_k1 = Some(v.as_str().parse().map_err(|_| {
644                    Error::Schema(format!("invalid BM25 k1 value '{}'", v.as_str()))
645                })?);
646            }
647        }
648        Rule::bm25_b_kwarg => {
649            if let Some(v) = p.into_inner().next() {
650                let b: f32 = v
651                    .as_str()
652                    .parse()
653                    .map_err(|_| Error::Schema(format!("invalid BM25 b value '{}'", v.as_str())))?;
654                if !(0.0..=1.0).contains(&b) {
655                    return Err(Error::Schema(format!(
656                        "BM25 b must be between 0 and 1, got {b}"
657                    )));
658                }
659                config.bm25_b = Some(b);
660            }
661        }
662        _ => {}
663    }
664
665    Ok(())
666}
667
668/// Parse query configuration block: query<tokenizer: "...", weighting: idf>
669fn parse_query_config_block(config: &mut IndexConfig, pair: pest::iterators::Pair<Rule>) {
670    for inner in pair.into_inner() {
671        if inner.as_rule() == Rule::query_config_params {
672            for param in inner.into_inner() {
673                if param.as_rule() == Rule::query_config_param {
674                    for p in param.into_inner() {
675                        match p.as_rule() {
676                            Rule::query_tokenizer_kwarg => {
677                                // query_tokenizer_kwarg = { "tokenizer" ~ ":" ~ tokenizer_path }
678                                if let Some(path) = p.into_inner().next()
679                                    && let Some(inner_path) = path.into_inner().next()
680                                {
681                                    config.query_tokenizer = Some(inner_path.as_str().to_string());
682                                }
683                            }
684                            Rule::query_weighting_kwarg => {
685                                // query_weighting_kwarg = { "weighting" ~ ":" ~ weighting_spec }
686                                if let Some(w) = p.into_inner().next() {
687                                    config.query_weighting = Some(match w.as_str() {
688                                        "one" => QueryWeighting::One,
689                                        "idf" => QueryWeighting::Idf,
690                                        "idf_file" => QueryWeighting::IdfFile,
691                                        _ => QueryWeighting::One,
692                                    });
693                                }
694                            }
695                            Rule::query_weight_threshold_kwarg => {
696                                if let Some(t) = p.into_inner().next() {
697                                    config.query_weight_threshold =
698                                        Some(t.as_str().parse().unwrap_or_else(|_| {
699                                            log::warn!(
700                                                "Invalid query weight_threshold '{}', using 0.0",
701                                                t.as_str()
702                                            );
703                                            0.0
704                                        }));
705                                }
706                            }
707                            Rule::query_max_dims_kwarg => {
708                                if let Some(t) = p.into_inner().next() {
709                                    config.query_max_dims =
710                                        Some(t.as_str().parse().unwrap_or_else(|_| {
711                                            log::warn!(
712                                                "Invalid query max_dims '{}', using 0",
713                                                t.as_str()
714                                            );
715                                            0
716                                        }));
717                                }
718                            }
719                            Rule::query_pruning_kwarg => {
720                                if let Some(t) = p.into_inner().next() {
721                                    config.query_pruning =
722                                        Some(t.as_str().parse().unwrap_or_else(|_| {
723                                            log::warn!(
724                                                "Invalid query pruning '{}', using 1.0",
725                                                t.as_str()
726                                            );
727                                            1.0
728                                        }));
729                                }
730                            }
731                            Rule::query_min_query_dims_kwarg => {
732                                if let Some(t) = p.into_inner().next() {
733                                    config.query_min_query_dims =
734                                        Some(t.as_str().parse().unwrap_or_else(|_| {
735                                            log::warn!(
736                                                "Invalid query min_query_dims '{}', using 4",
737                                                t.as_str()
738                                            );
739                                            4
740                                        }));
741                                }
742                            }
743                            Rule::query_lsp_gamma_kwarg => {
744                                if let Some(value) = p.into_inner().next() {
745                                    config.query_lsp_gamma =
746                                        Some(value.as_str().parse().unwrap_or_else(|_| {
747                                            log::warn!(
748                                                "Invalid query lsp_gamma '{}', using 0",
749                                                value.as_str()
750                                            );
751                                            0
752                                        }));
753                                }
754                            }
755                            _ => {}
756                        }
757                    }
758                }
759            }
760        }
761    }
762}
763
764/// Parse a field definition from pest pair
765fn parse_field_def(pair: pest::iterators::Pair<Rule>) -> Result<FieldDef> {
766    let mut inner = pair.into_inner();
767
768    let name = inner
769        .next()
770        .ok_or_else(|| Error::Schema("Missing field name".to_string()))?
771        .as_str()
772        .to_string();
773
774    let field_type_str = inner
775        .next()
776        .ok_or_else(|| Error::Schema("Missing field type".to_string()))?
777        .as_str();
778
779    let field_type = parse_field_type(field_type_str)?;
780
781    // Parse optional tokenizer spec, sparse_vector_config, dense_vector_config, and attributes
782    let mut tokenizer = None;
783    let mut sparse_vector_config = None;
784    let mut dense_vector_config = None;
785    let mut binary_dense_vector_config = None;
786    let mut indexed = true;
787    let mut stored = true;
788    let mut multi = false;
789    let mut fast = false;
790    let mut primary = false;
791    let mut reorder = false;
792    let mut index_config: Option<IndexConfig> = None;
793
794    for item in inner {
795        match item.as_rule() {
796            Rule::tokenizer_spec => {
797                // `<name>` or `<stem(by: field, default: simple)>`: store the
798                // canonical spec string (validated against the index in
799                // `parse_index_def`).
800                let raw = item.as_str().trim();
801                let raw = raw
802                    .strip_prefix('<')
803                    .and_then(|s| s.strip_suffix('>'))
804                    .unwrap_or(raw);
805                let spec = crate::tokenizer::TokenizerSpec::parse(raw)
806                    .map_err(|e| Error::Schema(format!("Field '{name}': {e}")))?;
807                tokenizer = Some(spec.to_string());
808            }
809            Rule::sparse_vector_config => {
810                // Parse named parameters: <index_size: u16, quantization: uint8, weight_threshold: 0.1>
811                sparse_vector_config = Some(parse_sparse_vector_config(item));
812            }
813            Rule::dense_vector_config => {
814                // Parse dense_vector_params (keyword or positional) - only dims
815                dense_vector_config = Some(parse_dense_vector_config(item));
816            }
817            Rule::binary_dense_vector_config => {
818                // Parse binary dense vector config - just dimension (number of bits)
819                let dim: usize = item
820                    .into_inner()
821                    .next()
822                    .map(|d| d.as_str().parse().unwrap_or(0))
823                    .unwrap_or(0);
824                if dim == 0 || !dim.is_multiple_of(8) {
825                    return Err(Error::Schema(format!(
826                        "BinaryDenseVector dimension must be a positive multiple of 8, got {dim}"
827                    )));
828                }
829                binary_dense_vector_config = Some(BinaryDenseVectorConfig::new(dim));
830            }
831            Rule::attributes => {
832                let attrs = parse_attributes(item)?;
833                indexed = attrs.indexed;
834                stored = attrs.stored;
835                multi = attrs.multi;
836                fast = attrs.fast;
837                primary = attrs.primary;
838                reorder = attrs.reorder;
839                index_config = attrs.index_config;
840            }
841            _ => {}
842        }
843    }
844
845    // PEG grammar ambiguity: both dense_vector_config and binary_dense_vector_config
846    // match `<N>`, and dense_vector_config comes first in the ordered choice. When the
847    // field_type is BinaryDenseVector, remap the matched dense_vector_config.
848    if field_type == FieldType::BinaryDenseVector
849        && binary_dense_vector_config.is_none()
850        && let Some(ref dv_config) = dense_vector_config
851    {
852        let dim = dv_config.dim;
853        if dim == 0 || !dim.is_multiple_of(8) {
854            return Err(Error::Schema(format!(
855                "BinaryDenseVector dimension must be a positive multiple of 8, got {dim}"
856            )));
857        }
858        binary_dense_vector_config = Some(BinaryDenseVectorConfig::new(dim));
859        dense_vector_config = None;
860    }
861
862    // Primary key implies fast + indexed (needed for dedup lookups)
863    if primary {
864        fast = true;
865        indexed = true;
866    }
867
868    // Merge index config into vector configs if both exist
869    let mut positions = None;
870    let mut chunked = false;
871    let mut bm25_k1 = None;
872    let mut bm25_b = None;
873    if let Some(idx_cfg) = index_config {
874        positions = idx_cfg.positions;
875        chunked = idx_cfg.chunked;
876        bm25_k1 = idx_cfg.bm25_k1;
877        bm25_b = idx_cfg.bm25_b;
878        if (bm25_k1.is_some() || bm25_b.is_some()) && field_type != FieldType::Text {
879            return Err(Error::Schema(format!(
880                "field '{name}': BM25 `k1`/`b` require a text field, got {field_type:?}"
881            )));
882        }
883        if chunked {
884            if field_type != FieldType::Text {
885                return Err(Error::Schema(format!(
886                    "field '{name}': `chunked` requires a text field, got {field_type:?}"
887                )));
888            }
889            if let Some(mode) = positions
890                && mode != super::schema::PositionMode::TokenPosition
891            {
892                return Err(Error::Schema(format!(
893                    "field '{name}': a chunked text field may only declare `token_position` \
894                     (positions restart in every chunk and the chunk is the ordinal); \
895                     `{}` is not allowed",
896                    match mode {
897                        super::schema::PositionMode::Ordinal => "ordinal",
898                        super::schema::PositionMode::Full => "positions",
899                        super::schema::PositionMode::TokenPosition => unreachable!(),
900                    }
901                )));
902            }
903            // Stored values of a chunked field round-trip as an array.
904            multi = true;
905        }
906        if let Some(ref mut bv_config) = binary_dense_vector_config {
907            apply_index_config_to_binary_dense_vector(bv_config, idx_cfg)?;
908        } else if let Some(ref mut dv_config) = dense_vector_config {
909            apply_index_config_to_dense_vector(dv_config, idx_cfg)?;
910        } else if field_type == FieldType::SparseVector {
911            reject_scann_options_for_non_dense_vector(&idx_cfg, "sparse vector")?;
912            // For sparse vectors, create default config if not present and apply index params
913            let sv_config = sparse_vector_config.get_or_insert(SparseVectorConfig::default());
914            apply_index_config_to_sparse_vector(sv_config, idx_cfg);
915        } else {
916            reject_scann_options_for_non_dense_vector(&idx_cfg, "non-vector field")?;
917        }
918    }
919
920    Ok(FieldDef {
921        name,
922        field_type,
923        indexed,
924        stored,
925        tokenizer,
926        multi,
927        positions,
928        sparse_vector_config,
929        dense_vector_config,
930        binary_dense_vector_config,
931        fast,
932        primary,
933        reorder,
934        chunked,
935        bm25_k1,
936        bm25_b,
937    })
938}
939
940fn reject_scann_options_for_non_dense_vector(config: &IndexConfig, field_kind: &str) -> Result<()> {
941    if config.index_type == Some(super::schema::VectorIndexType::Scann)
942        || config.binary_index_type == Some(super::schema::BinaryIndexType::Scann)
943        || config.tree_levels.is_some()
944        || config.target_vectors.is_some()
945    {
946        return Err(Error::Schema(format!(
947            "vector index options require a dense or binary dense vector field, not a {field_kind}"
948        )));
949    }
950    Ok(())
951}
952
953/// Apply index configuration from indexed<...> to BinaryDenseVectorConfig
954fn apply_index_config_to_binary_dense_vector(
955    config: &mut BinaryDenseVectorConfig,
956    idx_cfg: IndexConfig,
957) -> Result<()> {
958    if idx_cfg.target_vectors == Some(0) {
959        return Err(Error::Schema(
960            "target_vectors must be greater than zero".to_string(),
961        ));
962    }
963    if idx_cfg.index_type.is_some() && idx_cfg.binary_index_type.is_none() {
964        return Err(Error::Schema(
965            "binary dense vectors support only 'flat', 'ivf', or 'scann' index types".to_string(),
966        ));
967    }
968    if let Some(index_type) = idx_cfg.binary_index_type {
969        config.index_type = index_type;
970    }
971    if idx_cfg.target_vectors.is_some() && config.index_type == super::schema::BinaryIndexType::Flat
972    {
973        return Err(Error::Schema(
974            "'target_vectors' is only valid for binary IVF or ScaNN automatic topology".to_string(),
975        ));
976    }
977    validate_scann_index_options(
978        "binary dense vector",
979        config.index_type == super::schema::BinaryIndexType::Scann,
980        &idx_cfg,
981    )?;
982    match &idx_cfg.soar {
983        SoarDirective::Unspecified | SoarDirective::Disabled => config.soar = None,
984        SoarDirective::Enabled(soar)
985            if config.index_type == super::schema::BinaryIndexType::Scann =>
986        {
987            config.soar = Some(soar.clone());
988        }
989        SoarDirective::Enabled(_) => {
990            return Err(Error::Schema(
991                "'soar' on a binary dense vector requires the ScaNN index".to_string(),
992            ));
993        }
994    }
995    if idx_cfg.num_clusters.is_some() {
996        config.num_clusters = idx_cfg.num_clusters;
997    }
998    if idx_cfg.target_vectors.is_some() {
999        config.target_vectors = idx_cfg.target_vectors;
1000    }
1001    if idx_cfg.tree_levels.is_some() {
1002        config.tree_levels = idx_cfg.tree_levels;
1003    }
1004    if let Some(nprobe) = idx_cfg.nprobe {
1005        config.nprobe = nprobe;
1006    }
1007    if let Some(routing) = idx_cfg.ivf_routing {
1008        config.ivf_routing = routing;
1009    }
1010    Ok(())
1011}
1012
1013/// Apply index configuration from indexed<...> to DenseVectorConfig
1014fn apply_index_config_to_dense_vector(
1015    config: &mut DenseVectorConfig,
1016    idx_cfg: IndexConfig,
1017) -> Result<()> {
1018    if idx_cfg.target_vectors == Some(0) {
1019        return Err(Error::Schema(
1020            "target_vectors must be greater than zero".to_string(),
1021        ));
1022    }
1023    if idx_cfg.binary_index_type.is_some() && idx_cfg.index_type.is_none() {
1024        return Err(Error::Schema(
1025            "float dense vectors do not support the binary-only 'ivf' index type; use 'ivf_tq' or 'scann'"
1026                .to_string(),
1027        ));
1028    }
1029    // Apply index type if specified
1030    if let Some(index_type) = idx_cfg.index_type {
1031        config.index_type = index_type;
1032    }
1033    if idx_cfg.target_vectors.is_some()
1034        && matches!(
1035            config.index_type,
1036            super::schema::VectorIndexType::Flat | super::schema::VectorIndexType::Tq
1037        )
1038    {
1039        return Err(Error::Schema(
1040            "'target_vectors' is only valid for IVF-TQ or ScaNN automatic topology".to_string(),
1041        ));
1042    }
1043
1044    validate_scann_index_options(
1045        "dense vector",
1046        config.index_type == super::schema::VectorIndexType::Scann,
1047        &idx_cfg,
1048    )?;
1049    if idx_cfg.target_vectors.is_some() {
1050        config.target_vectors = idx_cfg.target_vectors;
1051    }
1052
1053    // TQ scans every code (no probing, no clusters, no routing); accepting
1054    // these knobs silently would misrepresent how the field is searched.
1055    if config.index_type == super::schema::VectorIndexType::Tq {
1056        for (option, present) in [
1057            ("num_clusters", idx_cfg.num_clusters.is_some()),
1058            ("nprobe", idx_cfg.nprobe.is_some()),
1059            ("routing", idx_cfg.ivf_routing.is_some()),
1060        ] {
1061            if present {
1062                log::warn!(
1063                    "'{option}' has no effect on the 'tq' index (training-free full \
1064                     scan); ignoring"
1065                );
1066            }
1067        }
1068        // Canonicalize to the same shape as DenseVectorConfig::tq() so every
1069        // construction path yields an identical config for a `tq` field.
1070        config.num_clusters = None;
1071        config.nprobe = 0;
1072        config.ivf_routing = super::schema::IvfRoutingMode::Flat;
1073        apply_soar_to_dense_vector(config, idx_cfg)?;
1074        return Ok(());
1075    }
1076
1077    // Apply num_clusters for IVF-based indexes
1078    if idx_cfg.num_clusters.is_some() {
1079        config.num_clusters = idx_cfg.num_clusters;
1080    }
1081    if idx_cfg.tree_levels.is_some() {
1082        config.tree_levels = idx_cfg.tree_levels;
1083    }
1084
1085    // Apply nprobe if specified
1086    if let Some(nprobe) = idx_cfg.nprobe {
1087        config.nprobe = nprobe;
1088    }
1089    if let Some(routing) = idx_cfg.ivf_routing {
1090        config.ivf_routing = routing;
1091    }
1092
1093    apply_soar_to_dense_vector(config, idx_cfg)?;
1094    Ok(())
1095}
1096
1097const MAX_SCANN_TREE_LEVELS: u8 = 3;
1098const MAX_SCANN_LEAVES: usize = 30_000_000;
1099
1100fn validate_scann_index_options(
1101    field_kind: &str,
1102    is_scann: bool,
1103    config: &IndexConfig,
1104) -> Result<()> {
1105    if !is_scann {
1106        if config.tree_levels.is_some() {
1107            return Err(Error::Schema(format!(
1108                "'tree_levels' is only valid for a ScaNN {field_kind} index"
1109            )));
1110        }
1111        return Ok(());
1112    }
1113
1114    if config.ivf_routing.is_some() {
1115        return Err(Error::Schema(format!(
1116            "'routing' is not configurable for ScaNN {field_kind} indexes; ScaNN owns its hierarchical routing"
1117        )));
1118    }
1119
1120    if let Some(tree_levels) = config.tree_levels
1121        && !(1..=MAX_SCANN_TREE_LEVELS).contains(&tree_levels)
1122    {
1123        return Err(Error::Schema(format!(
1124            "ScaNN tree_levels must be in 1..={MAX_SCANN_TREE_LEVELS}, got {tree_levels}"
1125        )));
1126    }
1127    if let Some(num_clusters) = config.num_clusters {
1128        if num_clusters < 2 {
1129            return Err(Error::Schema(
1130                "ScaNN num_clusters (terminal leaf count) must be at least 2".to_string(),
1131            ));
1132        }
1133        if num_clusters > MAX_SCANN_LEAVES {
1134            return Err(Error::Schema(format!(
1135                "ScaNN num_clusters cannot exceed {MAX_SCANN_LEAVES}, got {num_clusters}"
1136            )));
1137        }
1138        let nprobe = config.nprobe.unwrap_or(64);
1139        if nprobe > num_clusters {
1140            return Err(Error::Schema(format!(
1141                "ScaNN nprobe ({nprobe}) cannot exceed explicit num_clusters ({num_clusters})"
1142            )));
1143        }
1144    }
1145    if config.nprobe == Some(0) {
1146        return Err(Error::Schema("ScaNN nprobe must be positive".to_string()));
1147    }
1148    Ok(())
1149}
1150
1151/// Apply SOAR spilling if specified (IVF-based indexes only)
1152fn apply_soar_to_dense_vector(config: &mut DenseVectorConfig, idx_cfg: IndexConfig) -> Result<()> {
1153    match idx_cfg.soar {
1154        SoarDirective::Unspecified => {
1155            config.soar = config
1156                .supports_soar()
1157                .then(crate::structures::SoarConfig::default);
1158        }
1159        SoarDirective::Disabled => {
1160            config.soar = None;
1161        }
1162        SoarDirective::Enabled(soar) => {
1163            if config.supports_soar() {
1164                config.soar = Some(soar);
1165            } else {
1166                config.soar = None;
1167                return Err(Error::Schema(format!(
1168                    "'soar' requires the IVF-TQ index and is not implemented for {:?}",
1169                    config.index_type
1170                )));
1171            }
1172        }
1173    }
1174    Ok(())
1175}
1176
1177/// Parse sparse_vector_config - only index_size (positional)
1178/// Example: <u16> or <u32>
1179fn parse_sparse_vector_config(pair: pest::iterators::Pair<Rule>) -> SparseVectorConfig {
1180    let mut index_size = IndexSize::default();
1181
1182    // Parse positional index_size_spec
1183    for inner in pair.into_inner() {
1184        if inner.as_rule() == Rule::index_size_spec {
1185            index_size = match inner.as_str() {
1186                "u16" => IndexSize::U16,
1187                "u32" => IndexSize::U32,
1188                _ => IndexSize::default(),
1189            };
1190        }
1191    }
1192
1193    SparseVectorConfig {
1194        index_size,
1195        ..SparseVectorConfig::default()
1196    }
1197}
1198
1199/// Apply index configuration from indexed<...> to SparseVectorConfig
1200fn apply_index_config_to_sparse_vector(config: &mut SparseVectorConfig, idx_cfg: IndexConfig) {
1201    if let Some(f) = idx_cfg.sparse_format {
1202        config.format = f;
1203    }
1204    if let Some(q) = idx_cfg.quantization {
1205        config.weight_quantization = q;
1206    }
1207    if let Some(t) = idx_cfg.weight_threshold {
1208        config.weight_threshold = t;
1209    }
1210    if let Some(bs) = idx_cfg.block_size {
1211        let adjusted = bs.next_power_of_two();
1212        if adjusted != bs {
1213            log::warn!(
1214                "block_size {} adjusted to next power of two: {}",
1215                bs,
1216                adjusted
1217            );
1218        }
1219        config.block_size = adjusted;
1220    }
1221    if let Some(bs) = idx_cfg.bmp_block_size {
1222        let adjusted = bs.next_power_of_two().clamp(1, 256);
1223        if adjusted != bs {
1224            log::warn!(
1225                "bmp_block_size {} adjusted to power of two in 1..=256: {}",
1226                bs,
1227                adjusted
1228            );
1229        }
1230        config.bmp_block_size = adjusted;
1231    }
1232    if let Some(bits) = idx_cfg.bmp_grid_bits {
1233        if bits == 2 || bits == 4 {
1234            config.bmp_grid_bits = bits;
1235        } else {
1236            log::warn!(
1237                "bmp_grid_bits {} unsupported (must be 2 or 4), using {}",
1238                bits,
1239                SparseVectorConfig::DEFAULT_BMP_GRID_BITS,
1240            );
1241            config.bmp_grid_bits = SparseVectorConfig::DEFAULT_BMP_GRID_BITS;
1242        }
1243    }
1244    if let Some(p) = idx_cfg.pruning {
1245        let clamped = p.clamp(0.0, 1.0);
1246        if (clamped - p).abs() > f32::EPSILON {
1247            log::warn!(
1248                "pruning {} clamped to valid range [0.0, 1.0]: {}",
1249                p,
1250                clamped
1251            );
1252        }
1253        config.pruning = Some(clamped);
1254    }
1255    if let Some(mt) = idx_cfg.min_terms {
1256        config.min_terms = mt;
1257    }
1258    if let Some(dm) = idx_cfg.doc_mass {
1259        let clamped = dm.clamp(0.0, 1.0);
1260        if (clamped - dm).abs() > f32::EPSILON {
1261            log::warn!(
1262                "doc_mass {} clamped to valid range [0.0, 1.0]: {}",
1263                dm,
1264                clamped
1265            );
1266        }
1267        config.doc_mass = Some(clamped);
1268    }
1269    if let Some(d) = idx_cfg.dims {
1270        config.dims = Some(d);
1271    }
1272    if let Some(mw) = idx_cfg.max_weight {
1273        config.max_weight = Some(mw);
1274    }
1275    // Apply query-time configuration if present
1276    if idx_cfg.query_tokenizer.is_some()
1277        || idx_cfg.query_weighting.is_some()
1278        || idx_cfg.query_weight_threshold.is_some()
1279        || idx_cfg.query_max_dims.is_some()
1280        || idx_cfg.query_pruning.is_some()
1281        || idx_cfg.query_min_query_dims.is_some()
1282        || idx_cfg.query_lsp_gamma.is_some()
1283    {
1284        let query_config = config
1285            .query_config
1286            .get_or_insert(SparseQueryConfig::default());
1287        if let Some(tokenizer) = idx_cfg.query_tokenizer {
1288            query_config.tokenizer = Some(tokenizer);
1289        }
1290        if let Some(weighting) = idx_cfg.query_weighting {
1291            query_config.weighting = weighting;
1292        }
1293        if let Some(t) = idx_cfg.query_weight_threshold {
1294            query_config.weight_threshold = t;
1295        }
1296        if let Some(d) = idx_cfg.query_max_dims {
1297            query_config.max_query_dims = Some(d);
1298        }
1299        if let Some(p) = idx_cfg.query_pruning {
1300            query_config.pruning = Some(p);
1301        }
1302        if let Some(m) = idx_cfg.query_min_query_dims {
1303            query_config.min_query_dims = m;
1304        }
1305        if let Some(gamma) = idx_cfg.query_lsp_gamma {
1306            query_config.lsp_gamma = Some(gamma);
1307        }
1308    }
1309}
1310
1311/// Parse dense_vector_config - dims and optional quantization type
1312/// All index-related params are in indexed<...> attribute
1313fn parse_dense_vector_config(pair: pest::iterators::Pair<Rule>) -> DenseVectorConfig {
1314    let mut dim: usize = 0;
1315    let mut quantization = DenseVectorQuantization::F32;
1316
1317    // Navigate to dense_vector_params
1318    for params in pair.into_inner() {
1319        if params.as_rule() == Rule::dense_vector_params {
1320            for inner in params.into_inner() {
1321                match inner.as_rule() {
1322                    Rule::dense_vector_keyword_params => {
1323                        for kwarg in inner.into_inner() {
1324                            match kwarg.as_rule() {
1325                                Rule::dims_kwarg => {
1326                                    if let Some(d) = kwarg.into_inner().next() {
1327                                        dim = d.as_str().parse().unwrap_or(0);
1328                                    }
1329                                }
1330                                Rule::quant_type_spec => {
1331                                    quantization = parse_quant_type(kwarg.as_str());
1332                                }
1333                                _ => {}
1334                            }
1335                        }
1336                    }
1337                    Rule::dense_vector_positional_params => {
1338                        for item in inner.into_inner() {
1339                            match item.as_rule() {
1340                                Rule::dimension_spec => {
1341                                    dim = item.as_str().parse().unwrap_or(0);
1342                                }
1343                                Rule::quant_type_spec => {
1344                                    quantization = parse_quant_type(item.as_str());
1345                                }
1346                                _ => {}
1347                            }
1348                        }
1349                    }
1350                    _ => {}
1351                }
1352            }
1353        }
1354    }
1355
1356    DenseVectorConfig::new(dim).with_quantization(quantization)
1357}
1358
1359fn parse_quant_type(s: &str) -> DenseVectorQuantization {
1360    match s.trim() {
1361        "f16" => DenseVectorQuantization::F16,
1362        "uint8" | "u8" => DenseVectorQuantization::UInt8,
1363        _ => DenseVectorQuantization::F32,
1364    }
1365}
1366
1367/// Parse default_fields definition
1368fn parse_default_fields_def(pair: pest::iterators::Pair<Rule>) -> Vec<String> {
1369    pair.into_inner().map(|p| p.as_str().to_string()).collect()
1370}
1371
1372/// Parse a query router definition
1373fn parse_query_router_def(pair: pest::iterators::Pair<Rule>) -> Result<QueryRouterRule> {
1374    let mut pattern = String::new();
1375    let mut substitution = String::new();
1376    let mut target_field = String::new();
1377    let mut mode = RoutingMode::Additional;
1378
1379    for prop in pair.into_inner() {
1380        if prop.as_rule() != Rule::query_router_prop {
1381            continue;
1382        }
1383
1384        for inner in prop.into_inner() {
1385            match inner.as_rule() {
1386                Rule::query_router_pattern => {
1387                    if let Some(regex_str) = inner.into_inner().next() {
1388                        pattern = parse_string_value(regex_str);
1389                    }
1390                }
1391                Rule::query_router_substitution => {
1392                    if let Some(quoted) = inner.into_inner().next() {
1393                        substitution = parse_string_value(quoted);
1394                    }
1395                }
1396                Rule::query_router_target => {
1397                    if let Some(ident) = inner.into_inner().next() {
1398                        target_field = ident.as_str().to_string();
1399                    }
1400                }
1401                Rule::query_router_mode => {
1402                    if let Some(mode_val) = inner.into_inner().next() {
1403                        mode = match mode_val.as_str() {
1404                            "exclusive" => RoutingMode::Exclusive,
1405                            "additional" => RoutingMode::Additional,
1406                            _ => RoutingMode::Additional,
1407                        };
1408                    }
1409                }
1410                _ => {}
1411            }
1412        }
1413    }
1414
1415    if pattern.is_empty() {
1416        return Err(Error::Schema("query_router missing 'pattern'".to_string()));
1417    }
1418    if substitution.is_empty() {
1419        return Err(Error::Schema(
1420            "query_router missing 'substitution'".to_string(),
1421        ));
1422    }
1423    if target_field.is_empty() {
1424        return Err(Error::Schema(
1425            "query_router missing 'target_field'".to_string(),
1426        ));
1427    }
1428
1429    Ok(QueryRouterRule {
1430        pattern,
1431        substitution,
1432        target_field,
1433        mode,
1434    })
1435}
1436
1437/// Parse a string value from quoted_string, raw_string, or regex_string
1438fn parse_string_value(pair: pest::iterators::Pair<Rule>) -> String {
1439    let s = pair.as_str();
1440    match pair.as_rule() {
1441        Rule::regex_string => {
1442            // regex_string contains either raw_string or quoted_string
1443            if let Some(inner) = pair.into_inner().next() {
1444                parse_string_value(inner)
1445            } else {
1446                s.to_string()
1447            }
1448        }
1449        Rule::raw_string => {
1450            // r"..." - strip r" prefix and " suffix
1451            s[2..s.len() - 1].to_string()
1452        }
1453        Rule::quoted_string => {
1454            // "..." - strip quotes and handle escapes
1455            let inner = &s[1..s.len() - 1];
1456            // Simple escape handling
1457            inner
1458                .replace("\\n", "\n")
1459                .replace("\\t", "\t")
1460                .replace("\\\"", "\"")
1461                .replace("\\\\", "\\")
1462        }
1463        _ => s.to_string(),
1464    }
1465}
1466
1467/// Parse an index definition from pest pair
1468fn parse_index_def(pair: pest::iterators::Pair<Rule>) -> Result<IndexDef> {
1469    let mut inner = pair.into_inner();
1470
1471    let name = inner
1472        .next()
1473        .ok_or_else(|| Error::Schema("Missing index name".to_string()))?
1474        .as_str()
1475        .to_string();
1476
1477    let mut fields = Vec::new();
1478    let mut default_fields = Vec::new();
1479    let mut query_routers = Vec::new();
1480    let mut reorder_on_merge = false;
1481
1482    for item in inner {
1483        match item.as_rule() {
1484            Rule::field_def => {
1485                fields.push(parse_field_def(item)?);
1486            }
1487            Rule::default_fields_def => {
1488                default_fields = parse_default_fields_def(item);
1489            }
1490            Rule::query_router_def => {
1491                query_routers.push(parse_query_router_def(item)?);
1492            }
1493            Rule::reorder_on_merge_def => {
1494                let value = item
1495                    .into_inner()
1496                    .next()
1497                    .map(|b| b.as_str() == "true")
1498                    .unwrap_or(false);
1499                reorder_on_merge = value;
1500            }
1501            _ => {}
1502        }
1503    }
1504
1505    validate_tokenizer_specs(&name, &fields)?;
1506
1507    // Validate primary key constraints
1508    let primary_fields: Vec<&FieldDef> = fields.iter().filter(|f| f.primary).collect();
1509    if primary_fields.len() > 1 {
1510        return Err(Error::Schema(format!(
1511            "Index '{}' has {} primary key fields, but at most one is allowed",
1512            name,
1513            primary_fields.len()
1514        )));
1515    }
1516    if let Some(pk) = primary_fields.first() {
1517        if pk.field_type != FieldType::Text {
1518            return Err(Error::Schema(format!(
1519                "Primary key field '{}' must be of type text, got {:?}",
1520                pk.name, pk.field_type
1521            )));
1522        }
1523        if pk.multi {
1524            return Err(Error::Schema(format!(
1525                "Primary key field '{}' cannot be multi-valued",
1526                pk.name
1527            )));
1528        }
1529    }
1530
1531    Ok(IndexDef {
1532        name,
1533        fields,
1534        default_fields,
1535        query_routers,
1536        reorder_on_merge,
1537    })
1538}
1539
1540/// Fail loudly on tokenizer specs that would otherwise degrade silently:
1541/// unknown tokenizer names (the builder would fall back to plain lowercasing)
1542/// and dynamic stemmers whose hint field does not exist or is not text.
1543fn validate_tokenizer_specs(index_name: &str, fields: &[FieldDef]) -> Result<()> {
1544    use crate::tokenizer::{TokenizerRegistry, TokenizerSpec};
1545    let mut registry: Option<TokenizerRegistry> = None;
1546    for field in fields {
1547        let Some(raw) = field.tokenizer.as_deref() else {
1548            continue;
1549        };
1550        let spec = TokenizerSpec::parse(raw).map_err(|e| {
1551            Error::Schema(format!("Index '{index_name}', field '{}': {e}", field.name))
1552        })?;
1553        match spec {
1554            TokenizerSpec::Named(tokenizer) => {
1555                let registry = registry.get_or_insert_with(TokenizerRegistry::new);
1556                if !registry.contains(&tokenizer) {
1557                    return Err(Error::Schema(format!(
1558                        "Index '{index_name}', field '{}': unknown tokenizer '{tokenizer}'",
1559                        field.name
1560                    )));
1561                }
1562            }
1563            TokenizerSpec::DynamicStem { by, .. } => match fields.iter().find(|f| f.name == by) {
1564                None => {
1565                    return Err(Error::Schema(format!(
1566                        "Index '{index_name}', field '{}': tokenizer hint field '{by}' does not exist",
1567                        field.name
1568                    )));
1569                }
1570                Some(hint) if hint.field_type != FieldType::Text => {
1571                    return Err(Error::Schema(format!(
1572                        "Index '{index_name}', field '{}': tokenizer hint field '{by}' must be a text field, got {:?}",
1573                        field.name, hint.field_type
1574                    )));
1575                }
1576                Some(_) => {}
1577            },
1578        }
1579    }
1580    Ok(())
1581}
1582
1583/// Parse SDL from a string
1584pub fn parse_sdl(input: &str) -> Result<Vec<IndexDef>> {
1585    let pairs = SdlParser::parse(Rule::file, input)
1586        .map_err(|e| Error::Schema(format!("Parse error: {}", e)))?;
1587
1588    let mut indexes = Vec::new();
1589
1590    for pair in pairs {
1591        if pair.as_rule() == Rule::file {
1592            for inner in pair.into_inner() {
1593                if inner.as_rule() == Rule::index_def {
1594                    indexes.push(parse_index_def(inner)?);
1595                }
1596            }
1597        }
1598    }
1599
1600    Ok(indexes)
1601}
1602
1603/// Parse SDL and return a single index definition
1604pub fn parse_single_index(input: &str) -> Result<IndexDef> {
1605    let indexes = parse_sdl(input)?;
1606
1607    if indexes.is_empty() {
1608        return Err(Error::Schema("No index definition found".to_string()));
1609    }
1610
1611    if indexes.len() > 1 {
1612        return Err(Error::Schema(
1613            "Multiple index definitions found, expected one".to_string(),
1614        ));
1615    }
1616
1617    Ok(indexes.into_iter().next().unwrap())
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623
1624    #[test]
1625    fn test_parse_simple_schema() {
1626        let sdl = r#"
1627            index articles {
1628                field title: text [indexed, stored]
1629                field body: text [indexed]
1630            }
1631        "#;
1632
1633        let indexes = parse_sdl(sdl).unwrap();
1634        assert_eq!(indexes.len(), 1);
1635
1636        let index = &indexes[0];
1637        assert_eq!(index.name, "articles");
1638        assert_eq!(index.fields.len(), 2);
1639
1640        assert_eq!(index.fields[0].name, "title");
1641        assert!(matches!(index.fields[0].field_type, FieldType::Text));
1642        assert!(index.fields[0].indexed);
1643        assert!(index.fields[0].stored);
1644
1645        assert_eq!(index.fields[1].name, "body");
1646        assert!(matches!(index.fields[1].field_type, FieldType::Text));
1647        assert!(index.fields[1].indexed);
1648        assert!(!index.fields[1].stored);
1649    }
1650
1651    #[test]
1652    fn test_parse_all_field_types() {
1653        let sdl = r#"
1654            index test {
1655                field text_field: text [indexed, stored]
1656                field u64_field: u64 [indexed, stored]
1657                field i64_field: i64 [indexed, stored]
1658                field f64_field: f64 [indexed, stored]
1659                field bytes_field: bytes [stored]
1660            }
1661        "#;
1662
1663        let indexes = parse_sdl(sdl).unwrap();
1664        let index = &indexes[0];
1665
1666        assert!(matches!(index.fields[0].field_type, FieldType::Text));
1667        assert!(matches!(index.fields[1].field_type, FieldType::U64));
1668        assert!(matches!(index.fields[2].field_type, FieldType::I64));
1669        assert!(matches!(index.fields[3].field_type, FieldType::F64));
1670        assert!(matches!(index.fields[4].field_type, FieldType::Bytes));
1671    }
1672
1673    #[test]
1674    fn test_parse_with_comments() {
1675        let sdl = r#"
1676            # This is a comment
1677            index articles {
1678                # Title field
1679                field title: text [indexed, stored]
1680                field body: text [indexed] # inline comment not supported yet
1681            }
1682        "#;
1683
1684        let indexes = parse_sdl(sdl).unwrap();
1685        assert_eq!(indexes[0].fields.len(), 2);
1686    }
1687
1688    #[test]
1689    fn test_parse_type_aliases() {
1690        let sdl = r#"
1691            index test {
1692                field a: string [indexed]
1693                field b: int [indexed]
1694                field c: uint [indexed]
1695                field d: float [indexed]
1696                field e: binary [stored]
1697            }
1698        "#;
1699
1700        let indexes = parse_sdl(sdl).unwrap();
1701        let index = &indexes[0];
1702
1703        assert!(matches!(index.fields[0].field_type, FieldType::Text));
1704        assert!(matches!(index.fields[1].field_type, FieldType::I64));
1705        assert!(matches!(index.fields[2].field_type, FieldType::U64));
1706        assert!(matches!(index.fields[3].field_type, FieldType::F64));
1707        assert!(matches!(index.fields[4].field_type, FieldType::Bytes));
1708    }
1709
1710    #[test]
1711    fn test_to_schema() {
1712        let sdl = r#"
1713            index articles {
1714                field title: text [indexed, stored]
1715                field views: u64 [indexed, stored]
1716            }
1717        "#;
1718
1719        let indexes = parse_sdl(sdl).unwrap();
1720        let schema = indexes[0].to_schema();
1721
1722        assert!(schema.get_field("title").is_some());
1723        assert!(schema.get_field("views").is_some());
1724        assert!(schema.get_field("nonexistent").is_none());
1725    }
1726
1727    #[test]
1728    fn test_default_attributes() {
1729        let sdl = r#"
1730            index test {
1731                field title: text
1732            }
1733        "#;
1734
1735        let indexes = parse_sdl(sdl).unwrap();
1736        let field = &indexes[0].fields[0];
1737
1738        // Default should be indexed and stored
1739        assert!(field.indexed);
1740        assert!(field.stored);
1741    }
1742
1743    #[test]
1744    fn chunked_text_field_parses_and_implies_multi() {
1745        let sdl = r#"
1746            index documents {
1747                field languages: text<raw_ci> [fast]
1748                field content: text<stem(by: languages, default: simple)> [indexed<chunked, token_position>]
1749                field notes: text<simple> [indexed<chunked>, stored]
1750            }
1751        "#;
1752        let index = parse_single_index(sdl).unwrap();
1753        let content = &index.fields[1];
1754        assert!(content.chunked);
1755        assert!(content.multi, "chunked implies multi-valued storage");
1756        assert_eq!(
1757            content.positions,
1758            Some(crate::dsl::PositionMode::TokenPosition)
1759        );
1760        let notes = &index.fields[2];
1761        assert!(notes.chunked && notes.stored && notes.positions.is_none());
1762
1763        let schema = index.to_schema();
1764        let entry = schema
1765            .get_field_entry(schema.get_field("content").unwrap())
1766            .unwrap();
1767        assert!(entry.chunked && entry.multi);
1768        assert!(
1769            !schema
1770                .get_field_entry(schema.get_field("languages").unwrap())
1771                .unwrap()
1772                .chunked
1773        );
1774    }
1775
1776    #[test]
1777    fn chunked_rejects_non_text_and_ordinal_position_modes() {
1778        let non_text = parse_sdl("index i { field n: u64 [indexed<chunked>] }").unwrap_err();
1779        assert!(
1780            non_text.to_string().contains("requires a text field"),
1781            "{non_text}"
1782        );
1783
1784        for mode in ["positions", "ordinal"] {
1785            let sdl = format!("index i {{ field c: text<simple> [indexed<chunked, {mode}>] }}");
1786            let error = parse_sdl(&sdl).unwrap_err();
1787            assert!(
1788                error.to_string().contains("token_position"),
1789                "{mode}: {error}"
1790            );
1791        }
1792    }
1793
1794    #[test]
1795    fn test_multiple_indexes() {
1796        let sdl = r#"
1797            index articles {
1798                field title: text [indexed, stored]
1799            }
1800
1801            index users {
1802                field name: text [indexed, stored]
1803                field email: text [indexed, stored]
1804            }
1805        "#;
1806
1807        let indexes = parse_sdl(sdl).unwrap();
1808        assert_eq!(indexes.len(), 2);
1809        assert_eq!(indexes[0].name, "articles");
1810        assert_eq!(indexes[1].name, "users");
1811    }
1812
1813    #[test]
1814    fn test_tokenizer_spec() {
1815        let sdl = r#"
1816            index articles {
1817                field title: text<en_stem> [indexed, stored]
1818                field body: text<simple> [indexed]
1819                field author: text [indexed, stored]
1820            }
1821        "#;
1822
1823        let indexes = parse_sdl(sdl).unwrap();
1824        let index = &indexes[0];
1825
1826        assert_eq!(index.fields[0].name, "title");
1827        assert_eq!(index.fields[0].tokenizer, Some("en_stem".to_string()));
1828
1829        assert_eq!(index.fields[1].name, "body");
1830        assert_eq!(index.fields[1].tokenizer, Some("simple".to_string()));
1831
1832        assert_eq!(index.fields[2].name, "author");
1833        assert_eq!(index.fields[2].tokenizer, None); // No tokenizer specified
1834    }
1835
1836    #[test]
1837    fn test_dynamic_tokenizer_spec() {
1838        let sdl = r#"
1839            index documents {
1840                field languages: text<raw_ci> [fast]
1841                field content: text<stem(by: languages, default: simple)> [indexed<token_position>]
1842                field title: text<stem(by:languages,default:english)> [indexed]
1843                field embedding: dense_vector<768> [indexed]
1844                field hash: binary_dense_vector<64> [indexed]
1845            }
1846        "#;
1847
1848        let indexes = parse_sdl(sdl).unwrap();
1849        let index = &indexes[0];
1850        assert_eq!(
1851            index.fields[1].tokenizer,
1852            Some("stem(by: languages, default: simple)".to_string())
1853        );
1854        assert_eq!(
1855            index.fields[1].positions,
1856            Some(super::super::schema::PositionMode::TokenPosition)
1857        );
1858        // Canonical rendering normalises spacing and language names.
1859        assert_eq!(
1860            index.fields[2].tokenizer,
1861            Some("stem(by: languages, default: en)".to_string())
1862        );
1863        // Vector `<N>` configs are unaffected by the extended tokenizer grammar.
1864        assert_eq!(
1865            index.fields[3].dense_vector_config.as_ref().unwrap().dim,
1866            768
1867        );
1868        assert_eq!(
1869            index.fields[4]
1870                .binary_dense_vector_config
1871                .as_ref()
1872                .unwrap()
1873                .dim,
1874            64
1875        );
1876
1877        let schema = index.to_schema();
1878        let content = schema.get_field("content").unwrap();
1879        let languages = schema.get_field("languages").unwrap();
1880        assert_eq!(schema.tokenizer_hint_field(content), Some(languages));
1881        assert_eq!(schema.tokenizer_hint_field(languages), None);
1882        let entry = schema.get_field_entry(content).unwrap();
1883        assert_eq!(
1884            entry.tokenizer_spec().unwrap().hint_field(),
1885            Some("languages")
1886        );
1887    }
1888
1889    #[test]
1890    fn test_tokenizer_specs_fail_loud() {
1891        let missing_hint_field = r#"
1892            index documents {
1893                field content: text<stem(by: languages, default: simple)> [indexed]
1894            }
1895        "#;
1896        let err = parse_sdl(missing_hint_field).unwrap_err().to_string();
1897        assert!(
1898            err.contains("hint field 'languages' does not exist"),
1899            "{err}"
1900        );
1901
1902        let numeric_hint_field = r#"
1903            index documents {
1904                field languages: u64 [fast]
1905                field content: text<stem(by: languages)> [indexed]
1906            }
1907        "#;
1908        let err = parse_sdl(numeric_hint_field).unwrap_err().to_string();
1909        assert!(err.contains("must be a text field"), "{err}");
1910
1911        let unknown_default = r#"
1912            index documents {
1913                field languages: text [fast]
1914                field content: text<stem(by: languages, default: klingon)> [indexed]
1915            }
1916        "#;
1917        let err = parse_sdl(unknown_default).unwrap_err().to_string();
1918        assert!(err.contains("unknown default language 'klingon'"), "{err}");
1919
1920        let unknown_tokenizer = r#"
1921            index documents {
1922                field content: text<klingon_stem> [indexed]
1923            }
1924        "#;
1925        let err = parse_sdl(unknown_tokenizer).unwrap_err().to_string();
1926        assert!(err.contains("unknown tokenizer 'klingon_stem'"), "{err}");
1927    }
1928
1929    #[test]
1930    fn test_tokenizer_in_schema() {
1931        let sdl = r#"
1932            index articles {
1933                field title: text<german> [indexed, stored]
1934                field body: text<en_stem> [indexed]
1935            }
1936        "#;
1937
1938        let indexes = parse_sdl(sdl).unwrap();
1939        let schema = indexes[0].to_schema();
1940
1941        let title_field = schema.get_field("title").unwrap();
1942        let title_entry = schema.get_field_entry(title_field).unwrap();
1943        assert_eq!(title_entry.tokenizer, Some("german".to_string()));
1944
1945        let body_field = schema.get_field("body").unwrap();
1946        let body_entry = schema.get_field_entry(body_field).unwrap();
1947        assert_eq!(body_entry.tokenizer, Some("en_stem".to_string()));
1948    }
1949
1950    #[test]
1951    fn test_query_router_basic() {
1952        let sdl = r#"
1953            index documents {
1954                field title: text [indexed, stored]
1955                field uri: text [indexed, stored]
1956
1957                query_router {
1958                    pattern: "10\\.\\d{4,}/[^\\s]+"
1959                    substitution: "doi://{0}"
1960                    target_field: uris
1961                    mode: exclusive
1962                }
1963            }
1964        "#;
1965
1966        let indexes = parse_sdl(sdl).unwrap();
1967        let index = &indexes[0];
1968
1969        assert_eq!(index.query_routers.len(), 1);
1970        let router = &index.query_routers[0];
1971        assert_eq!(router.pattern, r"10\.\d{4,}/[^\s]+");
1972        assert_eq!(router.substitution, "doi://{0}");
1973        assert_eq!(router.target_field, "uris");
1974        assert_eq!(router.mode, RoutingMode::Exclusive);
1975    }
1976
1977    #[test]
1978    fn test_query_router_raw_string() {
1979        let sdl = r#"
1980            index documents {
1981                field uris: text [indexed, stored]
1982
1983                query_router {
1984                    pattern: r"^pmid:(\d+)$"
1985                    substitution: "pubmed://{1}"
1986                    target_field: uris
1987                    mode: additional
1988                }
1989            }
1990        "#;
1991
1992        let indexes = parse_sdl(sdl).unwrap();
1993        let router = &indexes[0].query_routers[0];
1994
1995        assert_eq!(router.pattern, r"^pmid:(\d+)$");
1996        assert_eq!(router.substitution, "pubmed://{1}");
1997        assert_eq!(router.mode, RoutingMode::Additional);
1998    }
1999
2000    #[test]
2001    fn test_multiple_query_routers() {
2002        let sdl = r#"
2003            index documents {
2004                field uris: text [indexed, stored]
2005
2006                query_router {
2007                    pattern: r"^doi:(10\.\d{4,}/[^\s]+)$"
2008                    substitution: "doi://{1}"
2009                    target_field: uris
2010                    mode: exclusive
2011                }
2012
2013                query_router {
2014                    pattern: r"^pmid:(\d+)$"
2015                    substitution: "pubmed://{1}"
2016                    target_field: uris
2017                    mode: exclusive
2018                }
2019
2020                query_router {
2021                    pattern: r"^arxiv:(\d+\.\d+)$"
2022                    substitution: "arxiv://{1}"
2023                    target_field: uris
2024                    mode: additional
2025                }
2026            }
2027        "#;
2028
2029        let indexes = parse_sdl(sdl).unwrap();
2030        assert_eq!(indexes[0].query_routers.len(), 3);
2031    }
2032
2033    #[test]
2034    fn test_query_router_default_mode() {
2035        let sdl = r#"
2036            index documents {
2037                field uris: text [indexed, stored]
2038
2039                query_router {
2040                    pattern: r"test"
2041                    substitution: "{0}"
2042                    target_field: uris
2043                }
2044            }
2045        "#;
2046
2047        let indexes = parse_sdl(sdl).unwrap();
2048        // Default mode should be Additional
2049        assert_eq!(indexes[0].query_routers[0].mode, RoutingMode::Additional);
2050    }
2051
2052    #[test]
2053    fn test_multi_attribute() {
2054        let sdl = r#"
2055            index documents {
2056                field uris: text [indexed, stored<multi>]
2057                field title: text [indexed, stored]
2058            }
2059        "#;
2060
2061        let indexes = parse_sdl(sdl).unwrap();
2062        assert_eq!(indexes.len(), 1);
2063
2064        let fields = &indexes[0].fields;
2065        assert_eq!(fields.len(), 2);
2066
2067        // uris should have multi=true
2068        assert_eq!(fields[0].name, "uris");
2069        assert!(fields[0].multi, "uris field should have multi=true");
2070
2071        // title should have multi=false
2072        assert_eq!(fields[1].name, "title");
2073        assert!(!fields[1].multi, "title field should have multi=false");
2074
2075        // Verify schema conversion preserves multi attribute
2076        let schema = indexes[0].to_schema();
2077        let uris_field = schema.get_field("uris").unwrap();
2078        let title_field = schema.get_field("title").unwrap();
2079
2080        assert!(schema.get_field_entry(uris_field).unwrap().multi);
2081        assert!(!schema.get_field_entry(title_field).unwrap().multi);
2082    }
2083
2084    #[test]
2085    fn test_sparse_vector_field() {
2086        let sdl = r#"
2087            index documents {
2088                field embedding: sparse_vector [indexed, stored]
2089            }
2090        "#;
2091
2092        let indexes = parse_sdl(sdl).unwrap();
2093        assert_eq!(indexes.len(), 1);
2094        assert_eq!(indexes[0].fields.len(), 1);
2095        assert_eq!(indexes[0].fields[0].name, "embedding");
2096        assert_eq!(indexes[0].fields[0].field_type, FieldType::SparseVector);
2097        assert!(indexes[0].fields[0].sparse_vector_config.is_none());
2098    }
2099
2100    #[test]
2101    fn test_sparse_vector_with_config() {
2102        let sdl = r#"
2103            index documents {
2104                field embedding: sparse_vector<u16> [indexed<quantization: uint8>, stored]
2105                field dense: sparse_vector<u32> [indexed<quantization: float32>]
2106            }
2107        "#;
2108
2109        let indexes = parse_sdl(sdl).unwrap();
2110        assert_eq!(indexes[0].fields.len(), 2);
2111
2112        // First field: u16 indices, uint8 quantization
2113        let f1 = &indexes[0].fields[0];
2114        assert_eq!(f1.name, "embedding");
2115        let config1 = f1.sparse_vector_config.as_ref().unwrap();
2116        assert_eq!(config1.index_size, IndexSize::U16);
2117        assert_eq!(config1.weight_quantization, WeightQuantization::UInt8);
2118
2119        // Second field: u32 indices, float32 quantization
2120        let f2 = &indexes[0].fields[1];
2121        assert_eq!(f2.name, "dense");
2122        let config2 = f2.sparse_vector_config.as_ref().unwrap();
2123        assert_eq!(config2.index_size, IndexSize::U32);
2124        assert_eq!(config2.weight_quantization, WeightQuantization::Float32);
2125    }
2126
2127    #[test]
2128    fn test_sparse_vector_bmp_block_size() {
2129        let sdl = r#"
2130            index documents {
2131                field emb: sparse_vector<u32> [indexed<format: bmp, dims: 105879, bmp_block_size: 256>]
2132                field emb2: sparse_vector<u32> [indexed<format: bmp, dims: 30522>]
2133            }
2134        "#;
2135
2136        let indexes = parse_sdl(sdl).unwrap();
2137        let config1 = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2138        assert_eq!(config1.format, SparseFormat::Bmp);
2139        assert_eq!(config1.bmp_block_size, 256);
2140
2141        // Default block size stays 32.
2142        let config2 = indexes[0].fields[1].sparse_vector_config.as_ref().unwrap();
2143        assert_eq!(
2144            config2.bmp_block_size,
2145            SparseVectorConfig::DEFAULT_BMP_BLOCK_SIZE
2146        );
2147    }
2148
2149    /// Regression: `bmp_grid_bits` parsed but was never applied to the field
2150    /// config — SDL said 2, segments silently built 4-bit grids.
2151    #[test]
2152    fn test_sparse_vector_bmp_grid_bits() {
2153        let sdl = r#"
2154            index documents {
2155                field emb: sparse_vector<u32> [indexed<format: bmp, dims: 105879, bmp_block_size: 256, bmp_grid_bits: 2>]
2156                field emb2: sparse_vector<u32> [indexed<format: bmp, dims: 30522>]
2157                field emb3: sparse_vector<u32> [indexed<format: bmp, dims: 30522, bmp_grid_bits: 3>]
2158            }
2159        "#;
2160
2161        let indexes = parse_sdl(sdl).unwrap();
2162        let config1 = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2163        assert_eq!(config1.bmp_grid_bits, 2);
2164        // Default stays 4
2165        let config2 = indexes[0].fields[1].sparse_vector_config.as_ref().unwrap();
2166        assert_eq!(
2167            config2.bmp_grid_bits,
2168            SparseVectorConfig::DEFAULT_BMP_GRID_BITS
2169        );
2170        // Unsupported width falls back to 4 with a warning
2171        let config3 = indexes[0].fields[2].sparse_vector_config.as_ref().unwrap();
2172        assert_eq!(
2173            config3.bmp_grid_bits,
2174            SparseVectorConfig::DEFAULT_BMP_GRID_BITS
2175        );
2176    }
2177
2178    #[test]
2179    fn test_sparse_vector_with_weight_threshold() {
2180        let sdl = r#"
2181            index documents {
2182                field embedding: sparse_vector<u16> [indexed<quantization: uint8, weight_threshold: 0.1>, stored]
2183                field embedding2: sparse_vector<u32> [indexed<quantization: float16, weight_threshold: 0.05>]
2184            }
2185        "#;
2186
2187        let indexes = parse_sdl(sdl).unwrap();
2188        assert_eq!(indexes[0].fields.len(), 2);
2189
2190        // First field: u16 indices, uint8 quantization, threshold 0.1
2191        let f1 = &indexes[0].fields[0];
2192        assert_eq!(f1.name, "embedding");
2193        let config1 = f1.sparse_vector_config.as_ref().unwrap();
2194        assert_eq!(config1.index_size, IndexSize::U16);
2195        assert_eq!(config1.weight_quantization, WeightQuantization::UInt8);
2196        assert!((config1.weight_threshold - 0.1).abs() < 0.001);
2197
2198        // Second field: u32 indices, float16 quantization, threshold 0.05
2199        let f2 = &indexes[0].fields[1];
2200        assert_eq!(f2.name, "embedding2");
2201        let config2 = f2.sparse_vector_config.as_ref().unwrap();
2202        assert_eq!(config2.index_size, IndexSize::U32);
2203        assert_eq!(config2.weight_quantization, WeightQuantization::Float16);
2204        assert!((config2.weight_threshold - 0.05).abs() < 0.001);
2205    }
2206
2207    #[test]
2208    fn test_sparse_vector_with_pruning() {
2209        let sdl = r#"
2210            index documents {
2211                field embedding: sparse_vector [indexed<quantization: uint8, pruning: 0.1>, stored]
2212            }
2213        "#;
2214
2215        let indexes = parse_sdl(sdl).unwrap();
2216        let f = &indexes[0].fields[0];
2217        assert_eq!(f.name, "embedding");
2218        let config = f.sparse_vector_config.as_ref().unwrap();
2219        assert_eq!(config.weight_quantization, WeightQuantization::UInt8);
2220        assert_eq!(config.pruning, Some(0.1));
2221    }
2222
2223    #[test]
2224    fn test_sparse_vector_with_doc_mass() {
2225        let sdl = r#"
2226            index documents {
2227                field embedding: sparse_vector [indexed<quantization: uint8, doc_mass: 0.9>, stored]
2228            }
2229        "#;
2230
2231        let indexes = parse_sdl(sdl).unwrap();
2232        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2233        assert_eq!(config.doc_mass, Some(0.9));
2234
2235        // Not specified → off
2236        let sdl = r#"
2237            index documents {
2238                field embedding: sparse_vector [indexed<quantization: uint8>]
2239            }
2240        "#;
2241        let indexes = parse_sdl(sdl).unwrap();
2242        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2243        assert_eq!(config.doc_mass, None);
2244    }
2245
2246    #[test]
2247    fn test_dense_vector_field() {
2248        let sdl = r#"
2249            index documents {
2250                field embedding: dense_vector<768> [indexed, stored]
2251            }
2252        "#;
2253
2254        let indexes = parse_sdl(sdl).unwrap();
2255        assert_eq!(indexes.len(), 1);
2256        assert_eq!(indexes[0].fields.len(), 1);
2257
2258        let f = &indexes[0].fields[0];
2259        assert_eq!(f.name, "embedding");
2260        assert_eq!(f.field_type, FieldType::DenseVector);
2261
2262        let config = f.dense_vector_config.as_ref().unwrap();
2263        assert_eq!(config.dim, 768);
2264    }
2265
2266    #[test]
2267    fn test_dense_vector_alias() {
2268        let sdl = r#"
2269            index documents {
2270                field embedding: vector<1536> [indexed]
2271            }
2272        "#;
2273
2274        let indexes = parse_sdl(sdl).unwrap();
2275        assert_eq!(indexes[0].fields[0].field_type, FieldType::DenseVector);
2276        assert_eq!(
2277            indexes[0].fields[0]
2278                .dense_vector_config
2279                .as_ref()
2280                .unwrap()
2281                .dim,
2282            1536
2283        );
2284    }
2285
2286    #[test]
2287    fn test_dense_vector_with_num_clusters() {
2288        let sdl = r#"
2289            index documents {
2290                field embedding: dense_vector<768> [indexed<ivf_tq, num_clusters: 256>, stored]
2291            }
2292        "#;
2293
2294        let indexes = parse_sdl(sdl).unwrap();
2295        assert_eq!(indexes.len(), 1);
2296
2297        let f = &indexes[0].fields[0];
2298        assert_eq!(f.name, "embedding");
2299        assert_eq!(f.field_type, FieldType::DenseVector);
2300
2301        let config = f.dense_vector_config.as_ref().unwrap();
2302        assert_eq!(config.dim, 768);
2303        assert_eq!(config.num_clusters, Some(256));
2304        assert_eq!(config.nprobe, 64); // billion-scale default
2305    }
2306
2307    #[test]
2308    fn scann_float_and_binary_parse_billion_scale_settings() {
2309        let indexes = parse_sdl(
2310            r#"
2311            index billion_vectors {
2312                field embedding: dense_vector<1024, f16> [indexed<scann, num_clusters: 10000000, tree_levels: 2, nprobe: 1024>]
2313                field hash: binary_dense_vector<1024> [indexed<scann, num_clusters: 10000000, tree_levels: 3, nprobe: 2048>]
2314            }
2315            "#,
2316        )
2317        .unwrap();
2318
2319        let dense = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2320        assert_eq!(
2321            dense.index_type,
2322            super::super::schema::VectorIndexType::Scann
2323        );
2324        assert_eq!(dense.num_clusters, Some(10_000_000));
2325        assert_eq!(dense.tree_levels, Some(2));
2326        assert_eq!(dense.nprobe, 1024);
2327
2328        let binary = indexes[0].fields[1]
2329            .binary_dense_vector_config
2330            .as_ref()
2331            .unwrap();
2332        assert_eq!(
2333            binary.index_type,
2334            super::super::schema::BinaryIndexType::Scann
2335        );
2336        assert_eq!(binary.num_clusters, Some(10_000_000));
2337        assert_eq!(binary.tree_levels, Some(3));
2338        assert_eq!(binary.nprobe, 2048);
2339    }
2340
2341    #[test]
2342    fn target_vectors_parses_for_float_and_binary_indexes() {
2343        let indexes = parse_sdl(
2344            r#"
2345            index streaming_vectors {
2346                field embedding: dense_vector<1024, f16> [indexed<scann, num_clusters: 1000000, target_vectors: 1000000000>]
2347                field hash: binary_dense_vector<2560> [indexed<ivf, target_vectors: 1000000000>]
2348            }
2349            "#,
2350        )
2351        .unwrap();
2352
2353        assert_eq!(
2354            indexes[0].fields[0]
2355                .dense_vector_config
2356                .as_ref()
2357                .unwrap()
2358                .target_vectors,
2359            Some(1_000_000_000)
2360        );
2361        assert_eq!(
2362            indexes[0].fields[0]
2363                .dense_vector_config
2364                .as_ref()
2365                .unwrap()
2366                .num_clusters,
2367            Some(1_000_000),
2368            "target_vectors may be persisted alongside an explicit, overriding topology"
2369        );
2370        assert_eq!(
2371            indexes[0].fields[1]
2372                .binary_dense_vector_config
2373                .as_ref()
2374                .unwrap()
2375                .target_vectors,
2376            Some(1_000_000_000)
2377        );
2378
2379        let error = parse_sdl(
2380            "index invalid { field hash: binary_dense_vector<256> [indexed<ivf, target_vectors: 0>] }",
2381        )
2382        .expect_err("zero target must fail");
2383        assert!(error.to_string().contains("greater than zero"), "{error}");
2384
2385        let error = parse_sdl(
2386            "index invalid { field embedding: dense_vector<256> [indexed<tq, target_vectors: 1000000>] }",
2387        )
2388        .expect_err("training-free topology hint must fail");
2389        assert!(error.to_string().contains("automatic topology"), "{error}");
2390
2391        for sdl in [
2392            "index invalid { field embedding: dense_vector<256> [indexed<flat, target_vectors: 1000000>] }",
2393            "index invalid { field hash: binary_dense_vector<256> [indexed<flat, target_vectors: 1000000>] }",
2394        ] {
2395            let error = parse_sdl(sdl).expect_err("flat topology hint must fail");
2396            assert!(error.to_string().contains("automatic topology"), "{error}");
2397        }
2398
2399        let error = parse_sdl(
2400            "index invalid { field hash: binary_dense_vector<256> [indexed<ivf, target_vectors: 18446744073709551616>] }",
2401        )
2402        .expect_err("u64 overflow must fail");
2403        assert!(error.to_string().contains("unsigned 64-bit"), "{error}");
2404    }
2405
2406    #[test]
2407    fn scann_rejects_invalid_geometry_and_algorithm_specific_options() {
2408        for (fragment, expected) in [
2409            ("scann, tree_levels: 0", "tree_levels"),
2410            ("scann, tree_levels: 4", "tree_levels"),
2411            ("scann, num_clusters: 30000001", "num_clusters"),
2412            ("scann, num_clusters: 1", "at least 2"),
2413            ("scann, routing: flat", "not configurable for ScaNN"),
2414            (
2415                "scann, num_clusters: 32, nprobe: 33",
2416                "cannot exceed explicit num_clusters",
2417            ),
2418            ("ivf_tq, tree_levels: 2", "only valid for a ScaNN"),
2419        ] {
2420            let sdl = format!(
2421                "index invalid {{ field embedding: dense_vector<128> [indexed<{fragment}>] }}"
2422            );
2423            let error = parse_sdl(&sdl).expect_err(fragment);
2424            assert!(error.to_string().contains(expected), "{fragment}: {error}");
2425        }
2426    }
2427
2428    #[test]
2429    fn binary_scann_accepts_selective_spilling_but_binary_ivf_rejects_it() {
2430        let indexes = parse_sdl(
2431            "index valid { field hash: binary_dense_vector<256> [indexed<scann, soar: selective>] }",
2432        )
2433        .unwrap();
2434        let soar = indexes[0].fields[0]
2435            .binary_dense_vector_config
2436            .as_ref()
2437            .unwrap()
2438            .soar
2439            .as_ref()
2440            .expect("binary ScaNN should retain explicit spilling");
2441        assert_eq!(soar.calibration_target(), Some(0.30));
2442
2443        let error = parse_sdl(
2444            "index invalid { field hash: binary_dense_vector<256> [indexed<ivf, soar: selective>] }",
2445        )
2446        .expect_err("binary IVF spilling must fail loudly");
2447        assert!(error.to_string().contains("requires the ScaNN"), "{error}");
2448    }
2449
2450    #[test]
2451    fn test_dense_vector_with_soar() {
2452        // Omission resolves to the selective one-secondary default.
2453        let sdl = r#"
2454            index documents {
2455                field embedding: dense_vector<768> [indexed<ivf_tq>]
2456            }
2457        "#;
2458        let indexes = parse_sdl(sdl).unwrap();
2459        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2460        let soar = config
2461            .soar
2462            .as_ref()
2463            .expect("omitted SOAR should enable selective spilling");
2464        assert_eq!(soar.num_secondary, 1);
2465        assert!(soar.selective);
2466        assert_eq!(soar.calibration_target(), Some(0.30));
2467
2468        // The explicit selective preset resolves to the same policy.
2469        let sdl = r#"
2470            index documents {
2471                field embedding: dense_vector<768> [indexed<ivf_tq, num_clusters: 256, soar: selective>, stored]
2472            }
2473        "#;
2474
2475        let indexes = parse_sdl(sdl).unwrap();
2476        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2477
2478        let soar = config.soar.as_ref().expect("soar should be enabled");
2479        assert_eq!(soar.num_secondary, 1);
2480        assert!(soar.selective);
2481
2482        // aggressive is a compatibility alias for full one-secondary spilling
2483        let sdl = r#"
2484            index documents {
2485                field embedding: dense_vector<768> [indexed<ivf_tq, soar: aggressive>]
2486            }
2487        "#;
2488        let indexes = parse_sdl(sdl).unwrap();
2489        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2490        let soar = config.soar.as_ref().expect("soar should be enabled");
2491        assert_eq!(soar.num_secondary, 1);
2492        assert!(!soar.selective);
2493
2494        // off keeps soar disabled
2495        let sdl = r#"
2496            index documents {
2497                field embedding: dense_vector<768> [indexed<ivf_tq, soar: off>]
2498            }
2499        "#;
2500        let indexes = parse_sdl(sdl).unwrap();
2501        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2502        assert!(config.soar.is_none());
2503    }
2504
2505    #[test]
2506    fn omitted_soar_is_canonicalized_off_for_non_ivf_formats() {
2507        let sdl = r#"
2508            index documents {
2509                field tq: dense_vector<768> [indexed<tq>]
2510                field flat: dense_vector<768> [indexed<flat>]
2511                field scann: dense_vector<768> [indexed<scann>]
2512            }
2513        "#;
2514        let indexes = parse_sdl(sdl).unwrap();
2515        for field in &indexes[0].fields {
2516            assert!(
2517                field
2518                    .dense_vector_config
2519                    .as_ref()
2520                    .expect("dense config")
2521                    .soar
2522                    .is_none(),
2523                "{} should not retain an ignored SOAR default",
2524                field.name,
2525            );
2526        }
2527    }
2528
2529    #[test]
2530    fn float_scann_rejects_explicit_soar_until_secondary_assignments_exist() {
2531        let error = parse_sdl(
2532            "index invalid { field embedding: dense_vector<256> [indexed<scann, soar: selective>] }",
2533        )
2534        .unwrap_err();
2535        assert!(error.to_string().contains("not implemented"));
2536        assert!(error.to_string().contains("Scann") || error.to_string().contains("ScaNN"));
2537    }
2538
2539    #[test]
2540    fn test_ivf_routing_modes_apply_to_float_and_binary_fields() {
2541        let indexes = parse_sdl(
2542            r#"
2543            index vectors {
2544                field embedding: dense_vector<768> [indexed<ivf_tq, routing: hnsw>]
2545                field hash: binary_dense_vector<512> [indexed<ivf, routing: two_level>]
2546            }
2547            "#,
2548        )
2549        .unwrap();
2550        let schema = indexes[0].to_schema();
2551        let embedding = schema.get_field("embedding").unwrap();
2552        let hash = schema.get_field("hash").unwrap();
2553        assert_eq!(
2554            schema
2555                .get_field_entry(embedding)
2556                .unwrap()
2557                .dense_vector_config
2558                .as_ref()
2559                .unwrap()
2560                .ivf_routing,
2561            super::super::schema::IvfRoutingMode::Hnsw
2562        );
2563        assert_eq!(
2564            schema
2565                .get_field_entry(hash)
2566                .unwrap()
2567                .binary_dense_vector_config
2568                .as_ref()
2569                .unwrap()
2570                .ivf_routing,
2571            super::super::schema::IvfRoutingMode::TwoLevel
2572        );
2573    }
2574
2575    #[test]
2576    fn test_binary_dense_vector_with_ivf() {
2577        let sdl = r#"
2578            index documents {
2579                field hash: binary_dense_vector<512> [indexed<ivf, num_clusters: 128, nprobe: 16>, stored]
2580            }
2581        "#;
2582
2583        let indexes = parse_sdl(sdl).unwrap();
2584        let config = indexes[0].fields[0]
2585            .binary_dense_vector_config
2586            .as_ref()
2587            .unwrap();
2588        assert_eq!(config.dim, 512);
2589        assert_eq!(
2590            config.index_type,
2591            super::super::schema::BinaryIndexType::Ivf
2592        );
2593        assert_eq!(config.num_clusters, Some(128));
2594        assert_eq!(config.nprobe, 16);
2595
2596        // Default targets the global IVF index; segments remain flat until
2597        // build_vector_index is requested.
2598        let sdl = r#"
2599            index documents {
2600                field hash: binary_dense_vector<512> [indexed]
2601            }
2602        "#;
2603        let indexes = parse_sdl(sdl).unwrap();
2604        let config = indexes[0].fields[0]
2605            .binary_dense_vector_config
2606            .as_ref()
2607            .unwrap();
2608        assert_eq!(
2609            config.index_type,
2610            super::super::schema::BinaryIndexType::Ivf
2611        );
2612    }
2613
2614    #[test]
2615    fn test_dense_vector_with_num_clusters_and_nprobe() {
2616        let sdl = r#"
2617            index documents {
2618                field embedding: dense_vector<1536> [indexed<ivf_tq, num_clusters: 512, nprobe: 64>]
2619            }
2620        "#;
2621
2622        let indexes = parse_sdl(sdl).unwrap();
2623        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2624
2625        assert_eq!(config.dim, 1536);
2626        assert_eq!(config.num_clusters, Some(512));
2627        assert_eq!(config.nprobe, 64);
2628    }
2629
2630    #[test]
2631    fn test_dense_vector_keyword_syntax() {
2632        let sdl = r#"
2633            index documents {
2634                field embedding: dense_vector<dims: 1536> [indexed, stored]
2635            }
2636        "#;
2637
2638        let indexes = parse_sdl(sdl).unwrap();
2639        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2640
2641        assert_eq!(config.dim, 1536);
2642        assert!(config.num_clusters.is_none());
2643    }
2644
2645    #[test]
2646    fn test_dense_vector_keyword_syntax_full() {
2647        let sdl = r#"
2648            index documents {
2649                field embedding: dense_vector<dims: 1536> [indexed<ivf_tq, num_clusters: 256, nprobe: 64>]
2650            }
2651        "#;
2652
2653        let indexes = parse_sdl(sdl).unwrap();
2654        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2655
2656        assert_eq!(config.dim, 1536);
2657        assert_eq!(config.num_clusters, Some(256));
2658        assert_eq!(config.nprobe, 64);
2659    }
2660
2661    #[test]
2662    fn test_dense_vector_keyword_syntax_partial() {
2663        let sdl = r#"
2664            index documents {
2665                field embedding: dense_vector<dims: 768> [indexed<ivf_tq, num_clusters: 128>]
2666            }
2667        "#;
2668
2669        let indexes = parse_sdl(sdl).unwrap();
2670        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2671
2672        assert_eq!(config.dim, 768);
2673        assert_eq!(config.num_clusters, Some(128));
2674        assert_eq!(config.nprobe, 64); // billion-scale default
2675    }
2676
2677    #[test]
2678    fn test_dense_vector_ivf_tq_index_with_probe() {
2679        use crate::dsl::schema::VectorIndexType;
2680
2681        let sdl = r#"
2682            index documents {
2683                field embedding: dense_vector<dims: 768> [indexed<ivf_tq, num_clusters: 256, nprobe: 64>]
2684            }
2685        "#;
2686
2687        let indexes = parse_sdl(sdl).unwrap();
2688        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2689
2690        assert_eq!(config.dim, 768);
2691        assert_eq!(config.index_type, VectorIndexType::IvfTq);
2692        assert_eq!(config.num_clusters, Some(256));
2693        assert_eq!(config.nprobe, 64);
2694    }
2695
2696    #[test]
2697    fn test_dense_vector_ivf_tq_index_without_explicit_probe() {
2698        use crate::dsl::schema::VectorIndexType;
2699
2700        let sdl = r#"
2701            index documents {
2702                field embedding: dense_vector<dims: 1536> [indexed<ivf_tq, num_clusters: 512>]
2703            }
2704        "#;
2705
2706        let indexes = parse_sdl(sdl).unwrap();
2707        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2708
2709        assert_eq!(config.dim, 1536);
2710        assert_eq!(config.index_type, VectorIndexType::IvfTq);
2711        assert_eq!(config.num_clusters, Some(512));
2712    }
2713
2714    #[test]
2715    fn test_dense_vector_ivf_tq_no_clusters() {
2716        use crate::dsl::schema::VectorIndexType;
2717
2718        let sdl = r#"
2719            index documents {
2720                field embedding: dense_vector<dims: 768> [indexed<ivf_tq>]
2721            }
2722        "#;
2723
2724        let indexes = parse_sdl(sdl).unwrap();
2725        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2726
2727        assert_eq!(config.dim, 768);
2728        assert_eq!(config.index_type, VectorIndexType::IvfTq);
2729        assert!(config.num_clusters.is_none());
2730    }
2731
2732    #[test]
2733    fn removed_ivf_pq_still_parses_to_the_reserved_variant() {
2734        use crate::dsl::schema::VectorIndexType;
2735
2736        // The SDL keeps accepting `ivf_pq` purely so index create/open can
2737        // reject it with an actionable message instead of a grammar error.
2738        let sdl = r#"
2739            index test {
2740                field embedding: dense_vector<8> [indexed<ivf_pq>]
2741            }
2742        "#;
2743        let indexes = parse_sdl(sdl).unwrap();
2744        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2745        assert_eq!(config.index_type, VectorIndexType::IvfPq);
2746
2747        let mut builder = crate::dsl::SchemaBuilder::default();
2748        builder.add_dense_vector_field_with_config("embedding", true, true, config.clone());
2749        let schema = builder.build();
2750        let error = crate::dsl::schema::reject_removed_vector_index_types(&schema)
2751            .expect_err("removed index types must be rejected at the index gate");
2752        assert!(error.contains("ivf_tq"), "{error}");
2753        assert!(error.contains("removed"), "{error}");
2754    }
2755
2756    #[test]
2757    fn test_dense_vector_flat_index() {
2758        use crate::dsl::schema::VectorIndexType;
2759
2760        let sdl = r#"
2761            index documents {
2762                field embedding: dense_vector<dims: 768> [indexed<flat>]
2763            }
2764        "#;
2765
2766        let indexes = parse_sdl(sdl).unwrap();
2767        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2768
2769        assert_eq!(config.dim, 768);
2770        assert_eq!(config.index_type, VectorIndexType::Flat);
2771    }
2772
2773    #[test]
2774    fn test_dense_vector_default_index_type() {
2775        use crate::dsl::schema::VectorIndexType;
2776
2777        // Omitting an index type selects the production IVF-PQ path.
2778        let sdl = r#"
2779            index documents {
2780                field embedding: dense_vector<dims: 768> [indexed]
2781            }
2782        "#;
2783
2784        let indexes = parse_sdl(sdl).unwrap();
2785        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2786
2787        assert_eq!(config.dim, 768);
2788        assert_eq!(config.index_type, VectorIndexType::IvfTq);
2789    }
2790
2791    #[test]
2792    fn test_dense_vector_f16_quantization() {
2793        use crate::dsl::schema::{DenseVectorQuantization, VectorIndexType};
2794
2795        let sdl = r#"
2796            index documents {
2797                field embedding: dense_vector<768, f16> [indexed]
2798            }
2799        "#;
2800
2801        let indexes = parse_sdl(sdl).unwrap();
2802        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2803
2804        assert_eq!(config.dim, 768);
2805        assert_eq!(config.quantization, DenseVectorQuantization::F16);
2806        assert_eq!(config.index_type, VectorIndexType::IvfTq);
2807    }
2808
2809    #[test]
2810    fn test_dense_vector_uint8_quantization() {
2811        use crate::dsl::schema::DenseVectorQuantization;
2812
2813        let sdl = r#"
2814            index documents {
2815                field embedding: dense_vector<1024, uint8> [indexed<ivf_tq>]
2816            }
2817        "#;
2818
2819        let indexes = parse_sdl(sdl).unwrap();
2820        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2821
2822        assert_eq!(config.dim, 1024);
2823        assert_eq!(config.quantization, DenseVectorQuantization::UInt8);
2824    }
2825
2826    #[test]
2827    fn test_dense_vector_u8_alias() {
2828        use crate::dsl::schema::DenseVectorQuantization;
2829
2830        let sdl = r#"
2831            index documents {
2832                field embedding: dense_vector<512, u8> [indexed]
2833            }
2834        "#;
2835
2836        let indexes = parse_sdl(sdl).unwrap();
2837        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2838
2839        assert_eq!(config.dim, 512);
2840        assert_eq!(config.quantization, DenseVectorQuantization::UInt8);
2841    }
2842
2843    #[test]
2844    fn test_dense_vector_default_f32_quantization() {
2845        use crate::dsl::schema::DenseVectorQuantization;
2846
2847        // No quantization type → default f32
2848        let sdl = r#"
2849            index documents {
2850                field embedding: dense_vector<768> [indexed]
2851            }
2852        "#;
2853
2854        let indexes = parse_sdl(sdl).unwrap();
2855        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2856
2857        assert_eq!(config.dim, 768);
2858        assert_eq!(config.quantization, DenseVectorQuantization::F32);
2859    }
2860
2861    #[test]
2862    fn test_dense_vector_keyword_with_quantization() {
2863        use crate::dsl::schema::DenseVectorQuantization;
2864
2865        let sdl = r#"
2866            index documents {
2867                field embedding: dense_vector<dims: 768, f16> [indexed]
2868            }
2869        "#;
2870
2871        let indexes = parse_sdl(sdl).unwrap();
2872        let config = indexes[0].fields[0].dense_vector_config.as_ref().unwrap();
2873
2874        assert_eq!(config.dim, 768);
2875        assert_eq!(config.quantization, DenseVectorQuantization::F16);
2876    }
2877
2878    #[test]
2879    fn test_json_field_type() {
2880        let sdl = r#"
2881            index documents {
2882                field title: text [indexed, stored]
2883                field metadata: json [stored]
2884                field extra: json
2885            }
2886        "#;
2887
2888        let indexes = parse_sdl(sdl).unwrap();
2889        let index = &indexes[0];
2890
2891        assert_eq!(index.fields.len(), 3);
2892
2893        // Check JSON field
2894        assert_eq!(index.fields[1].name, "metadata");
2895        assert!(matches!(index.fields[1].field_type, FieldType::Json));
2896        assert!(index.fields[1].stored);
2897        // JSON fields should not be indexed (enforced by add_json_field)
2898
2899        // Check default attributes for JSON field
2900        assert_eq!(index.fields[2].name, "extra");
2901        assert!(matches!(index.fields[2].field_type, FieldType::Json));
2902
2903        // Verify schema conversion
2904        let schema = index.to_schema();
2905        let metadata_field = schema.get_field("metadata").unwrap();
2906        let entry = schema.get_field_entry(metadata_field).unwrap();
2907        assert_eq!(entry.field_type, FieldType::Json);
2908        assert!(!entry.indexed); // JSON fields are never indexed
2909        assert!(entry.stored);
2910    }
2911
2912    #[test]
2913    fn test_sparse_vector_query_config() {
2914        use crate::structures::QueryWeighting;
2915
2916        let sdl = r#"
2917            index documents {
2918                field embedding: sparse_vector<u16> [indexed<quantization: uint8, query<tokenizer: "Alibaba-NLP/gte-Qwen2-1.5B-instruct", weighting: idf>>]
2919            }
2920        "#;
2921
2922        let indexes = parse_sdl(sdl).unwrap();
2923        let index = &indexes[0];
2924
2925        assert_eq!(index.fields.len(), 1);
2926        assert_eq!(index.fields[0].name, "embedding");
2927        assert!(matches!(
2928            index.fields[0].field_type,
2929            FieldType::SparseVector
2930        ));
2931
2932        let config = index.fields[0].sparse_vector_config.as_ref().unwrap();
2933        assert_eq!(config.index_size, IndexSize::U16);
2934        assert_eq!(config.weight_quantization, WeightQuantization::UInt8);
2935
2936        // Check query config
2937        let query_config = config.query_config.as_ref().unwrap();
2938        assert_eq!(
2939            query_config.tokenizer.as_deref(),
2940            Some("Alibaba-NLP/gte-Qwen2-1.5B-instruct")
2941        );
2942        assert_eq!(query_config.weighting, QueryWeighting::Idf);
2943
2944        // Verify schema conversion preserves query config
2945        let schema = index.to_schema();
2946        let embedding_field = schema.get_field("embedding").unwrap();
2947        let entry = schema.get_field_entry(embedding_field).unwrap();
2948        let sv_config = entry.sparse_vector_config.as_ref().unwrap();
2949        let qc = sv_config.query_config.as_ref().unwrap();
2950        assert_eq!(
2951            qc.tokenizer.as_deref(),
2952            Some("Alibaba-NLP/gte-Qwen2-1.5B-instruct")
2953        );
2954        assert_eq!(qc.weighting, QueryWeighting::Idf);
2955    }
2956
2957    #[test]
2958    fn test_sparse_vector_query_config_weighting_one() {
2959        use crate::structures::QueryWeighting;
2960
2961        let sdl = r#"
2962            index documents {
2963                field embedding: sparse_vector [indexed<query<weighting: one>>]
2964            }
2965        "#;
2966
2967        let indexes = parse_sdl(sdl).unwrap();
2968        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2969
2970        let query_config = config.query_config.as_ref().unwrap();
2971        assert!(query_config.tokenizer.is_none());
2972        assert_eq!(query_config.weighting, QueryWeighting::One);
2973    }
2974
2975    #[test]
2976    fn test_sparse_vector_query_config_weighting_idf_file() {
2977        use crate::structures::QueryWeighting;
2978
2979        let sdl = r#"
2980            index documents {
2981                field embedding: sparse_vector<u16> [indexed<quantization: uint8, query<tokenizer: "opensearch-neural-sparse-encoding-v1", weighting: idf_file>>]
2982            }
2983        "#;
2984
2985        let indexes = parse_sdl(sdl).unwrap();
2986        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
2987
2988        let query_config = config.query_config.as_ref().unwrap();
2989        assert_eq!(
2990            query_config.tokenizer.as_deref(),
2991            Some("opensearch-neural-sparse-encoding-v1")
2992        );
2993        assert_eq!(query_config.weighting, QueryWeighting::IdfFile);
2994
2995        // Verify schema conversion preserves idf_file
2996        let schema = indexes[0].to_schema();
2997        let field = schema.get_field("embedding").unwrap();
2998        let entry = schema.get_field_entry(field).unwrap();
2999        let sc = entry.sparse_vector_config.as_ref().unwrap();
3000        let qc = sc.query_config.as_ref().unwrap();
3001        assert_eq!(qc.weighting, QueryWeighting::IdfFile);
3002    }
3003
3004    #[test]
3005    fn test_sparse_vector_query_config_pruning_params() {
3006        let sdl = r#"
3007            index documents {
3008                field embedding: sparse_vector<u16> [indexed<quantization: uint8, query<weighting: idf, weight_threshold: 0.03, max_dims: 25, pruning: 0.2, lsp_gamma: 500>>]
3009            }
3010        "#;
3011
3012        let indexes = parse_sdl(sdl).unwrap();
3013        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
3014
3015        let qc = config.query_config.as_ref().unwrap();
3016        assert_eq!(qc.weighting, QueryWeighting::Idf);
3017        assert!((qc.weight_threshold - 0.03).abs() < 0.001);
3018        assert_eq!(qc.max_query_dims, Some(25));
3019        assert!((qc.pruning.unwrap() - 0.2).abs() < 0.001);
3020        assert_eq!(qc.lsp_gamma, Some(500));
3021
3022        // Verify schema roundtrip
3023        let schema = indexes[0].to_schema();
3024        let field = schema.get_field("embedding").unwrap();
3025        let entry = schema.get_field_entry(field).unwrap();
3026        let sc = entry.sparse_vector_config.as_ref().unwrap();
3027        let rqc = sc.query_config.as_ref().unwrap();
3028        assert!((rqc.weight_threshold - 0.03).abs() < 0.001);
3029        assert_eq!(rqc.max_query_dims, Some(25));
3030        assert!((rqc.pruning.unwrap() - 0.2).abs() < 0.001);
3031        assert_eq!(rqc.lsp_gamma, Some(500));
3032    }
3033
3034    #[test]
3035    fn test_sparse_vector_format_maxscore() {
3036        let sdl = r#"
3037            index documents {
3038                field embedding: sparse_vector<u16> [indexed<format: maxscore, quantization: uint8>]
3039            }
3040        "#;
3041
3042        let indexes = parse_sdl(sdl).unwrap();
3043        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
3044        assert_eq!(config.format, SparseFormat::MaxScore);
3045        assert_eq!(config.weight_quantization, WeightQuantization::UInt8);
3046
3047        // Verify schema roundtrip
3048        let schema = indexes[0].to_schema();
3049        let field = schema.get_field("embedding").unwrap();
3050        let entry = schema.get_field_entry(field).unwrap();
3051        let sc = entry.sparse_vector_config.as_ref().unwrap();
3052        assert_eq!(sc.format, SparseFormat::MaxScore);
3053    }
3054
3055    #[test]
3056    fn test_sparse_vector_format_bmp() {
3057        let sdl = r#"
3058            index documents {
3059                field embedding: sparse_vector<u16> [indexed<format: bmp, quantization: uint8>]
3060            }
3061        "#;
3062
3063        let indexes = parse_sdl(sdl).unwrap();
3064        let config = indexes[0].fields[0].sparse_vector_config.as_ref().unwrap();
3065        assert_eq!(config.format, SparseFormat::Bmp);
3066    }
3067
3068    #[test]
3069    fn test_fast_attribute() {
3070        let sdl = r#"
3071            index products {
3072                field name: text [indexed, stored]
3073                field price: f64 [indexed, fast]
3074                field category: text [indexed, stored, fast]
3075                field count: u64 [fast]
3076                field score: i64 [indexed, stored, fast]
3077            }
3078        "#;
3079
3080        let indexes = parse_sdl(sdl).unwrap();
3081        assert_eq!(indexes.len(), 1);
3082        let index = &indexes[0];
3083        assert_eq!(index.fields.len(), 5);
3084
3085        // name: no fast
3086        assert!(!index.fields[0].fast);
3087        // price: fast
3088        assert!(index.fields[1].fast);
3089        assert!(matches!(index.fields[1].field_type, FieldType::F64));
3090        // category: fast text
3091        assert!(index.fields[2].fast);
3092        assert!(matches!(index.fields[2].field_type, FieldType::Text));
3093        // count: fast only
3094        assert!(index.fields[3].fast);
3095        assert!(matches!(index.fields[3].field_type, FieldType::U64));
3096        // score: fast i64
3097        assert!(index.fields[4].fast);
3098        assert!(matches!(index.fields[4].field_type, FieldType::I64));
3099
3100        // Verify schema roundtrip preserves fast flag
3101        let schema = index.to_schema();
3102        let price_field = schema.get_field("price").unwrap();
3103        assert!(schema.get_field_entry(price_field).unwrap().fast);
3104
3105        let category_field = schema.get_field("category").unwrap();
3106        assert!(schema.get_field_entry(category_field).unwrap().fast);
3107
3108        let name_field = schema.get_field("name").unwrap();
3109        assert!(!schema.get_field_entry(name_field).unwrap().fast);
3110    }
3111
3112    #[test]
3113    fn test_primary_attribute() {
3114        let sdl = r#"
3115            index documents {
3116                field id: text [primary, stored]
3117                field title: text [indexed, stored]
3118            }
3119        "#;
3120
3121        let indexes = parse_sdl(sdl).unwrap();
3122        assert_eq!(indexes.len(), 1);
3123        let index = &indexes[0];
3124        assert_eq!(index.fields.len(), 2);
3125
3126        // id should be primary, and auto-set fast + indexed
3127        let id_field = &index.fields[0];
3128        assert!(id_field.primary, "id should be primary");
3129        assert!(id_field.fast, "primary implies fast");
3130        assert!(id_field.indexed, "primary implies indexed");
3131
3132        // title should NOT be primary
3133        assert!(!index.fields[1].primary);
3134
3135        // Verify schema conversion preserves primary_key
3136        let schema = index.to_schema();
3137        let id = schema.get_field("id").unwrap();
3138        let id_entry = schema.get_field_entry(id).unwrap();
3139        assert!(id_entry.primary_key);
3140        assert!(id_entry.fast);
3141        assert!(id_entry.indexed);
3142
3143        let title = schema.get_field("title").unwrap();
3144        assert!(!schema.get_field_entry(title).unwrap().primary_key);
3145
3146        // primary_field() should return the primary field
3147        assert_eq!(schema.primary_field(), Some(id));
3148    }
3149
3150    #[test]
3151    fn test_primary_with_other_attributes() {
3152        let sdl = r#"
3153            index documents {
3154                field id: text<simple> [primary, indexed, stored]
3155                field body: text [indexed]
3156            }
3157        "#;
3158
3159        let indexes = parse_sdl(sdl).unwrap();
3160        let id_field = &indexes[0].fields[0];
3161        assert!(id_field.primary);
3162        assert!(id_field.indexed);
3163        assert!(id_field.stored);
3164        assert!(id_field.fast);
3165        assert_eq!(id_field.tokenizer, Some("simple".to_string()));
3166    }
3167
3168    #[test]
3169    fn test_primary_only_one_allowed() {
3170        let sdl = r#"
3171            index documents {
3172                field id: text [primary]
3173                field alt_id: text [primary]
3174            }
3175        "#;
3176
3177        let result = parse_sdl(sdl);
3178        assert!(result.is_err());
3179        let err = result.unwrap_err().to_string();
3180        assert!(
3181            err.contains("primary key"),
3182            "Error should mention primary key: {}",
3183            err
3184        );
3185    }
3186
3187    #[test]
3188    fn test_primary_must_be_text() {
3189        let sdl = r#"
3190            index documents {
3191                field id: u64 [primary]
3192            }
3193        "#;
3194
3195        let result = parse_sdl(sdl);
3196        assert!(result.is_err());
3197        let err = result.unwrap_err().to_string();
3198        assert!(
3199            err.contains("text"),
3200            "Error should mention text type: {}",
3201            err
3202        );
3203    }
3204
3205    #[test]
3206    fn test_primary_cannot_be_multi() {
3207        let sdl = r#"
3208            index documents {
3209                field id: text [primary, stored<multi>]
3210            }
3211        "#;
3212
3213        let result = parse_sdl(sdl);
3214        assert!(result.is_err());
3215        let err = result.unwrap_err().to_string();
3216        assert!(err.contains("multi"), "Error should mention multi: {}", err);
3217    }
3218
3219    #[test]
3220    fn test_no_primary_field() {
3221        // Schema without primary field should work fine
3222        let sdl = r#"
3223            index documents {
3224                field title: text [indexed, stored]
3225            }
3226        "#;
3227
3228        let indexes = parse_sdl(sdl).unwrap();
3229        let schema = indexes[0].to_schema();
3230        assert!(schema.primary_field().is_none());
3231    }
3232
3233    #[test]
3234    fn bm25_parameters_parse_per_text_field() {
3235        let sdl = r#"
3236            index documents {
3237                field title: text<en_stem> [indexed<token_position, k1: 0.9, b: 0.4>]
3238                field body: text<en_stem> [indexed<chunked, token_position, b: 0.3>]
3239                field plain: text<en_stem> [indexed]
3240            }
3241        "#;
3242        let schema = parse_sdl(sdl).unwrap()[0].to_schema();
3243        let entry = |name: &str| {
3244            schema
3245                .get_field_entry(schema.get_field(name).unwrap())
3246                .unwrap()
3247                .clone()
3248        };
3249        assert_eq!(entry("title").bm25_k1, Some(0.9));
3250        assert_eq!(entry("title").bm25_b, Some(0.4));
3251        assert_eq!(entry("body").bm25_k1, None);
3252        assert_eq!(entry("body").bm25_b, Some(0.3));
3253        assert!(entry("body").chunked);
3254        assert_eq!(entry("plain").bm25_k1, None);
3255        assert_eq!(entry("plain").bm25_b, None);
3256        let params =
3257            crate::query::Bm25Params::for_field(&schema, schema.get_field("title").unwrap());
3258        assert_eq!((params.k1, params.b), (0.9, 0.4));
3259        let params =
3260            crate::query::Bm25Params::for_field(&schema, schema.get_field("plain").unwrap());
3261        assert_eq!((params.k1, params.b), (1.2, 0.75));
3262
3263        // Validation: b outside 0..=1, and parameters on a non-text field.
3264        assert!(parse_sdl("index i { field t: text [indexed<b: 1.5>] }").is_err());
3265        assert!(parse_sdl("index i { field n: u64 [indexed<k1: 0.9>] }").is_err());
3266    }
3267
3268    #[test]
3269    fn test_reorder_attribute() {
3270        let sdl = r#"
3271            index documents {
3272                field embedding: sparse_vector<u16> [indexed<format: bmp, quantization: uint8>, reorder]
3273                field embedding2: sparse_vector [indexed<format: bmp>]
3274            }
3275        "#;
3276
3277        let indexes = parse_sdl(sdl).unwrap();
3278        assert_eq!(indexes[0].fields.len(), 2);
3279
3280        // First field should have reorder=true
3281        assert!(indexes[0].fields[0].reorder);
3282        // Second field should have reorder=false
3283        assert!(!indexes[0].fields[1].reorder);
3284
3285        // Verify schema roundtrip
3286        let schema = indexes[0].to_schema();
3287        let f1 = schema.get_field("embedding").unwrap();
3288        assert!(schema.get_field_entry(f1).unwrap().reorder);
3289
3290        let f2 = schema.get_field("embedding2").unwrap();
3291        assert!(!schema.get_field_entry(f2).unwrap().reorder);
3292
3293        // Index-level reorder_on_merge absent → disabled (current behaviour)
3294        assert!(!schema.reorder_on_merge());
3295    }
3296
3297    #[test]
3298    fn test_reorder_on_merge_index_option() {
3299        let sdl = r#"
3300            index documents {
3301                reorder_on_merge: true
3302                field embedding: sparse_vector<u16> [indexed<format: bmp>, reorder]
3303            }
3304        "#;
3305
3306        let indexes = parse_sdl(sdl).unwrap();
3307        assert!(indexes[0].reorder_on_merge);
3308        let schema = indexes[0].to_schema();
3309        assert!(schema.reorder_on_merge());
3310
3311        // Explicit false parses and stays disabled
3312        let sdl_off = r#"
3313            index documents {
3314                reorder_on_merge: false
3315                field embedding: sparse_vector<u16> [indexed<format: bmp>, reorder]
3316            }
3317        "#;
3318        let indexes = parse_sdl(sdl_off).unwrap();
3319        assert!(!indexes[0].reorder_on_merge);
3320        assert!(!indexes[0].to_schema().reorder_on_merge());
3321
3322        // Schema serde roundtrip preserves the flag (persisted in metadata)
3323        let schema_on = parse_sdl(sdl).unwrap()[0].to_schema();
3324        let json = serde_json::to_string(&schema_on).unwrap();
3325        let back: crate::dsl::Schema = serde_json::from_str(&json).unwrap();
3326        assert!(back.reorder_on_merge());
3327    }
3328}