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