Skip to main content

linkrs_core/
vector.rs

1//! Transport-independent vector payload types shared across crates.
2//!
3//! # Type categories
4//!
5//! This module contains two logically distinct groups of types:
6//!
7//! ## Core shared types (`PointId`, `Payload`, `PayloadValue`, `PayloadSchemaType`)
8//! True foundational types used by the wire layer (`linkrs-wire`), the storage
9//! engine, and the query layer. These must remain in `linkrs-core` because
10//! `linkrs-wire` depends on `linkrs-core` but cannot depend on `simvec`
11//! (it would drag in heavy transitive deps like rayon/memmap2 into a lightweight
12//! wire crate).
13//!
14//! ## Vector filter DSL types (`VectorFilter`, `FilterCondition`, `ConditionType`, etc.)
15//! Transport-semantics copies of the filter types. The index-semantics
16//! canonical definitions live in the `simvec` crate (`simvec::filter_cond`);
17//! this module keeps an identical transport-facing copy because
18//! `linkrs-wire` depends on `linkrs-core` and cannot depend on `simvec`
19//! (it would drag in heavy transitive deps like rayon/memmap2 into a
20//! lightweight wire crate). The two copies are converted mechanically at
21//! the sync-side backend enumeration boundary; keep them in sync when
22//! changing the filter surface.
23
24use std::collections::HashMap;
25
26// ---------------------------------------------------------------------------
27// Core shared types
28// ---------------------------------------------------------------------------
29
30/// Payload attached to a vector point: a JSON object keyed by field name.
31pub type Payload = HashMap<String, serde_json::Value>;
32
33/// A scalar payload value (alias of `serde_json::Value`).
34pub type PayloadValue = serde_json::Value;
35
36/// Point identifier: numeric or UUID-shaped string.
37#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
38#[serde(untagged)]
39pub enum PointId {
40    Num(u64),
41    Uuid(String),
42}
43
44impl From<u64> for PointId {
45    fn from(v: u64) -> Self {
46        Self::Num(v)
47    }
48}
49
50impl From<String> for PointId {
51    fn from(v: String) -> Self {
52        // Numeric strings normalize to `Num` so an id round-trips through
53        // its string form regardless of the wire encoding.
54        if let Ok(n) = v.parse::<u64>() {
55            Self::Num(n)
56        } else {
57            Self::Uuid(v)
58        }
59    }
60}
61
62impl From<&str> for PointId {
63    fn from(v: &str) -> Self {
64        Self::from(v.to_string())
65    }
66}
67
68impl std::fmt::Display for PointId {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::Num(n) => write!(f, "{n}"),
72            Self::Uuid(s) => write!(f, "{s}"),
73        }
74    }
75}
76
77/// Declared schema of an indexed payload field.
78#[derive(
79    Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
80)]
81pub enum PayloadSchemaType {
82    Keyword,
83    Integer,
84    Float,
85    Text,
86    Bool,
87    Geo,
88    Datetime,
89}
90
91impl PayloadSchemaType {
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            Self::Keyword => "keyword",
95            Self::Integer => "integer",
96            Self::Float => "float",
97            Self::Text => "text",
98            Self::Bool => "bool",
99            Self::Geo => "geo",
100            Self::Datetime => "datetime",
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// Vector filter DSL types
107//
108// These are logically part of `simvec` but live in core due to the
109// dependency DAG constraint. The filter evaluation logic is in
110// `simvec::filter`. Downstream crates may import these from either
111// `linkrs_core::vector` or `simvec::types`.
112// ---------------------------------------------------------------------------
113
114/// Geographic point (`lat`, `lon` in degrees).
115#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
116pub struct GeoPoint {
117    pub lat: f64,
118    pub lon: f64,
119}
120
121impl GeoPoint {
122    pub fn new(lat: f64, lon: f64) -> Self {
123        Self { lat, lon }
124    }
125}
126
127/// Radius filter around a geographic center.
128#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
129pub struct GeoRadius {
130    pub center: GeoPoint,
131    /// Radius in meters.
132    pub radius: f64,
133}
134
135impl GeoRadius {
136    pub fn new(center: GeoPoint, radius: f64) -> Self {
137        Self { center, radius }
138    }
139}
140
141/// Lat/lon bounding-box filter.
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
143pub struct GeoBoundingBox {
144    pub top_left: GeoPoint,
145    pub bottom_right: GeoPoint,
146}
147
148impl GeoBoundingBox {
149    pub fn new(top_left: GeoPoint, bottom_right: GeoPoint) -> Self {
150        Self {
151            top_left,
152            bottom_right,
153        }
154    }
155}
156
157/// Range bound on the number of values an array field holds.
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
159pub struct ValuesCountCondition {
160    pub gt: Option<u64>,
161    pub gte: Option<u64>,
162    pub lt: Option<u64>,
163    pub lte: Option<u64>,
164}
165
166impl ValuesCountCondition {
167    pub fn new() -> Self {
168        Self {
169            gt: None,
170            gte: None,
171            lt: None,
172            lte: None,
173        }
174    }
175
176    pub fn gt(mut self, value: u64) -> Self {
177        self.gt = Some(value);
178        self
179    }
180
181    pub fn gte(mut self, value: u64) -> Self {
182        self.gte = Some(value);
183        self
184    }
185
186    pub fn lt(mut self, value: u64) -> Self {
187        self.lt = Some(value);
188        self
189    }
190
191    pub fn lte(mut self, value: u64) -> Self {
192        self.lte = Some(value);
193        self
194    }
195}
196
197impl Default for ValuesCountCondition {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203/// Top-level payload filter with Qdrant-style clause groups.
204#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
205#[schema(no_recursion)]
206pub struct VectorFilter {
207    pub must: Option<Vec<FilterCondition>>,
208    pub must_not: Option<Vec<FilterCondition>>,
209    pub should: Option<Vec<FilterCondition>>,
210    pub min_should: Option<MinShouldCondition>,
211}
212
213impl VectorFilter {
214    pub fn new() -> Self {
215        Self::default()
216    }
217
218    pub fn must(mut self, condition: FilterCondition) -> Self {
219        self.must.get_or_insert_with(Vec::new).push(condition);
220        self
221    }
222
223    pub fn must_not(mut self, condition: FilterCondition) -> Self {
224        self.must_not.get_or_insert_with(Vec::new).push(condition);
225        self
226    }
227
228    pub fn should(mut self, condition: FilterCondition) -> Self {
229        self.should.get_or_insert_with(Vec::new).push(condition);
230        self
231    }
232}
233
234/// `should` clause with an explicit minimum match count.
235#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
236#[schema(no_recursion)]
237pub struct MinShouldCondition {
238    pub conditions: Vec<FilterCondition>,
239    pub min_count: usize,
240}
241
242/// A single filter condition: field plus its predicate.
243#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
244#[schema(no_recursion)]
245pub struct FilterCondition {
246    pub field: String,
247    pub condition: ConditionType,
248}
249
250impl FilterCondition {
251    pub fn new(field: impl Into<String>, condition: ConditionType) -> Self {
252        Self {
253            field: field.into(),
254            condition,
255        }
256    }
257
258    pub fn match_value(field: impl Into<String>, value: impl Into<String>) -> Self {
259        Self::new(
260            field,
261            ConditionType::Match {
262                value: value.into(),
263            },
264        )
265    }
266
267    pub fn match_any(field: impl Into<String>, values: Vec<PayloadValue>) -> Self {
268        Self::new(field, ConditionType::MatchAny { values })
269    }
270
271    pub fn range(field: impl Into<String>, range: RangeCondition) -> Self {
272        Self::new(field, ConditionType::Range(range))
273    }
274
275    pub fn is_empty(field: impl Into<String>) -> Self {
276        Self::new(field, ConditionType::IsEmpty)
277    }
278
279    pub fn is_null(field: impl Into<String>) -> Self {
280        Self::new(field, ConditionType::IsNull)
281    }
282
283    pub fn has_id(ids: Vec<String>) -> Self {
284        Self::new("_id", ConditionType::HasId { ids })
285    }
286
287    pub fn geo_radius(field: impl Into<String>, radius: GeoRadius) -> Self {
288        Self::new(field, ConditionType::GeoRadius(radius))
289    }
290
291    pub fn geo_bounding_box(field: impl Into<String>, bbox: GeoBoundingBox) -> Self {
292        Self::new(field, ConditionType::GeoBoundingBox(bbox))
293    }
294
295    pub fn values_count(field: impl Into<String>, count: ValuesCountCondition) -> Self {
296        Self::new(field, ConditionType::ValuesCount(count))
297    }
298
299    pub fn contains(field: impl Into<String>, value: impl Into<String>) -> Self {
300        Self::new(
301            field,
302            ConditionType::Contains {
303                value: value.into(),
304            },
305        )
306    }
307}
308
309/// Predicate of a [`FilterCondition`].
310#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
311#[schema(no_recursion)]
312pub enum ConditionType {
313    /// Match a scalar field value.
314    Match {
315        value: String,
316    },
317    /// Match any of the given values (OR semantics).
318    MatchAny {
319        #[schema(value_type = Vec<serde_json::Value>)]
320        values: Vec<PayloadValue>,
321    },
322    /// Numeric range over the field value.
323    Range(RangeCondition),
324    IsEmpty,
325    IsNull,
326    HasId {
327        ids: Vec<String>,
328    },
329    Nested {
330        filter: Box<VectorFilter>,
331    },
332    GeoRadius(GeoRadius),
333    GeoBoundingBox(GeoBoundingBox),
334    ValuesCount(ValuesCountCondition),
335    Contains {
336        value: String,
337    },
338}
339
340/// Numeric range bounds; absent bounds are open.
341#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
342pub struct RangeCondition {
343    pub gt: Option<f64>,
344    pub gte: Option<f64>,
345    pub lt: Option<f64>,
346    pub lte: Option<f64>,
347}
348
349impl RangeCondition {
350    pub fn new() -> Self {
351        Self {
352            gt: None,
353            gte: None,
354            lt: None,
355            lte: None,
356        }
357    }
358
359    pub fn gt(mut self, value: f64) -> Self {
360        self.gt = Some(value);
361        self
362    }
363
364    pub fn gte(mut self, value: f64) -> Self {
365        self.gte = Some(value);
366        self
367    }
368
369    pub fn lt(mut self, value: f64) -> Self {
370        self.lt = Some(value);
371        self
372    }
373
374    pub fn lte(mut self, value: f64) -> Self {
375        self.lte = Some(value);
376        self
377    }
378}
379
380impl Default for RangeCondition {
381    fn default() -> Self {
382        Self::new()
383    }
384}
385
386/// Returned-payload projection for search results.
387#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
388pub struct PayloadSelector {
389    pub include: Option<Vec<String>>,
390    pub exclude: Option<Vec<String>>,
391}
392
393impl PayloadSelector {
394    /// Apply this selector to a payload map: include-list keeps only the
395    /// listed fields, exclude-list removes them.
396    pub fn apply(&self, payload: &Payload) -> Payload {
397        let mut out = if let Some(include) = &self.include {
398            include
399                .iter()
400                .filter_map(|k| payload.get(k).map(|v| (k.clone(), v.clone())))
401                .collect::<Payload>()
402        } else {
403            payload.clone()
404        };
405        if let Some(exclude) = &self.exclude {
406            for k in exclude {
407                out.remove(k);
408            }
409        }
410        out
411    }
412
413    pub fn include(fields: Vec<String>) -> Self {
414        Self {
415            include: Some(fields),
416            exclude: None,
417        }
418    }
419
420    pub fn exclude(fields: Vec<String>) -> Self {
421        Self {
422            include: None,
423            exclude: Some(fields),
424        }
425    }
426
427    pub fn all() -> Self {
428        Self {
429            include: None,
430            exclude: None,
431        }
432    }
433}