Skip to main content

hermes_core/dsl/sdl/
mod.rs

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