Skip to main content

hawdb_core/
vector_index.rs

1// Copyright 2026 Nowledge
2// Licensed under the Apache License, Version 2.0.
3
4//! Schema-bound vector index definitions shared by SQL and Cypher.
5//!
6//! A descriptor is a definition, not serving authority. Publication identity,
7//! source epoch and ordinal ownership belong to its registered Consumer pin.
8
9use crate::{HawDBError, PropertyId, Result, TableId, VectorDimension, VectorDistance};
10
11mod codec;
12
13/// Upper bound used to admit descriptor encoding/decoding before allocation.
14pub const MAX_VECTOR_INDEX_DESCRIPTOR_BYTES: usize = 4096;
15
16/// Immutable source and publication identity of an eligible vector domain.
17/// This is a query binding, never independent authority to publish or serve.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct VectorIndexReadIdentity {
20    pub database: crate::Uuid,
21    pub branch: crate::Uuid,
22    pub consumer_id_hash: [u8; 32],
23    pub target_locator_hash: [u8; 32],
24    pub registration: crate::Uuid,
25    pub projection: crate::Uuid,
26    pub checkpoint: crate::Uuid,
27    pub target_incarnation: std::num::NonZeroU64,
28    pub generation: std::num::NonZeroU64,
29    pub operation: std::num::NonZeroU64,
30    pub source_epoch: u64,
31}
32
33/// Query semantics, independent of the persistent index backend.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub enum VectorIndexQueryMode {
36    /// Fix an approximate window before applying canonical query predicates.
37    #[default]
38    AnnWindow,
39    /// Exhaust the declared eligible domain before selecting exact nearest rows.
40    Exact,
41    /// Restrict approximate candidate generation to a complete eligible domain.
42    FilteredAnn,
43}
44
45impl VectorIndexQueryMode {
46    pub fn parse(value: &str) -> Result<Self> {
47        match value {
48            "ann_window" => Ok(Self::AnnWindow),
49            "exact" => Ok(Self::Exact),
50            "filtered_ann" => Ok(Self::FilteredAnn),
51            _ => Err(invalid(
52                "query mode must be 'ann_window', 'exact' or 'filtered_ann'",
53            )),
54        }
55    }
56
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::AnnWindow => "ann_window",
60            Self::Exact => "exact",
61            Self::FilteredAnn => "filtered_ann",
62        }
63    }
64
65    pub const fn requires_eligible_domain(self) -> bool {
66        matches!(self, Self::Exact | Self::FilteredAnn)
67    }
68
69    pub const fn filter_position(self) -> &'static str {
70        match self {
71            Self::AnnWindow => "after_window",
72            Self::Exact => "before_ranking",
73            Self::FilteredAnn => "before_candidates",
74        }
75    }
76}
77
78/// Resolved field identity. Relational schemas currently identify columns by
79/// normalized table name and ordinal; the descriptor's schema digest fences
80/// reinterpretation of that ordinal after a schema change.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum VectorIndexTarget {
83    Relational {
84        table: String,
85        column: String,
86        column_ordinal: u32,
87    },
88    NodeProperty {
89        table: String,
90        property: String,
91        table_id: TableId,
92        property_id: PropertyId,
93    },
94}
95
96/// Persistent algorithm settings, independent of per-operation admission.
97/// Runtime memory limits and cancellation are supplied at build/query time.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum VectorIndexBackend {
100    RaBitQ {
101        bit_width: u8,
102        segment_rows: u32,
103        transform_seed: u64,
104    },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct VectorIndexDescriptor {
109    name: String,
110    target: VectorIndexTarget,
111    dimension: VectorDimension,
112    distance: VectorDistance,
113    backend: VectorIndexBackend,
114    schema_digest: [u8; 32],
115}
116
117impl VectorIndexDescriptor {
118    #[doc(hidden)]
119    pub fn retained_capacity_bytes(&self) -> usize {
120        let (table, field) = match &self.target {
121            VectorIndexTarget::Relational { table, column, .. } => (table, column),
122            VectorIndexTarget::NodeProperty {
123                table, property, ..
124            } => (table, property),
125        };
126        std::mem::size_of::<Self>()
127            .saturating_add(self.name.capacity())
128            .saturating_add(table.capacity())
129            .saturating_add(field.capacity())
130    }
131
132    /// Constructs a validated definition. The facade must resolve the target,
133    /// dimension and digest against its pinned canonical schema before use.
134    pub fn new(
135        name: String,
136        target: VectorIndexTarget,
137        dimension: VectorDimension,
138        distance: VectorDistance,
139        backend: VectorIndexBackend,
140        schema_digest: [u8; 32],
141    ) -> Result<Self> {
142        if name.is_empty()
143            || name.len() > 128
144            || !name
145                .bytes()
146                .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
147        {
148            return Err(invalid(
149                "name must be 1..128 ASCII letters, digits, '.', '_' or '-'",
150            ));
151        }
152        let (table, field) = match &target {
153            VectorIndexTarget::Relational { table, column, .. } => (table, column),
154            VectorIndexTarget::NodeProperty {
155                table, property, ..
156            } => (table, property),
157        };
158        for name in [table, field] {
159            if name.is_empty() || name.len() > 1024 || name.contains('\0') {
160                return Err(invalid(
161                    "target names must contain 1..1024 non-NUL UTF-8 bytes",
162                ));
163            }
164        }
165        let VectorIndexBackend::RaBitQ {
166            bit_width,
167            segment_rows,
168            ..
169        } = backend;
170        if !matches!(bit_width, 1 | 4) || segment_rows == 0 {
171            return Err(invalid(
172                "RaBitQ requires 1 or 4 bits and a positive segment size",
173            ));
174        }
175        if distance != VectorDistance::Cosine {
176            return Err(invalid(
177                "RaBitQ indexes currently support cosine distance only",
178            ));
179        }
180        Ok(Self {
181            name,
182            target,
183            dimension,
184            distance,
185            backend,
186            schema_digest,
187        })
188    }
189
190    pub fn name(&self) -> &str {
191        &self.name
192    }
193
194    pub fn target(&self) -> &VectorIndexTarget {
195        &self.target
196    }
197
198    pub const fn dimension(&self) -> VectorDimension {
199        self.dimension
200    }
201
202    pub const fn distance(&self) -> VectorDistance {
203        self.distance
204    }
205
206    pub const fn backend(&self) -> VectorIndexBackend {
207        self.backend
208    }
209
210    pub const fn schema_digest(&self) -> &[u8; 32] {
211        &self.schema_digest
212    }
213
214    pub fn retained_bytes(&self) -> usize {
215        let (table, field) = match &self.target {
216            VectorIndexTarget::Relational { table, column, .. } => (table, column),
217            VectorIndexTarget::NodeProperty {
218                table, property, ..
219            } => (table, property),
220        };
221        std::mem::size_of::<Self>() + self.name.capacity() + table.capacity() + field.capacity()
222    }
223}
224
225fn invalid(reason: &str) -> HawDBError {
226    HawDBError::Semantic(format!("invalid vector index: {reason}"))
227}