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