Skip to main content

summa_core/dsl/sdl/
mod.rs

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