1use std::{collections::BTreeSet, path::Path, time::Instant};
4
5use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName};
6use hyphae_query::{
7 ExecutionLimits, Query, QueryError, QueryResult, Record, execute, validate_query,
8};
9use hyphae_retrieval::{
10 DurableVectorRecord, ExactRetrievalError, ExactRetrievalLimits, ExactRetrievalOutcome,
11 ExactRetrievalRequest, HybridError, HybridOutcome, HybridRequest, LexicalError,
12 LexicalIndexDefinition, LexicalLimits, LexicalOutcome, LexicalRequest, RetrievalError,
13 RetrievalLimits, RetrievalOutcome, RetrievalRequest, VectorRecord, fuse_hybrid, retrieve,
14 retrieve_exact, retrieve_lexical_materialized, tokenize_v1,
15};
16use hyphae_storage::{
17 AppendOutcome, BackupError, BackupInfo, CompactionOutcome, MAX_SCAN_PAGE_ENTRIES, Mutation,
18 RestoreInfo, SnapshotInfo, StorageEngine, StorageError, StorageRecoveryReport, restore_backup,
19 verify_backup,
20};
21use thiserror::Error;
22use uuid::Uuid;
23
24use crate::{
25 DocumentError, ExactRetrievalProof, ExactRetrievalProofArtifact, HybridRetrievalProof,
26 HybridRetrievalProofArtifact, LexicalRetrievalProof, LexicalRetrievalProofArtifact, ProofError,
27 ResultProof, ResultProofArtifact, RetrievalProofError, decode_document, encode_document,
28};
29
30#[derive(Debug, Error)]
32pub enum EngineError {
33 #[error(transparent)]
35 Storage(#[from] StorageError),
36
37 #[error(transparent)]
39 Backup(#[from] BackupError),
40
41 #[error(transparent)]
43 Document(#[from] DocumentError),
44
45 #[error(transparent)]
47 Query(#[from] QueryError),
48
49 #[error(transparent)]
51 Retrieval(#[from] RetrievalError),
52
53 #[error(transparent)]
55 ExactRetrieval(#[from] ExactRetrievalError),
56
57 #[error(transparent)]
59 Proof(#[from] ProofError),
60
61 #[error(transparent)]
63 RetrievalProof(#[from] RetrievalProofError),
64
65 #[error(transparent)]
67 Lexical(#[from] LexicalError),
68
69 #[error(transparent)]
71 Hybrid(#[from] HybridError),
72
73 #[error("atomic document batch contains a duplicate key")]
75 DuplicateDocumentKey,
76
77 #[error("atomic batch must contain at least one item")]
79 EmptyBatch,
80}
81
82#[derive(Debug)]
84pub struct OpenedEngine {
85 pub engine: HyphaeEngine,
87 pub recovery: StorageRecoveryReport,
89}
90
91#[derive(Debug)]
93pub struct HyphaeEngine {
94 storage: StorageEngine,
95}
96
97impl HyphaeEngine {
98 pub fn open(path: impl AsRef<Path>) -> Result<OpenedEngine, EngineError> {
105 let opened = StorageEngine::open(path)?;
106 Ok(OpenedEngine {
107 engine: Self {
108 storage: opened.storage,
109 },
110 recovery: opened.recovery,
111 })
112 }
113
114 pub fn data_path(&self) -> &Path {
116 self.storage.data_path()
117 }
118
119 pub fn put_record(
125 &mut self,
126 transaction_id: Uuid,
127 record: &Record,
128 ) -> Result<AppendOutcome, EngineError> {
129 self.put_records(transaction_id, std::slice::from_ref(record))
130 }
131
132 pub fn put_records(
142 &mut self,
143 transaction_id: Uuid,
144 records: &[Record],
145 ) -> Result<AppendOutcome, EngineError> {
146 if records.is_empty() {
147 return Err(EngineError::EmptyBatch);
148 }
149 let mut keys = BTreeSet::new();
150 let mut mutations = Vec::with_capacity(records.len());
151 for record in records {
152 if !keys.insert(record.key.as_slice()) {
153 return Err(EngineError::DuplicateDocumentKey);
154 }
155 mutations.push(Mutation::put(
156 record.key.clone(),
157 encode_document(&record.value)?,
158 ));
159 }
160 Ok(self.storage.write(transaction_id, &mutations)?)
161 }
162
163 pub fn delete_record(
169 &mut self,
170 transaction_id: Uuid,
171 key: &[u8],
172 ) -> Result<AppendOutcome, EngineError> {
173 self.delete_records(transaction_id, &[key])
174 }
175
176 pub fn delete_records(
186 &mut self,
187 transaction_id: Uuid,
188 keys: &[&[u8]],
189 ) -> Result<AppendOutcome, EngineError> {
190 if keys.is_empty() {
191 return Err(EngineError::EmptyBatch);
192 }
193 let mut unique = BTreeSet::new();
194 let mut mutations = Vec::with_capacity(keys.len());
195 for key in keys {
196 if !unique.insert(*key) {
197 return Err(EngineError::DuplicateDocumentKey);
198 }
199 mutations.push(Mutation::delete(*key));
200 }
201 Ok(self.storage.write(transaction_id, &mutations)?)
202 }
203
204 pub fn get_record(&self, key: &[u8]) -> Result<Option<Record>, EngineError> {
210 self.storage
211 .get(key)?
212 .map(|encoded| {
213 Ok(Record {
214 key: key.to_vec(),
215 value: decode_document(&encoded)?,
216 })
217 })
218 .transpose()
219 }
220
221 pub fn get_record_with_proof(&self, key: &[u8]) -> Result<ResultProofArtifact, EngineError> {
228 let result = self.get_record(key)?;
229 let snapshot = self.snapshot()?;
230 let proof = ResultProof::for_get(&snapshot, key.to_vec(), result)?;
231 Ok(ResultProofArtifact { proof, snapshot })
232 }
233
234 pub fn query(
244 &self,
245 query: &Query,
246 limits: &ExecutionLimits,
247 ) -> Result<QueryResult, EngineError> {
248 validate_query(query, limits)?;
249 let started = Instant::now();
250 let mut records = Vec::new();
251 let mut after = None;
252 loop {
253 if started.elapsed() >= limits.timeout {
254 return Err(QueryError::TimedOut.into());
255 }
256 let loaded = u64::try_from(records.len()).unwrap_or(u64::MAX);
257 let remaining = limits.max_scanned_records.saturating_sub(loaded);
258 let remaining_entries = match usize::try_from(remaining) {
259 Ok(value) => value,
260 Err(_) => usize::MAX,
261 };
262 let page_limit = remaining_entries
263 .saturating_add(1)
264 .min(MAX_SCAN_PAGE_ENTRIES);
265 let page = self.storage.scan_page(after.as_deref(), page_limit)?;
266 for entry in page.entries {
267 if u64::try_from(records.len()).unwrap_or(u64::MAX) >= limits.max_scanned_records {
268 return Err(QueryError::ScannedBudgetExceeded {
269 maximum: limits.max_scanned_records,
270 }
271 .into());
272 }
273 records.push(Record {
274 key: entry.key,
275 value: decode_document(&entry.value)?,
276 });
277 }
278 let Some(next_after) = page.next_after else {
279 break;
280 };
281 after = Some(next_after);
282 }
283
284 let elapsed = started.elapsed();
285 let Some(timeout) = limits.timeout.checked_sub(elapsed) else {
286 return Err(QueryError::TimedOut.into());
287 };
288 if timeout.is_zero() {
289 return Err(QueryError::TimedOut.into());
290 }
291 let execution_limits = ExecutionLimits {
292 timeout,
293 ..limits.clone()
294 };
295 Ok(execute(&[records.as_slice()], query, &execution_limits)?)
296 }
297
298 pub fn query_with_proof(
306 &self,
307 query: &Query,
308 limits: &ExecutionLimits,
309 ) -> Result<ResultProofArtifact, EngineError> {
310 let result = self.query(query, limits)?;
311 let snapshot = self.snapshot()?;
312 let proof = ResultProof::for_query(&snapshot, query.clone(), result)?;
313 Ok(ResultProofArtifact { proof, snapshot })
314 }
315
316 pub fn retrieve_vectors(
323 shards: &[&[VectorRecord]],
324 request: &RetrievalRequest,
325 limits: &RetrievalLimits,
326 ) -> Result<RetrievalOutcome, EngineError> {
327 Ok(retrieve(shards, request, limits)?)
328 }
329
330 pub fn define_vector_space(
339 &mut self,
340 transaction_id: Uuid,
341 definition: VectorSpaceDefinition,
342 ) -> Result<AppendOutcome, EngineError> {
343 Ok(self
344 .storage
345 .write(transaction_id, &[Mutation::define_vector_space(definition)])?)
346 }
347
348 pub fn put_vectors(
355 &mut self,
356 transaction_id: Uuid,
357 space: &VectorSpaceName,
358 vectors: &[(Vec<u8>, Q15Vector)],
359 ) -> Result<AppendOutcome, EngineError> {
360 if vectors.is_empty() {
361 return Err(EngineError::EmptyBatch);
362 }
363 let mut keys = BTreeSet::new();
364 let mut mutations = Vec::with_capacity(vectors.len());
365 for (key, vector) in vectors {
366 if !keys.insert(key.as_slice()) {
367 return Err(EngineError::DuplicateDocumentKey);
368 }
369 mutations.push(Mutation::upsert_vector(
370 space.clone(),
371 key.clone(),
372 vector.clone(),
373 ));
374 }
375 Ok(self.storage.write(transaction_id, &mutations)?)
376 }
377
378 pub fn delete_vectors(
385 &mut self,
386 transaction_id: Uuid,
387 space: &VectorSpaceName,
388 keys: &[&[u8]],
389 ) -> Result<AppendOutcome, EngineError> {
390 if keys.is_empty() {
391 return Err(EngineError::EmptyBatch);
392 }
393 let mut unique = BTreeSet::new();
394 let mut mutations = Vec::with_capacity(keys.len());
395 for key in keys {
396 if !unique.insert(*key) {
397 return Err(EngineError::DuplicateDocumentKey);
398 }
399 mutations.push(Mutation::delete_vector(space.clone(), *key));
400 }
401 Ok(self.storage.write(transaction_id, &mutations)?)
402 }
403
404 pub fn retrieve_exact(
414 &self,
415 request: &ExactRetrievalRequest,
416 limits: &ExactRetrievalLimits,
417 ) -> Result<ExactRetrievalOutcome, EngineError> {
418 let Some(definition) = self.storage.vector_space(&request.vector_space)? else {
419 return Err(StorageError::from(
420 hyphae_storage::MaterializedIndexError::UnknownVectorSpace {
421 name: request.vector_space.as_str().to_owned(),
422 },
423 )
424 .into());
425 };
426 definition
427 .validate_vector(&request.query)
428 .map_err(|source| {
429 StorageError::from(hyphae_storage::MaterializedIndexError::from(source))
430 })?;
431 let candidates = self.storage.vector_entries(
432 &request.vector_space,
433 limits.max_candidates,
434 limits.max_candidate_bytes,
435 )?;
436 let candidates = candidates
437 .into_iter()
438 .map(|entry| DurableVectorRecord {
439 key: entry.key,
440 vector: entry.vector,
441 })
442 .collect::<Vec<_>>();
443 Ok(retrieve_exact(&candidates, request, limits)?)
444 }
445
446 pub fn retrieve_exact_with_proof(
454 &self,
455 request: &ExactRetrievalRequest,
456 limits: &ExactRetrievalLimits,
457 ) -> Result<ExactRetrievalProofArtifact, EngineError> {
458 let outcome = self.retrieve_exact(request, limits)?;
459 let snapshot = self.snapshot()?;
460 let proof = ExactRetrievalProof::new(&snapshot, request.clone(), outcome)?;
461 Ok(ExactRetrievalProofArtifact { proof, snapshot })
462 }
463
464 pub fn define_lexical_index(
473 &mut self,
474 transaction_id: Uuid,
475 definition: LexicalIndexDefinition,
476 ) -> Result<AppendOutcome, EngineError> {
477 Ok(self.storage.write(
478 transaction_id,
479 &[Mutation::define_lexical_index(definition)],
480 )?)
481 }
482
483 pub fn retrieve_lexical(
493 &self,
494 request: &LexicalRequest,
495 limits: &LexicalLimits,
496 ) -> Result<LexicalOutcome, EngineError> {
497 let Some(definition) = self.storage.lexical_index(&request.index)? else {
498 return Err(StorageError::from(
499 hyphae_storage::MaterializedIndexError::UnknownLexicalIndex {
500 name: request.index.as_str().to_owned(),
501 },
502 )
503 .into());
504 };
505 let query_tokens = tokenize_v1(&request.query)
506 .into_iter()
507 .collect::<BTreeSet<_>>()
508 .into_iter()
509 .collect::<Vec<_>>();
510 if query_tokens.is_empty() {
511 return Err(LexicalError::EmptyQuery.into());
512 }
513 let started = Instant::now();
514 let corpus = match self.storage.lexical_corpus(
515 &definition,
516 &query_tokens,
517 limits.max_candidates,
518 limits.timeout,
519 ) {
520 Ok(corpus) => corpus,
521 Err(StorageError::Index { source }) => match *source {
522 hyphae_storage::MaterializedIndexError::Lexical(error) => {
523 return Err(error.into());
524 }
525 source => {
526 return Err(StorageError::Index {
527 source: Box::new(source),
528 }
529 .into());
530 }
531 },
532 Err(error) => return Err(error.into()),
533 };
534 let Some(timeout) = limits.timeout.checked_sub(started.elapsed()) else {
535 return Err(LexicalError::TimedOut.into());
536 };
537 if timeout.is_zero() {
538 return Err(LexicalError::TimedOut.into());
539 }
540 let execution_limits = LexicalLimits {
541 timeout,
542 ..limits.clone()
543 };
544 Ok(retrieve_lexical_materialized(
545 &corpus,
546 &definition,
547 request,
548 &execution_limits,
549 )?)
550 }
551
552 pub fn retrieve_lexical_with_proof(
559 &self,
560 request: &LexicalRequest,
561 limits: &LexicalLimits,
562 ) -> Result<LexicalRetrievalProofArtifact, EngineError> {
563 let outcome = self.retrieve_lexical(request, limits)?;
564 let snapshot = self.snapshot()?;
565 let proof = LexicalRetrievalProof::new(&snapshot, request.clone(), outcome)?;
566 Ok(LexicalRetrievalProofArtifact { proof, snapshot })
567 }
568
569 pub fn retrieve_hybrid(
578 &self,
579 lexical_request: &LexicalRequest,
580 lexical_limits: &LexicalLimits,
581 vector_request: &ExactRetrievalRequest,
582 vector_limits: &ExactRetrievalLimits,
583 hybrid_request: &HybridRequest,
584 ) -> Result<HybridOutcome, EngineError> {
585 let lexical = self.retrieve_lexical(lexical_request, lexical_limits)?;
586 let vector = self.retrieve_exact(vector_request, vector_limits)?;
587 Ok(fuse_hybrid(&lexical, &vector, hybrid_request)?)
588 }
589
590 pub fn retrieve_hybrid_with_proof(
597 &self,
598 lexical_request: &LexicalRequest,
599 lexical_limits: &LexicalLimits,
600 vector_request: &ExactRetrievalRequest,
601 vector_limits: &ExactRetrievalLimits,
602 hybrid_request: &HybridRequest,
603 ) -> Result<HybridRetrievalProofArtifact, EngineError> {
604 let lexical_outcome = self.retrieve_lexical(lexical_request, lexical_limits)?;
605 let vector_outcome = self.retrieve_exact(vector_request, vector_limits)?;
606 let outcome = fuse_hybrid(&lexical_outcome, &vector_outcome, hybrid_request)?;
607 let snapshot = self.snapshot()?;
608 let proof = HybridRetrievalProof::new(
609 &snapshot,
610 lexical_request.clone(),
611 lexical_outcome,
612 vector_request.clone(),
613 vector_outcome,
614 hybrid_request.clone(),
615 outcome,
616 )?;
617 Ok(HybridRetrievalProofArtifact { proof, snapshot })
618 }
619
620 pub fn snapshot(&self) -> Result<SnapshotInfo, EngineError> {
626 Ok(self.storage.snapshot()?)
627 }
628
629 pub fn compact(&mut self) -> Result<CompactionOutcome, EngineError> {
635 Ok(self.storage.compact()?)
636 }
637
638 pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
644 Ok(self.storage.backup(destination)?)
645 }
646
647 pub fn verify_backup(path: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
654 Ok(verify_backup(path)?)
655 }
656
657 pub fn restore_backup(
664 backup: impl AsRef<Path>,
665 destination: impl AsRef<Path>,
666 ) -> Result<RestoreInfo, EngineError> {
667 Ok(restore_backup(backup, destination)?)
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use std::{collections::BTreeMap, fs, path::PathBuf, time::Duration};
674
675 use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName};
676 use hyphae_query::{
677 AggregationPlan, CompareOperator, FieldPath, Filter, Metric, MetricValue, NamedMetric,
678 NullPlacement, SortDirection, SortField, Value,
679 };
680 use uuid::Uuid;
681
682 use hyphae_retrieval::{
683 ExactRetrievalLimits, ExactRetrievalOutcome, ExactRetrievalRequest, HybridOutcome,
684 HybridRequest, LexicalField, LexicalIndexDefinition, LexicalLimits, LexicalOutcome,
685 LexicalRequest, retrieve_lexical,
686 };
687
688 use super::{EngineError, ExecutionLimits, HyphaeEngine, Query, Record};
689
690 struct TestDirectory {
691 path: PathBuf,
692 }
693
694 impl TestDirectory {
695 fn new(name: &str) -> std::io::Result<Self> {
696 let path = std::env::temp_dir().join(format!(
697 "hyphae-engine-{name}-{}-{}",
698 std::process::id(),
699 Uuid::now_v7()
700 ));
701 fs::create_dir_all(&path)?;
702 Ok(Self { path })
703 }
704
705 fn path(&self) -> &std::path::Path {
706 &self.path
707 }
708 }
709
710 impl Drop for TestDirectory {
711 fn drop(&mut self) {
712 let _ignored = fs::remove_dir_all(&self.path);
713 }
714 }
715
716 fn value(score: i64, group: &str) -> Value {
717 Value::Object(BTreeMap::from([
718 ("group".to_owned(), Value::String(group.to_owned())),
719 ("score".to_owned(), Value::Integer(score)),
720 ]))
721 }
722
723 #[test]
724 fn durable_documents_query_identically_after_compaction_and_reopen()
725 -> Result<(), Box<dyn std::error::Error>> {
726 let temporary = TestDirectory::new("engine-query-reopen")?;
727 let root = temporary.path().join("data");
728 let mut opened = HyphaeEngine::open(&root)?;
729 opened.engine.put_records(
730 Uuid::now_v7(),
731 &[
732 Record::new(b"a", value(10, "x")),
733 Record::new(b"b", value(8, "x")),
734 Record::new(b"c", value(7, "y")),
735 Record::new(b"d", value(2, "y")),
736 ],
737 )?;
738 let request = Query {
739 filter: Filter::Compare {
740 path: FieldPath::field("score"),
741 operator: CompareOperator::GreaterOrEqual,
742 value: Value::Integer(7),
743 },
744 sort: vec![SortField {
745 path: FieldPath::field("score"),
746 direction: SortDirection::Descending,
747 nulls: NullPlacement::Last,
748 }],
749 cursor: None,
750 limit: 2,
751 aggregation: Some(AggregationPlan {
752 group_by: Vec::new(),
753 metrics: vec![NamedMetric {
754 name: "count".to_owned(),
755 metric: Metric::Count,
756 }],
757 }),
758 };
759 let before = opened.engine.query(&request, &ExecutionLimits::default())?;
760 assert_eq!(before.rows.len(), 2);
761 assert_eq!(
762 before
763 .aggregation
764 .as_ref()
765 .map(|aggregation| { aggregation.groups[0].metrics[0].value.clone() }),
766 Some(MetricValue::Count(3))
767 );
768 opened.engine.compact()?;
769 drop(opened);
770
771 let reopened = HyphaeEngine::open(&root)?;
772 let after = reopened
773 .engine
774 .query(&request, &ExecutionLimits::default())?;
775 assert_eq!(before, after);
776 assert_eq!(
777 reopened.engine.get_record(b"a")?.map(|record| record.value),
778 Some(value(10, "x"))
779 );
780 Ok(())
781 }
782
783 #[test]
784 fn facade_enforces_scan_budget_before_building_a_partial_page()
785 -> Result<(), Box<dyn std::error::Error>> {
786 let temporary = TestDirectory::new("engine-query-budget")?;
787 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
788 opened.engine.put_records(
789 Uuid::now_v7(),
790 &[
791 Record::new(b"a", Value::Null),
792 Record::new(b"b", Value::Null),
793 ],
794 )?;
795 let limits = ExecutionLimits {
796 max_scanned_records: 1,
797 ..ExecutionLimits::default()
798 };
799 let result = opened.engine.query(
800 &Query {
801 filter: Filter::MatchAll,
802 sort: Vec::new(),
803 cursor: None,
804 limit: 1,
805 aggregation: None,
806 },
807 &limits,
808 );
809 assert!(matches!(
810 result,
811 Err(EngineError::Query(
812 hyphae_query::QueryError::ScannedBudgetExceeded { maximum: 1 }
813 ))
814 ));
815 Ok(())
816 }
817
818 #[test]
819 fn durable_vectors_survive_compaction_backup_restore_and_index_rebuild()
820 -> Result<(), Box<dyn std::error::Error>> {
821 let temporary = TestDirectory::new("durable-vectors-lifecycle")?;
822 let root = temporary.path().join("data");
823 let backup = temporary.path().join("backup");
824 let restored = temporary.path().join("restored");
825 let space = VectorSpaceName::new("semantic.v1")?;
826 let definition = VectorSpaceDefinition::cosine(space.clone(), 3)?;
827 let mut opened = HyphaeEngine::open(&root)?;
828 opened
829 .engine
830 .define_vector_space(Uuid::now_v7(), definition.clone())?;
831 opened.engine.put_vectors(
832 Uuid::now_v7(),
833 &space,
834 &[
835 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0, 0])?),
836 (b"beta".to_vec(), Q15Vector::new(vec![0, 32_767, 0])?),
837 ],
838 )?;
839 let request = ExactRetrievalRequest {
840 vector_space: space.clone(),
841 query: Q15Vector::new(vec![32_767, 0, 0])?,
842 limit: 2,
843 minimum_score_nanos: -1_000_000_000,
844 minimum_margin_nanos: 0,
845 };
846 let limits = ExactRetrievalLimits {
847 max_candidates: 10,
848 max_candidate_bytes: 64 * 1024,
849 max_returned: 10,
850 timeout: Duration::from_secs(1),
851 };
852 let expected = opened.engine.retrieve_exact(&request, &limits)?;
853 assert!(matches!(
854 &expected,
855 ExactRetrievalOutcome::Matches { matches, .. }
856 if matches.first().is_some_and(|matched| matched.key == b"alpha")
857 ));
858 opened.engine.compact()?;
859 assert_eq!(opened.engine.retrieve_exact(&request, &limits)?, expected);
860 opened.engine.backup(&backup)?;
861 drop(opened);
862
863 let reopened = HyphaeEngine::open(&root)?;
864 assert_eq!(reopened.engine.retrieve_exact(&request, &limits)?, expected);
865 drop(reopened);
866 fs::remove_file(root.join("indexes/primary.redb"))?;
867 let rebuilt = HyphaeEngine::open(&root)?;
868 assert_eq!(rebuilt.engine.retrieve_exact(&request, &limits)?, expected);
869 drop(rebuilt);
870
871 HyphaeEngine::restore_backup(&backup, &restored)?;
872 let restored = HyphaeEngine::open(&restored)?;
873 assert_eq!(restored.engine.retrieve_exact(&request, &limits)?, expected);
874 Ok(())
875 }
876
877 #[test]
878 fn mixed_validity_vector_batch_is_rejected_without_partial_visibility()
879 -> Result<(), Box<dyn std::error::Error>> {
880 let temporary = TestDirectory::new("vector-batch-rollback")?;
881 let root = temporary.path().join("data");
882 let space = VectorSpaceName::new("semantic")?;
883 let mut opened = HyphaeEngine::open(&root)?;
884 opened.engine.define_vector_space(
885 Uuid::now_v7(),
886 VectorSpaceDefinition::cosine(space.clone(), 2)?,
887 )?;
888 let result = opened.engine.put_vectors(
889 Uuid::now_v7(),
890 &space,
891 &[
892 (b"valid".to_vec(), Q15Vector::new(vec![32_767, 0])?),
893 (b"wrong".to_vec(), Q15Vector::new(vec![32_767, 0, 0])?),
894 ],
895 );
896 assert!(result.is_err());
897 let request = ExactRetrievalRequest {
898 vector_space: space,
899 query: Q15Vector::new(vec![32_767, 0])?,
900 limit: 10,
901 minimum_score_nanos: -1_000_000_000,
902 minimum_margin_nanos: 0,
903 };
904 assert!(matches!(
905 opened
906 .engine
907 .retrieve_exact(&request, &ExactRetrievalLimits::default())?,
908 ExactRetrievalOutcome::Abstained(_)
909 ));
910 Ok(())
911 }
912
913 fn lexical_value(title: &str, body: &str) -> Value {
914 Value::Object(BTreeMap::from([
915 ("body".to_owned(), Value::String(body.to_owned())),
916 ("title".to_owned(), Value::String(title.to_owned())),
917 ]))
918 }
919
920 #[test]
921 #[allow(clippy::too_many_lines)]
922 fn lexical_and_hybrid_retrieval_survive_every_durable_lifecycle()
923 -> Result<(), Box<dyn std::error::Error>> {
924 let temporary = TestDirectory::new("lexical-hybrid-lifecycle")?;
925 let root = temporary.path().join("data");
926 let backup = temporary.path().join("backup");
927 let restored = temporary.path().join("restored");
928 let name = VectorSpaceName::new("documents.v1")?;
929 let lexical_definition = LexicalIndexDefinition::new(
930 name.clone(),
931 vec![
932 LexicalField {
933 path: FieldPath::field("body"),
934 weight_micros: 1_000_000,
935 },
936 LexicalField {
937 path: FieldPath::field("title"),
938 weight_micros: 2_000_000,
939 },
940 ],
941 )?;
942 let vector_definition = VectorSpaceDefinition::cosine(name.clone(), 2)?;
943 let mut opened = HyphaeEngine::open(&root)?;
944 opened.engine.put_records(
945 Uuid::now_v7(),
946 &[
947 Record::new(b"alpha", lexical_value("Durable Rust", "offline engine")),
948 Record::new(b"beta", lexical_value("Other", "durable storage")),
949 Record::new(b"gamma", lexical_value("Unrelated", "nothing")),
950 ],
951 )?;
952 opened
953 .engine
954 .define_lexical_index(Uuid::now_v7(), lexical_definition)?;
955 opened
956 .engine
957 .define_vector_space(Uuid::now_v7(), vector_definition)?;
958 opened.engine.put_vectors(
959 Uuid::now_v7(),
960 &name,
961 &[
962 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0])?),
963 (b"beta".to_vec(), Q15Vector::new(vec![30_000, 2_000])?),
964 (b"gamma".to_vec(), Q15Vector::new(vec![0, 32_767])?),
965 ],
966 )?;
967 let lexical_request = LexicalRequest {
968 index: name.clone(),
969 query: "durable".into(),
970 limit: 3,
971 };
972 let lexical_limits = LexicalLimits {
973 max_documents: 10,
974 max_tokens: 100,
975 max_candidates: 10,
976 max_returned: 10,
977 timeout: Duration::from_secs(2),
978 };
979 let vector_request = ExactRetrievalRequest {
980 vector_space: name,
981 query: Q15Vector::new(vec![32_767, 0])?,
982 limit: 3,
983 minimum_score_nanos: -1_000_000_000,
984 minimum_margin_nanos: 0,
985 };
986 let vector_limits = ExactRetrievalLimits {
987 max_candidates: 10,
988 max_candidate_bytes: 64 * 1024,
989 max_returned: 10,
990 timeout: Duration::from_secs(2),
991 };
992 let hybrid_request = HybridRequest {
993 lexical_weight: 1,
994 vector_weight: 1,
995 limit: 3,
996 };
997 let expected_lexical = opened
998 .engine
999 .retrieve_lexical(&lexical_request, &lexical_limits)?;
1000 assert!(matches!(
1001 &expected_lexical,
1002 LexicalOutcome::Matches { matches, .. }
1003 if matches.first().is_some_and(|matched| matched.key == b"alpha")
1004 ));
1005 let expected_hybrid = opened.engine.retrieve_hybrid(
1006 &lexical_request,
1007 &lexical_limits,
1008 &vector_request,
1009 &vector_limits,
1010 &hybrid_request,
1011 )?;
1012 assert!(matches!(
1013 &expected_hybrid,
1014 HybridOutcome::Matches { matches, .. }
1015 if matches.first().is_some_and(|matched| matched.key == b"alpha")
1016 ));
1017 opened.engine.compact()?;
1018 assert_eq!(
1019 opened
1020 .engine
1021 .retrieve_lexical(&lexical_request, &lexical_limits)?,
1022 expected_lexical
1023 );
1024 opened.engine.backup(&backup)?;
1025 drop(opened);
1026
1027 let reopened = HyphaeEngine::open(&root)?;
1028 assert_eq!(
1029 reopened.engine.retrieve_hybrid(
1030 &lexical_request,
1031 &lexical_limits,
1032 &vector_request,
1033 &vector_limits,
1034 &hybrid_request,
1035 )?,
1036 expected_hybrid
1037 );
1038 drop(reopened);
1039 fs::remove_file(root.join("indexes/primary.redb"))?;
1040 let rebuilt = HyphaeEngine::open(&root)?;
1041 assert_eq!(
1042 rebuilt
1043 .engine
1044 .retrieve_lexical(&lexical_request, &lexical_limits)?,
1045 expected_lexical
1046 );
1047 drop(rebuilt);
1048
1049 HyphaeEngine::restore_backup(&backup, &restored)?;
1050 let restored = HyphaeEngine::open(&restored)?;
1051 assert_eq!(
1052 restored.engine.retrieve_hybrid(
1053 &lexical_request,
1054 &lexical_limits,
1055 &vector_request,
1056 &vector_limits,
1057 &hybrid_request,
1058 )?,
1059 expected_hybrid
1060 );
1061 assert_eq!(restored.engine.snapshot()?.lexical_index_count, 1);
1062 Ok(())
1063 }
1064
1065 #[test]
1066 fn lexical_document_budget_returns_no_partial_ranking() -> Result<(), Box<dyn std::error::Error>>
1067 {
1068 let temporary = TestDirectory::new("lexical-budget")?;
1069 let name = VectorSpaceName::new("documents")?;
1070 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1071 opened.engine.put_records(
1072 Uuid::now_v7(),
1073 &[
1074 Record::new(b"a", lexical_value("one", "durable")),
1075 Record::new(b"b", lexical_value("two", "durable")),
1076 ],
1077 )?;
1078 opened.engine.define_lexical_index(
1079 Uuid::now_v7(),
1080 LexicalIndexDefinition::new(
1081 name.clone(),
1082 vec![LexicalField {
1083 path: FieldPath::field("body"),
1084 weight_micros: 1_000_000,
1085 }],
1086 )?,
1087 )?;
1088 let outcome = opened.engine.retrieve_lexical(
1089 &LexicalRequest {
1090 index: name,
1091 query: "durable".into(),
1092 limit: 2,
1093 },
1094 &LexicalLimits {
1095 max_documents: 1,
1096 ..LexicalLimits::default()
1097 },
1098 );
1099 assert!(matches!(
1100 outcome,
1101 Err(EngineError::Lexical(
1102 hyphae_retrieval::LexicalError::DocumentBudgetExceeded { maximum: 1 }
1103 ))
1104 ));
1105 Ok(())
1106 }
1107
1108 #[test]
1109 fn lexical_materialization_timeout_returns_typed_timeout()
1110 -> Result<(), Box<dyn std::error::Error>> {
1111 let temporary = TestDirectory::new("lexical-timeout")?;
1112 let name = VectorSpaceName::new("documents.timeout")?;
1113 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1114 opened.engine.put_record(
1115 Uuid::now_v7(),
1116 &Record::new(b"a", lexical_value("one", "durable")),
1117 )?;
1118 opened.engine.define_lexical_index(
1119 Uuid::now_v7(),
1120 LexicalIndexDefinition::new(
1121 name.clone(),
1122 vec![LexicalField {
1123 path: FieldPath::field("body"),
1124 weight_micros: 1_000_000,
1125 }],
1126 )?,
1127 )?;
1128
1129 let outcome = opened.engine.retrieve_lexical(
1130 &LexicalRequest {
1131 index: name,
1132 query: "durable".into(),
1133 limit: 1,
1134 },
1135 &LexicalLimits {
1136 timeout: Duration::ZERO,
1137 ..LexicalLimits::default()
1138 },
1139 );
1140
1141 assert!(matches!(
1142 outcome,
1143 Err(EngineError::Lexical(
1144 hyphae_retrieval::LexicalError::TimedOut
1145 ))
1146 ));
1147 Ok(())
1148 }
1149
1150 #[test]
1151 fn materialized_lexical_index_matches_reference_after_update_delete_and_rebuild()
1152 -> Result<(), Box<dyn std::error::Error>> {
1153 let temporary = TestDirectory::new("lexical-reference-equivalence")?;
1154 let root = temporary.path().join("data");
1155 let name = VectorSpaceName::new("documents.reference")?;
1156 let definition = LexicalIndexDefinition::new(
1157 name.clone(),
1158 vec![
1159 LexicalField {
1160 path: FieldPath::field("body"),
1161 weight_micros: 1_000_000,
1162 },
1163 LexicalField {
1164 path: FieldPath::field("title"),
1165 weight_micros: 2_000_000,
1166 },
1167 ],
1168 )?;
1169 let request = LexicalRequest {
1170 index: name,
1171 query: "durable rust engine".into(),
1172 limit: 10,
1173 };
1174 let limits = LexicalLimits {
1175 max_documents: 100,
1176 max_tokens: 10_000,
1177 max_candidates: 100,
1178 max_returned: 100,
1179 timeout: Duration::from_secs(2),
1180 };
1181 let mut records = vec![
1182 Record::new(
1183 b"alpha",
1184 lexical_value("Durable Rust", "offline engine durable durable"),
1185 ),
1186 Record::new(
1187 b"beta",
1188 lexical_value("Storage Engine", "rust transactions"),
1189 ),
1190 Record::new(b"gamma", lexical_value("Unrelated", "nothing relevant")),
1191 Record::new(
1192 b"delta",
1193 lexical_value("Rust Engine", "durable local search"),
1194 ),
1195 ];
1196 let mut opened = HyphaeEngine::open(&root)?;
1197 opened.engine.put_records(Uuid::now_v7(), &records)?;
1198 opened
1199 .engine
1200 .define_lexical_index(Uuid::now_v7(), definition.clone())?;
1201
1202 let reference = retrieve_lexical(&records, &definition, &request, &limits)?;
1203 assert_eq!(
1204 opened.engine.retrieve_lexical(&request, &limits)?,
1205 reference
1206 );
1207
1208 let updated = Record::new(
1209 b"gamma",
1210 lexical_value("Durable Engine", "rust rust offline"),
1211 );
1212 opened.engine.put_record(Uuid::now_v7(), &updated)?;
1213 records.retain(|record| record.key != b"gamma");
1214 records.push(updated);
1215 opened.engine.delete_record(Uuid::now_v7(), b"beta")?;
1216 records.retain(|record| record.key != b"beta");
1217
1218 let updated_reference = retrieve_lexical(&records, &definition, &request, &limits)?;
1219 assert_eq!(
1220 opened.engine.retrieve_lexical(&request, &limits)?,
1221 updated_reference
1222 );
1223 drop(opened);
1224
1225 fs::remove_file(root.join("indexes/primary.redb"))?;
1226 let rebuilt = HyphaeEngine::open(&root)?;
1227 assert_eq!(
1228 rebuilt.engine.retrieve_lexical(&request, &limits)?,
1229 updated_reference
1230 );
1231 Ok(())
1232 }
1233}