1use std::collections::BTreeMap;
12use std::sync::Arc;
13
14use uqa_core::{
15 DocId, FieldName, IndexStats, Payload, PostingEntry, PostingList, Predicate, Value,
16};
17use uqa_scoring::Scorer;
18use uqa_storage::StorageBackendError;
19
20use crate::base::{
21 missing_backend, require_finite_score, ExecutionContext, Operator, OperatorResult,
22};
23
24pub struct TermOperator {
29 pub term: String,
30 pub field: String,
31}
32
33impl TermOperator {
34 pub fn new(term: impl Into<String>, field: impl Into<String>) -> Self {
35 Self {
36 term: term.into(),
37 field: field.into(),
38 }
39 }
40}
41
42impl Operator for TermOperator {
43 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
44 let Some(idx) = ctx.inverted_index.as_ref() else {
45 return Err(missing_backend("inverted-index", "term search"));
46 };
47 let analyzer = idx.get_search_analyzer(&self.field);
50 let tokens = analyzer.analyze(&self.term)?;
51 if tokens.is_empty() {
52 return Ok(PostingList::new());
53 }
54 let mut acc = idx.get_posting_list(&self.field, &tokens[0])?;
55 for t in &tokens[1..] {
56 acc = acc.merge_union(&idx.get_posting_list(&self.field, t)?);
57 }
58 Ok(acc)
59 }
60
61 fn cost_estimate(&self, stats: &IndexStats) -> f64 {
62 stats.doc_freq(&self.field, &self.term) as f64
63 }
64}
65
66pub struct SpatialWithinOperator {
73 pub field: String,
74 pub center_x: f64,
75 pub center_y: f64,
76 pub distance: f64,
77}
78
79impl SpatialWithinOperator {
80 pub fn new(field: impl Into<String>, center_x: f64, center_y: f64, distance: f64) -> Self {
81 Self {
82 field: field.into(),
83 center_x,
84 center_y,
85 distance,
86 }
87 }
88}
89
90impl Operator for SpatialWithinOperator {
91 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
92 if !self.center_x.is_finite()
93 || !self.center_y.is_finite()
94 || !self.distance.is_finite()
95 || self.distance < 0.0
96 {
97 return Err(StorageBackendError::Other(format!(
98 "spatial filter requires finite coordinates and a non-negative finite distance, got ({}, {}) distance {}",
99 self.center_x, self.center_y, self.distance
100 )));
101 }
102 let Some(doc_store) = ctx.document_store.as_ref() else {
103 return Err(missing_backend("document-store", "spatial filter"));
104 };
105 let mut entries: Vec<PostingEntry> = Vec::new();
106 let mut ids = doc_store.doc_ids()?;
107 ids.sort_unstable();
108 for doc_id in ids {
109 if doc_store.get(doc_id)?.is_none() {
110 return Err(StorageBackendError::Other(format!(
111 "spatial filter candidate {doc_id} is missing from the document store"
112 )));
113 }
114 let Some(pt) = doc_store.get_field(doc_id, &self.field)? else {
115 continue;
116 };
117 let coords = match &pt {
118 Value::List(items) if items.len() == 2 => items,
119 _ => {
120 return Err(StorageBackendError::Other(format!(
121 "spatial field {:?} for document {doc_id} must be a two-component numeric list",
122 self.field
123 )))
124 }
125 };
126 let (Some(x), Some(y)) = (value_to_f64(&coords[0]), value_to_f64(&coords[1])) else {
127 return Err(StorageBackendError::Other(format!(
128 "spatial field {:?} for document {doc_id} contains a non-numeric coordinate",
129 self.field
130 )));
131 };
132 if !x.is_finite() || !y.is_finite() {
133 return Err(StorageBackendError::Other(format!(
134 "spatial field {:?} for document {doc_id} contains a non-finite coordinate",
135 self.field
136 )));
137 }
138 let dist = uqa_storage::haversine_distance(self.center_x, self.center_y, x, y);
139 if dist <= self.distance {
140 let score = if self.distance > 0.0 {
141 1.0 - (dist / self.distance)
142 } else {
143 1.0
144 };
145 entries.push(PostingEntry::new(
146 doc_id,
147 Payload {
148 score,
149 ..Default::default()
150 },
151 ));
152 }
153 }
154 Ok(PostingList::from_sorted_unchecked(entries))
155 }
156
157 fn cost_estimate(&self, stats: &IndexStats) -> f64 {
158 ((stats.total_docs + 1) as f64).log2()
159 }
160}
161
162fn value_to_f64(v: &Value) -> Option<f64> {
163 match v {
164 Value::Int(i) => Some(*i as f64),
165 Value::Float(f) => Some(*f),
166 _ => None,
167 }
168}
169
170pub struct FilterOperator {
173 pub field: String,
174 pub predicate: Predicate,
175 pub source: Option<Arc<dyn Operator>>,
176}
177
178impl FilterOperator {
179 pub fn new(
180 field: impl Into<String>,
181 predicate: Predicate,
182 source: Option<Arc<dyn Operator>>,
183 ) -> Self {
184 Self {
185 field: field.into(),
186 predicate,
187 source,
188 }
189 }
190}
191
192impl Operator for FilterOperator {
193 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
194 let Some(doc_store) = ctx.document_store.as_ref() else {
195 return Err(missing_backend("document-store", "field filter"));
196 };
197 let null_aware = self.predicate.is_null_aware();
198
199 let candidates: Vec<PostingEntry> = if let Some(src) = &self.source {
200 src.execute(ctx)?.into_iter().collect()
201 } else {
202 doc_store
203 .doc_ids()?
204 .into_iter()
205 .map(|id| PostingEntry::new(id, Payload::default()))
206 .collect()
207 };
208
209 let mut out = Vec::with_capacity(candidates.len());
210 for entry in candidates {
211 if doc_store.get(entry.doc_id)?.is_none() {
212 return Err(StorageBackendError::Other(format!(
213 "field filter candidate {} is missing from the document store",
214 entry.doc_id
215 )));
216 }
217 let value = doc_store.get_field(entry.doc_id, &self.field)?;
218 let matched = if null_aware {
219 self.predicate.evaluate(value.as_ref())
220 } else {
221 value.is_some() && self.predicate.evaluate(value.as_ref())
222 };
223 if matched {
224 out.push(entry);
225 }
226 }
227 Ok(PostingList::from_sorted_unchecked(out))
228 }
229}
230
231pub struct FacetOperator {
236 pub field: String,
237 pub source: Option<Arc<dyn Operator>>,
238}
239
240impl FacetOperator {
241 pub fn new(field: impl Into<String>, source: Option<Arc<dyn Operator>>) -> Self {
242 Self {
243 field: field.into(),
244 source,
245 }
246 }
247}
248
249impl Operator for FacetOperator {
250 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
251 let Some(doc_store) = ctx.document_store.as_ref() else {
252 return Err(missing_backend("document-store", "facet aggregation"));
253 };
254
255 let candidate_ids: Vec<DocId> = if let Some(src) = &self.source {
256 src.execute(ctx)?.doc_ids().collect()
257 } else {
258 doc_store.doc_ids()?
259 };
260
261 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
262 for doc_id in candidate_ids {
263 if doc_store.get(doc_id)?.is_none() {
264 return Err(StorageBackendError::Other(format!(
265 "facet candidate {doc_id} is missing from the document store"
266 )));
267 }
268 if let Some(v) = doc_store.get_field(doc_id, &self.field)? {
269 let key = value_to_string(&v);
270 let count = counts.entry(key).or_insert(0);
271 *count = count.checked_add(1).ok_or_else(|| {
272 StorageBackendError::Other("facet count overflowed u64".to_string())
273 })?;
274 }
275 }
276
277 let mut entries = Vec::with_capacity(counts.len());
278 for (i, (value, count)) in counts.into_iter().enumerate() {
279 if count > 9_007_199_254_740_992 {
280 return Err(StorageBackendError::Other(format!(
281 "facet count {count} cannot be represented exactly as an f64 score"
282 )));
283 }
284 let mut fields = BTreeMap::new();
285 fields.insert("_facet_field".to_string(), Value::Str(self.field.clone()));
286 fields.insert("_facet_value".to_string(), Value::Str(value));
287 fields.insert(
288 "_facet_count".to_string(),
289 Value::Int(i64::try_from(count).map_err(|_| {
290 StorageBackendError::Other(format!(
291 "facet count {count} exceeds the Value::Int range"
292 ))
293 })?),
294 );
295 entries.push(PostingEntry::new(
296 DocId::try_from(i).map_err(|_| {
297 StorageBackendError::Other(format!(
298 "facet bucket index {i} exceeds the document-id range"
299 ))
300 })?,
301 Payload {
302 positions: Vec::new(),
303 score: count as f64,
304 fields,
305 },
306 ));
307 }
308 Ok(PostingList::from_sorted_unchecked(entries))
309 }
310}
311
312fn value_to_string(v: &Value) -> String {
313 match v {
314 Value::Null => "null".to_string(),
315 Value::Bool(b) => b.to_string(),
316 Value::Int(i) => i.to_string(),
317 Value::Float(f) => f.to_string(),
318 Value::Str(s) => s.clone(),
319 other => format!("{other:?}"),
320 }
321}
322
323pub struct ScoreOperator {
326 pub scorer: Arc<dyn Scorer>,
327 pub source: Arc<dyn Operator>,
328 pub query_terms: Vec<String>,
329 pub field: FieldName,
330}
331
332impl ScoreOperator {
333 pub fn new(
334 scorer: Arc<dyn Scorer>,
335 source: Arc<dyn Operator>,
336 query_terms: Vec<String>,
337 field: impl Into<FieldName>,
338 ) -> Self {
339 Self {
340 scorer,
341 source,
342 query_terms,
343 field: field.into(),
344 }
345 }
346}
347
348impl Operator for ScoreOperator {
349 fn execute(&self, ctx: &ExecutionContext) -> OperatorResult {
350 let source_pl = self.source.execute(ctx)?;
351 let Some(idx) = ctx.inverted_index.as_ref() else {
352 return Err(missing_backend("inverted-index", "score operator"));
353 };
354
355 let mut term_idfs = Vec::with_capacity(self.query_terms.len());
357 for term in &self.query_terms {
358 term_idfs.push(self.scorer.idf(idx.doc_freq(&self.field, term)?));
359 }
360
361 let doc_ids: Vec<DocId> = source_pl.iter().map(|entry| entry.doc_id).collect();
362 let scoring_inputs =
363 idx.get_scoring_inputs_bulk(&doc_ids, &self.field, &self.query_terms)?;
364 if source_pl.len() != scoring_inputs.len() {
365 return Err(StorageBackendError::Other(format!(
366 "score operator received {} storage inputs for {} source documents",
367 scoring_inputs.len(),
368 source_pl.len()
369 )));
370 }
371 let mut entries = Vec::with_capacity(source_pl.len());
372 let mut per_term_scores = Vec::with_capacity(self.query_terms.len());
373 for (entry, (doc_length, term_freqs)) in source_pl.iter().zip(scoring_inputs) {
374 per_term_scores.clear();
375 per_term_scores.extend(term_freqs.into_iter().zip(&term_idfs).map(
376 |(term_freq, idf)| self.scorer.term_score_with_idf(term_freq, doc_length, *idf),
377 ));
378 let total = self.scorer.finalize_score(&per_term_scores);
379 require_finite_score(total, "score operator")?;
380 entries.push(PostingEntry {
381 doc_id: entry.doc_id,
382 payload: Payload {
383 positions: entry.payload.positions.clone(),
384 score: total,
385 fields: entry.payload.fields.clone(),
386 },
387 });
388 }
389 Ok(PostingList::from_sorted_unchecked(entries))
390 }
391}