Skip to main content

hyphae_core/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Stable product identity, compatibility constants, and canonical vector
4//! domain values shared by Hyphae surfaces.
5
6use std::{error::Error, fmt};
7
8/// Canonical product and executable name.
9pub const PRODUCT_NAME: &str = "hyphae";
10
11/// Current public HTTP API version.
12pub const API_VERSION: &str = "v1";
13
14/// Current on-disk format version.
15pub const DISK_FORMAT_VERSION: u16 = 2;
16
17/// Oldest on-disk format this binary can open and migrate.
18pub const MIN_DISK_FORMAT_VERSION: u16 = 1;
19
20/// Disk format introduced by Hyphae 0.2 durable retrieval.
21pub const DURABLE_RETRIEVAL_DISK_FORMAT_VERSION: u16 = 2;
22
23/// Maximum canonical vector-space identifier length.
24pub const MAX_VECTOR_SPACE_NAME_BYTES: usize = 128;
25
26/// Maximum canonical vector dimension.
27pub const MAX_VECTOR_DIMENSIONS: usize = 4_096;
28
29/// Scale used by canonical cosine scores.
30pub const SCORE_NANOS_SCALE: i64 = 1_000_000_000;
31
32/// Failure to construct a canonical shared vector value.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum VectorValueError {
35    /// The vector-space name is empty.
36    EmptySpaceName,
37    /// The vector-space name exceeds its byte limit.
38    SpaceNameTooLong,
39    /// The vector-space name does not match the canonical ASCII grammar.
40    InvalidSpaceName,
41    /// A vector has no elements.
42    EmptyVector,
43    /// A vector exceeds the maximum dimension.
44    DimensionTooLarge,
45    /// Signed Q15 reserves `i16::MIN` and therefore rejects it.
46    InvalidQ15Element,
47    /// Cosine is undefined for an all-zero vector.
48    ZeroVector,
49    /// A space dimension and vector dimension differ.
50    DimensionMismatch,
51}
52
53impl fmt::Display for VectorValueError {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        let message = match self {
56            Self::EmptySpaceName => "vector-space name must be nonempty",
57            Self::SpaceNameTooLong => "vector-space name exceeds 128 bytes",
58            Self::InvalidSpaceName => {
59                "vector-space name does not match the canonical ASCII grammar"
60            }
61            Self::EmptyVector => "vector must be nonempty",
62            Self::DimensionTooLarge => "vector dimension exceeds 4096",
63            Self::InvalidQ15Element => "Q15 vector elements cannot equal -32768",
64            Self::ZeroVector => "vector must have nonzero magnitude",
65            Self::DimensionMismatch => "vector dimension does not match the named space",
66        };
67        formatter.write_str(message)
68    }
69}
70
71impl Error for VectorValueError {}
72
73/// Canonical bounded ASCII name for one vector space.
74#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
75pub struct VectorSpaceName(String);
76
77impl VectorSpaceName {
78    /// Validates and constructs a canonical vector-space name.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error unless `value` matches
83    /// `[A-Za-z][A-Za-z0-9._-]{0,127}`.
84    pub fn new(value: impl Into<String>) -> Result<Self, VectorValueError> {
85        let value = value.into();
86        if value.is_empty() {
87            return Err(VectorValueError::EmptySpaceName);
88        }
89        if value.len() > MAX_VECTOR_SPACE_NAME_BYTES {
90            return Err(VectorValueError::SpaceNameTooLong);
91        }
92        let mut bytes = value.bytes();
93        let first = bytes.next().ok_or(VectorValueError::EmptySpaceName)?;
94        if !first.is_ascii_alphabetic()
95            || !bytes.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
96        {
97            return Err(VectorValueError::InvalidSpaceName);
98        }
99        Ok(Self(value))
100    }
101
102    /// Returns the canonical UTF-8/ASCII representation.
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106
107    /// Consumes the name and returns its canonical string.
108    pub fn into_string(self) -> String {
109        self.0
110    }
111}
112
113impl fmt::Display for VectorSpaceName {
114    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
115        formatter.write_str(self.as_str())
116    }
117}
118
119/// Canonical nonzero signed-Q15 vector.
120#[derive(Clone, Debug, Eq, PartialEq)]
121pub struct Q15Vector(Vec<i16>);
122
123impl Q15Vector {
124    /// Validates and constructs a canonical Q15 vector.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error for an empty, oversized, all-zero vector or an element
129    /// equal to `i16::MIN`.
130    pub fn new(values: impl Into<Vec<i16>>) -> Result<Self, VectorValueError> {
131        let values = values.into();
132        if values.is_empty() {
133            return Err(VectorValueError::EmptyVector);
134        }
135        if values.len() > MAX_VECTOR_DIMENSIONS {
136            return Err(VectorValueError::DimensionTooLarge);
137        }
138        if values.contains(&i16::MIN) {
139            return Err(VectorValueError::InvalidQ15Element);
140        }
141        if values.iter().all(|value| *value == 0) {
142            return Err(VectorValueError::ZeroVector);
143        }
144        Ok(Self(values))
145    }
146
147    /// Returns the signed Q15 elements.
148    pub fn as_slice(&self) -> &[i16] {
149        &self.0
150    }
151
152    /// Returns the canonical dimension.
153    pub fn dimension(&self) -> u16 {
154        u16::try_from(self.0.len()).unwrap_or(u16::MAX)
155    }
156
157    /// Consumes the vector and returns its elements.
158    pub fn into_vec(self) -> Vec<i16> {
159        self.0
160    }
161}
162
163/// Supported canonical vector metric.
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165#[repr(u8)]
166pub enum VectorMetric {
167    /// Integer cosine-nanos semantics from ADR-0015.
168    Cosine = 1,
169}
170
171/// Immutable definition of one named vector space.
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct VectorSpaceDefinition {
174    /// Canonical vector-space identifier.
175    pub name: VectorSpaceName,
176    /// Fixed vector dimension.
177    pub dimension: u16,
178    /// Canonical metric.
179    pub metric: VectorMetric,
180}
181
182impl VectorSpaceDefinition {
183    /// Constructs a cosine vector space with a fixed dimension.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error for a zero or oversized dimension.
188    pub fn cosine(name: VectorSpaceName, dimension: u16) -> Result<Self, VectorValueError> {
189        if dimension == 0 {
190            return Err(VectorValueError::EmptyVector);
191        }
192        if usize::from(dimension) > MAX_VECTOR_DIMENSIONS {
193            return Err(VectorValueError::DimensionTooLarge);
194        }
195        Ok(Self {
196            name,
197            dimension,
198            metric: VectorMetric::Cosine,
199        })
200    }
201
202    /// Checks that a vector belongs to this space.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error when dimensions differ.
207    pub fn validate_vector(&self, vector: &Q15Vector) -> Result<(), VectorValueError> {
208        if vector.dimension() == self.dimension {
209            Ok(())
210        } else {
211            Err(VectorValueError::DimensionMismatch)
212        }
213    }
214}
215
216/// Product version information that can be reported without opening a data directory.
217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
218pub struct VersionInfo {
219    /// Product name.
220    pub product: &'static str,
221    /// Cargo package version for the running binary.
222    pub engine: &'static str,
223    /// Public HTTP API version.
224    pub api: &'static str,
225    /// On-disk format version.
226    pub disk_format: u16,
227}
228
229/// Returns the version information compiled into this build.
230pub const fn current_version() -> VersionInfo {
231    VersionInfo {
232        product: PRODUCT_NAME,
233        engine: env!("CARGO_PKG_VERSION"),
234        api: API_VERSION,
235        disk_format: DISK_FORMAT_VERSION,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::{
242        API_VERSION, DISK_FORMAT_VERSION, PRODUCT_NAME, Q15Vector, VectorSpaceDefinition,
243        VectorSpaceName, VectorValueError, current_version,
244    };
245
246    #[test]
247    fn current_version_matches_public_constants() {
248        let version = current_version();
249        assert_eq!(version.product, PRODUCT_NAME);
250        assert_eq!(version.api, API_VERSION);
251        assert_eq!(version.disk_format, DISK_FORMAT_VERSION);
252        assert!(!version.engine.is_empty());
253    }
254
255    #[test]
256    fn vector_space_names_follow_the_canonical_ascii_grammar() -> Result<(), VectorValueError> {
257        let name = VectorSpaceName::new("semantic.v1")?;
258        assert_eq!(name.as_str(), "semantic.v1");
259        assert_eq!(
260            VectorSpaceName::new("1semantic"),
261            Err(VectorValueError::InvalidSpaceName)
262        );
263        assert_eq!(
264            VectorSpaceName::new("semántica"),
265            Err(VectorValueError::InvalidSpaceName)
266        );
267        Ok(())
268    }
269
270    #[test]
271    fn q15_vectors_are_nonzero_bounded_and_dimension_checked() -> Result<(), VectorValueError> {
272        let vector = Q15Vector::new(vec![32_767, 0])?;
273        let space = VectorSpaceDefinition::cosine(VectorSpaceName::new("semantic")?, 2)?;
274        assert_eq!(space.validate_vector(&vector), Ok(()));
275        assert_eq!(
276            Q15Vector::new(vec![i16::MIN]),
277            Err(VectorValueError::InvalidQ15Element)
278        );
279        assert_eq!(
280            Q15Vector::new(vec![0, 0]),
281            Err(VectorValueError::ZeroVector)
282        );
283        Ok(())
284    }
285}