Skip to main content

memstead_base/search_index/
schema.rs

1//! Per-mem tantivy schema construction.
2//!
3//! A mem's indexed shape is derived from its resolved `memstead_schema::Schema`:
4//! the union of all types' section keys becomes the set of text fields,
5//! and every metadata field with `Filterable::Equality | Range` becomes a
6//! `STRING` fast field. Fixed fields (`id`, `mem`, `entity_type`, `title`)
7//! are always present.
8//!
9//! Field lookups at index/query time go through `IndexFields` rather than
10//! re-hitting `schema.get_field(...)` string-based dispatch — keeps hot
11//! paths cheap and panics explicit.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::sync::Arc;
15
16use memstead_schema::{Filterable, Schema};
17use tantivy::schema::{
18    Field, STORED, STRING, Schema as TantivySchema, SchemaBuilder, TextFieldIndexing, TextOptions,
19};
20
21use super::tokenizer::MEMSTEAD_TOKENIZER;
22
23/// Tantivy schema + pre-resolved field handles for a single mem index.
24#[derive(Clone, Debug)]
25pub struct IndexFields {
26    pub schema: TantivySchema,
27    pub id: Field,
28    pub mem: Field,
29    pub entity_type: Field,
30    pub title: Field,
31    /// Section-key → tantivy text field. Keys mirror the section keys the
32    /// parser writes into `Entity.sections`.
33    pub sections: BTreeMap<String, Field>,
34    /// Metadata field key → tantivy STRING field. Only filterable fields
35    /// are added so the index doesn't carry fields no caller will query.
36    pub metadata: BTreeMap<String, Field>,
37    /// ONE tokenized field carrying every entity's metadata KEYS and
38    /// VALUES ("key value" lines), built from the entity — no
39    /// `filterable` declaration needed, present even for a mem whose
40    /// schema declares no metadata fields. Participates in the
41    /// free-text query at a weight below title/sections so
42    /// identifier-shaped values are findable without enum/date tokens
43    /// swamping prose ranking. The untokenized `meta_<key>` fields
44    /// above keep their exact-match filter role untouched.
45    pub metadata_text: Field,
46}
47
48impl IndexFields {
49    /// Build the tantivy schema for a mem given the resolved mem schema.
50    ///
51    /// Falls back to a minimal fixed-field schema when `mem_schema` is
52    /// `None` — useful for read-mems whose pinned schema failed to
53    /// resolve (we still index id/mem/title so structural filters work).
54    pub fn build(mem_schema: Option<&Arc<Schema>>) -> Self {
55        let mut builder = SchemaBuilder::new();
56
57        // Fixed fields — id/mem are STRING for exact-match; title is a
58        // tokenized TEXT field with the memstead analyzer.
59        let id = builder.add_text_field("id", STRING | STORED);
60        let mem = builder.add_text_field("mem", STRING | STORED);
61        let entity_type = builder.add_text_field("entity_type", STRING | STORED);
62        let title = builder.add_text_field("title", text_options());
63        let metadata_text = builder.add_text_field("metadata", text_options());
64
65        // Union of section keys across every type in the mem's schema.
66        // BTreeSet — deterministic field order across runs, cheap to diff.
67        let section_keys: BTreeSet<String> = match mem_schema {
68            Some(schema) => schema
69                .types
70                .values()
71                .flat_map(|t| t.sections.iter().map(|s| s.key.clone()))
72                .collect(),
73            None => BTreeSet::new(),
74        };
75        let mut sections = BTreeMap::new();
76        for key in section_keys {
77            let f = builder.add_text_field(&format!("section_{key}"), text_options());
78            sections.insert(key, f);
79        }
80
81        // Filterable metadata — one STRING field per unique key. Per-type
82        // collisions on the same key are fine since `Filterable::Equality`
83        // fields carry the same lexical value across types.
84        let filterable_keys: BTreeSet<String> = match mem_schema {
85            Some(schema) => schema
86                .types
87                .values()
88                .flat_map(|t| t.metadata_fields.iter())
89                .filter(|f| matches!(f.filterable, Filterable::Equality | Filterable::Range))
90                .map(|f| f.key.clone())
91                .collect(),
92            None => BTreeSet::new(),
93        };
94        let mut metadata = BTreeMap::new();
95        for key in filterable_keys {
96            let f = builder.add_text_field(&format!("meta_{key}"), STRING);
97            metadata.insert(key, f);
98        }
99
100        let schema = builder.build();
101
102        Self {
103            schema,
104            id,
105            mem,
106            entity_type,
107            title,
108            sections,
109            metadata,
110            metadata_text,
111        }
112    }
113}
114
115/// Text-field options keyed to the memstead tokenizer, storing positions so
116/// phrase queries work without re-indexing.
117fn text_options() -> TextOptions {
118    TextOptions::default().set_indexing_options(
119        TextFieldIndexing::default()
120            .set_tokenizer(MEMSTEAD_TOKENIZER)
121            .set_index_option(tantivy::schema::IndexRecordOption::WithFreqsAndPositions),
122    )
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use memstead_schema::Schema;
129
130    #[test]
131    fn build_without_schema_still_has_fixed_fields() {
132        let fields = IndexFields::build(None);
133        assert!(fields.schema.get_field("id").is_ok());
134        assert!(fields.schema.get_field("mem").is_ok());
135        assert!(fields.schema.get_field("title").is_ok());
136        assert!(fields.sections.is_empty());
137        assert!(fields.metadata.is_empty());
138    }
139
140    #[test]
141    fn build_with_default_schema_emits_section_fields() {
142        let schema = Schema::builtin_default();
143        let fields = IndexFields::build(Some(&schema));
144        // The default schema's `spec` type declares identity/purpose —
145        // those must surface as tantivy fields.
146        assert!(fields.sections.contains_key("identity"));
147        assert!(fields.sections.contains_key("purpose"));
148        assert!(fields.schema.get_field("section_identity").is_ok());
149    }
150
151    #[test]
152    fn build_with_default_schema_emits_filterable_metadata() {
153        let schema = Schema::builtin_default();
154        let fields = IndexFields::build(Some(&schema));
155        // The built-in types declare filterable keys like `level` and
156        // `status`; a drift in any single key shouldn't fail the test,
157        // so we just check that at least one filterable key surfaced.
158        assert!(
159            !fields.metadata.is_empty(),
160            "default schema should expose at least one filterable metadata field"
161        );
162    }
163}