1use std::{
4 collections::BTreeSet,
5 path::Path,
6 time::{Duration, Instant},
7};
8
9use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName};
10use hyphae_query::{
11 BoundedQueryError, ExecutionLimits, Query, QueryError, QueryResult, Record, execute,
12 execute_with_byte_limit, validate_query,
13};
14use hyphae_retrieval::{
15 DurableVectorRecord, ExactRetrievalError, ExactRetrievalLimits, ExactRetrievalOutcome,
16 ExactRetrievalRequest, HybridError, HybridOutcome, HybridRequest, LexicalError,
17 LexicalIndexDefinition, LexicalLimits, LexicalOutcome, LexicalRequest, RetrievalError,
18 RetrievalLimits, RetrievalOutcome, RetrievalRequest, VectorRecord, fuse_hybrid, retrieve,
19 retrieve_exact, retrieve_lexical_materialized, tokenize_v1_checked,
20};
21use hyphae_storage::{
22 AppendOutcome, BackupError, BackupInfo, CompactionOutcome, MAX_SCAN_PAGE_ENTRIES,
23 MaintenanceLimits, Mutation, RestoreInfo, ScanPageError, SnapshotError, SnapshotInfo,
24 StorageEngine, StorageError, StorageLimitError, StorageLimits, StorageRecoveryReport,
25 VectorEntriesError, restore_backup, verify_backup,
26};
27use thiserror::Error;
28use uuid::Uuid;
29
30use crate::{
31 DocumentError, ExactRetrievalProof, ExactRetrievalProofArtifact, HybridRetrievalProof,
32 HybridRetrievalProofArtifact, LexicalRetrievalProof, LexicalRetrievalProofArtifact, ProofError,
33 ResultProof, ResultProofArtifact, RetrievalProofError, decode_document, encode_document,
34};
35
36#[derive(Debug, Error)]
38pub enum EngineError {
39 #[error(transparent)]
41 Storage(#[from] StorageError),
42
43 #[error(transparent)]
45 Backup(#[from] BackupError),
46
47 #[error(transparent)]
49 Document(#[from] DocumentError),
50
51 #[error(transparent)]
53 Query(#[from] QueryError),
54
55 #[error(transparent)]
57 Retrieval(#[from] RetrievalError),
58
59 #[error(transparent)]
61 ExactRetrieval(#[from] ExactRetrievalError),
62
63 #[error(transparent)]
65 Proof(#[from] ProofError),
66
67 #[error(transparent)]
69 RetrievalProof(#[from] RetrievalProofError),
70
71 #[error(transparent)]
73 Lexical(#[from] LexicalError),
74
75 #[error(transparent)]
77 Hybrid(#[from] HybridError),
78
79 #[error("atomic document batch contains a duplicate key")]
81 DuplicateDocumentKey,
82
83 #[error("atomic batch must contain at least one item")]
85 EmptyBatch,
86}
87
88#[derive(Debug, Error)]
91pub enum BoundedEngineQueryError {
92 #[error(transparent)]
94 Engine(#[from] EngineError),
95 #[error("global scanned-byte budget exceeded: {maximum}")]
97 ScannedByteBudgetExceeded {
98 maximum: u64,
100 },
101}
102
103impl From<BoundedQueryError> for BoundedEngineQueryError {
104 fn from(source: BoundedQueryError) -> Self {
105 match source {
106 BoundedQueryError::Query(source) => Self::Engine(EngineError::Query(source)),
107 BoundedQueryError::RecordDocument(source) => {
108 Self::Engine(EngineError::Document(source))
109 }
110 BoundedQueryError::ScannedByteBudgetExceeded { maximum } => {
111 Self::ScannedByteBudgetExceeded { maximum }
112 }
113 }
114 }
115}
116
117#[derive(Debug)]
119pub struct OpenedEngine {
120 pub engine: HyphaeEngine,
122 pub recovery: StorageRecoveryReport,
124}
125
126#[derive(Debug)]
127struct HybridExecution {
128 started: Instant,
129 total_timeout: Duration,
130 lexical: LexicalOutcome,
131 vector: ExactRetrievalOutcome,
132 outcome: HybridOutcome,
133}
134
135#[derive(Debug)]
137pub struct HyphaeEngine {
138 storage: StorageEngine,
139}
140
141impl HyphaeEngine {
142 pub fn open(path: impl AsRef<Path>) -> Result<OpenedEngine, EngineError> {
149 let opened = StorageEngine::open(path)?;
150 Ok(OpenedEngine {
151 engine: Self {
152 storage: opened.storage,
153 },
154 recovery: opened.recovery,
155 })
156 }
157
158 pub fn open_with_limits(
166 path: impl AsRef<Path>,
167 limits: StorageLimits,
168 ) -> Result<OpenedEngine, EngineError> {
169 let opened = StorageEngine::open_with_limits(path, limits)?;
170 Ok(OpenedEngine {
171 engine: Self {
172 storage: opened.storage,
173 },
174 recovery: opened.recovery,
175 })
176 }
177
178 pub fn data_path(&self) -> &Path {
180 self.storage.data_path()
181 }
182
183 pub fn put_record(
189 &mut self,
190 transaction_id: Uuid,
191 record: &Record,
192 ) -> Result<AppendOutcome, EngineError> {
193 self.put_records(transaction_id, std::slice::from_ref(record))
194 }
195
196 pub fn put_records(
206 &mut self,
207 transaction_id: Uuid,
208 records: &[Record],
209 ) -> Result<AppendOutcome, EngineError> {
210 if records.is_empty() {
211 return Err(EngineError::EmptyBatch);
212 }
213 let mut keys = BTreeSet::new();
214 let mut mutations = Vec::with_capacity(records.len());
215 for record in records {
216 if !keys.insert(record.key.as_slice()) {
217 return Err(EngineError::DuplicateDocumentKey);
218 }
219 mutations.push(Mutation::put(
220 record.key.clone(),
221 encode_document(&record.value)?,
222 ));
223 }
224 Ok(self.storage.write(transaction_id, &mutations)?)
225 }
226
227 pub fn delete_record(
233 &mut self,
234 transaction_id: Uuid,
235 key: &[u8],
236 ) -> Result<AppendOutcome, EngineError> {
237 self.delete_records(transaction_id, &[key])
238 }
239
240 pub fn delete_records(
250 &mut self,
251 transaction_id: Uuid,
252 keys: &[&[u8]],
253 ) -> Result<AppendOutcome, EngineError> {
254 if keys.is_empty() {
255 return Err(EngineError::EmptyBatch);
256 }
257 let mut unique = BTreeSet::new();
258 let mut mutations = Vec::with_capacity(keys.len());
259 for key in keys {
260 if !unique.insert(*key) {
261 return Err(EngineError::DuplicateDocumentKey);
262 }
263 mutations.push(Mutation::delete(*key));
264 }
265 Ok(self.storage.write(transaction_id, &mutations)?)
266 }
267
268 pub fn get_record(&self, key: &[u8]) -> Result<Option<Record>, EngineError> {
274 self.storage
275 .get(key)?
276 .map(|encoded| {
277 Ok(Record {
278 key: key.to_vec(),
279 value: decode_document(&encoded)?,
280 })
281 })
282 .transpose()
283 }
284
285 pub fn get_record_with_proof(&self, key: &[u8]) -> Result<ResultProofArtifact, EngineError> {
292 let result = self.get_record(key)?;
293 let snapshot = self.snapshot()?;
294 let proof = ResultProof::for_get(&snapshot, key.to_vec(), result)?;
295 Ok(ResultProofArtifact { proof, snapshot })
296 }
297
298 pub fn get_record_with_proof_with_limits(
306 &self,
307 key: &[u8],
308 maintenance: &MaintenanceLimits,
309 ) -> Result<ResultProofArtifact, EngineError> {
310 let started = Instant::now();
311 let total_timeout = maintenance.timeout;
312 let result = self.get_record(key)?;
313 let maintenance = remaining_maintenance_limits(maintenance, started, total_timeout)?;
314 let snapshot = self.snapshot_with_limits(&maintenance)?;
315 let proof = ResultProof::for_get(&snapshot, key.to_vec(), result)?;
316 ensure_total_timeout(started, total_timeout)?;
317 Ok(ResultProofArtifact { proof, snapshot })
318 }
319
320 pub fn query(
330 &self,
331 query: &Query,
332 limits: &ExecutionLimits,
333 ) -> Result<QueryResult, EngineError> {
334 match self.query_internal(query, limits, None) {
335 Ok(result) => Ok(result),
336 Err(BoundedEngineQueryError::Engine(source)) => Err(source),
337 Err(BoundedEngineQueryError::ScannedByteBudgetExceeded { .. }) => {
338 unreachable!("legacy query execution does not enforce an aggregate byte limit")
339 }
340 }
341 }
342
343 pub fn query_with_byte_limit(
355 &self,
356 query: &Query,
357 limits: &ExecutionLimits,
358 max_scanned_bytes: u64,
359 ) -> Result<QueryResult, BoundedEngineQueryError> {
360 self.query_internal(query, limits, Some(max_scanned_bytes))
361 }
362
363 fn query_internal(
364 &self,
365 query: &Query,
366 limits: &ExecutionLimits,
367 max_scanned_bytes: Option<u64>,
368 ) -> Result<QueryResult, BoundedEngineQueryError> {
369 validate_query(query, limits).map_err(EngineError::from)?;
370 let started = Instant::now();
371 let mut records = Vec::new();
372 let mut after = None;
373 let mut scanned_bytes = 0_u64;
374 loop {
375 if started.elapsed() >= limits.timeout {
376 return Err(EngineError::from(QueryError::TimedOut).into());
377 }
378 let loaded = u64::try_from(records.len()).unwrap_or(u64::MAX);
379 let remaining = limits.max_scanned_records.saturating_sub(loaded);
380 let remaining_entries = match usize::try_from(remaining) {
381 Ok(value) => value,
382 Err(_) => usize::MAX,
383 };
384 let page_limit = remaining_entries
385 .saturating_add(1)
386 .min(MAX_SCAN_PAGE_ENTRIES);
387 let page = if let Some(maximum) = max_scanned_bytes {
388 let remaining_bytes = maximum.saturating_sub(scanned_bytes);
389 match self.storage.scan_page_with_byte_limit(
390 after.as_deref(),
391 page_limit,
392 remaining_bytes,
393 ) {
394 Err(ScanPageError::ByteBudgetExceeded { .. }) => {
395 return Err(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum });
396 }
397 Err(ScanPageError::Storage(source)) => {
398 return Err(EngineError::from(source).into());
399 }
400 Ok(page) => page,
401 }
402 } else {
403 self.storage
404 .scan_page(after.as_deref(), page_limit)
405 .map_err(EngineError::from)?
406 };
407 for entry in page.entries {
408 if u64::try_from(records.len()).unwrap_or(u64::MAX) >= limits.max_scanned_records {
409 return Err(EngineError::from(QueryError::ScannedBudgetExceeded {
410 maximum: limits.max_scanned_records,
411 })
412 .into());
413 }
414 if let Some(maximum) = max_scanned_bytes {
415 let entry_bytes = u64::try_from(entry.key.len())
416 .ok()
417 .and_then(|key_bytes| {
418 u64::try_from(entry.value.len())
419 .ok()
420 .and_then(|value_bytes| key_bytes.checked_add(value_bytes))
421 })
422 .ok_or(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum })?;
423 scanned_bytes = scanned_bytes
424 .checked_add(entry_bytes)
425 .ok_or(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum })?;
426 if scanned_bytes > maximum {
427 return Err(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum });
428 }
429 }
430 records.push(Record {
431 key: entry.key,
432 value: decode_document(&entry.value).map_err(EngineError::from)?,
433 });
434 }
435 let Some(next_after) = page.next_after else {
436 break;
437 };
438 after = Some(next_after);
439 }
440
441 let elapsed = started.elapsed();
442 let Some(timeout) = limits.timeout.checked_sub(elapsed) else {
443 return Err(EngineError::from(QueryError::TimedOut).into());
444 };
445 if timeout.is_zero() {
446 return Err(EngineError::from(QueryError::TimedOut).into());
447 }
448 let execution_limits = ExecutionLimits {
449 timeout,
450 ..limits.clone()
451 };
452 match max_scanned_bytes {
453 Some(maximum) => {
454 execute_with_byte_limit(&[records.as_slice()], query, &execution_limits, maximum)
455 .map_err(BoundedEngineQueryError::from)
456 }
457 None => execute(&[records.as_slice()], query, &execution_limits)
458 .map_err(EngineError::from)
459 .map_err(BoundedEngineQueryError::from),
460 }
461 }
462
463 pub fn query_with_proof(
471 &self,
472 query: &Query,
473 limits: &ExecutionLimits,
474 ) -> Result<ResultProofArtifact, EngineError> {
475 let result = self.query(query, limits)?;
476 let snapshot = self.snapshot()?;
477 let proof = ResultProof::for_query(&snapshot, query.clone(), result)?;
478 Ok(ResultProofArtifact { proof, snapshot })
479 }
480
481 pub fn query_with_proof_with_limits(
489 &self,
490 query: &Query,
491 limits: &ExecutionLimits,
492 max_scanned_bytes: u64,
493 maintenance: &MaintenanceLimits,
494 ) -> Result<ResultProofArtifact, BoundedEngineQueryError> {
495 let started = Instant::now();
496 let result = self.query_with_byte_limit(query, limits, max_scanned_bytes)?;
497 let maintenance = remaining_maintenance_limits(maintenance, started, limits.timeout)
498 .map_err(BoundedEngineQueryError::from)?;
499 let snapshot = self
500 .snapshot_with_limits(&maintenance)
501 .map_err(BoundedEngineQueryError::from)?;
502 let proof = ResultProof::for_query(&snapshot, query.clone(), result)
503 .map_err(EngineError::from)
504 .map_err(BoundedEngineQueryError::from)?;
505 ensure_total_timeout(started, limits.timeout).map_err(BoundedEngineQueryError::from)?;
506 Ok(ResultProofArtifact { proof, snapshot })
507 }
508
509 pub fn retrieve_vectors(
516 shards: &[&[VectorRecord]],
517 request: &RetrievalRequest,
518 limits: &RetrievalLimits,
519 ) -> Result<RetrievalOutcome, EngineError> {
520 Ok(retrieve(shards, request, limits)?)
521 }
522
523 pub fn define_vector_space(
532 &mut self,
533 transaction_id: Uuid,
534 definition: VectorSpaceDefinition,
535 ) -> Result<AppendOutcome, EngineError> {
536 Ok(self
537 .storage
538 .write(transaction_id, &[Mutation::define_vector_space(definition)])?)
539 }
540
541 pub fn put_vectors(
548 &mut self,
549 transaction_id: Uuid,
550 space: &VectorSpaceName,
551 vectors: &[(Vec<u8>, Q15Vector)],
552 ) -> Result<AppendOutcome, EngineError> {
553 if vectors.is_empty() {
554 return Err(EngineError::EmptyBatch);
555 }
556 let mut keys = BTreeSet::new();
557 let mut mutations = Vec::with_capacity(vectors.len());
558 for (key, vector) in vectors {
559 if !keys.insert(key.as_slice()) {
560 return Err(EngineError::DuplicateDocumentKey);
561 }
562 mutations.push(Mutation::upsert_vector(
563 space.clone(),
564 key.clone(),
565 vector.clone(),
566 ));
567 }
568 Ok(self.storage.write(transaction_id, &mutations)?)
569 }
570
571 pub fn delete_vectors(
578 &mut self,
579 transaction_id: Uuid,
580 space: &VectorSpaceName,
581 keys: &[&[u8]],
582 ) -> Result<AppendOutcome, EngineError> {
583 if keys.is_empty() {
584 return Err(EngineError::EmptyBatch);
585 }
586 let mut unique = BTreeSet::new();
587 let mut mutations = Vec::with_capacity(keys.len());
588 for key in keys {
589 if !unique.insert(*key) {
590 return Err(EngineError::DuplicateDocumentKey);
591 }
592 mutations.push(Mutation::delete_vector(space.clone(), *key));
593 }
594 Ok(self.storage.write(transaction_id, &mutations)?)
595 }
596
597 pub fn retrieve_exact(
607 &self,
608 request: &ExactRetrievalRequest,
609 limits: &ExactRetrievalLimits,
610 ) -> Result<ExactRetrievalOutcome, EngineError> {
611 let started = Instant::now();
612 let Some(definition) = self.storage.vector_space(&request.vector_space)? else {
613 return Err(StorageError::from(
614 hyphae_storage::MaterializedIndexError::UnknownVectorSpace {
615 name: request.vector_space.as_str().to_owned(),
616 },
617 )
618 .into());
619 };
620 definition
621 .validate_vector(&request.query)
622 .map_err(|source| {
623 StorageError::from(hyphae_storage::MaterializedIndexError::from(source))
624 })?;
625 let validation_limits = ExactRetrievalLimits {
626 timeout: Duration::MAX,
627 ..limits.clone()
628 };
629 retrieve_exact(&[], request, &validation_limits)?;
630 let timeout = remaining_exact_timeout(started, limits.timeout)?;
631 let candidates = match self.storage.vector_entries_with_timeout(
632 &request.vector_space,
633 limits.max_candidates,
634 limits.max_candidate_bytes,
635 timeout,
636 ) {
637 Ok(candidates) => candidates,
638 Err(VectorEntriesError::ExactRetrieval(error)) => return Err(error.into()),
639 Err(VectorEntriesError::Storage(StorageError::Index { source })) => match *source {
640 hyphae_storage::MaterializedIndexError::VectorCandidateBudgetExceeded {
641 maximum,
642 } => {
643 return Err(ExactRetrievalError::CandidateBudgetExceeded { maximum }.into());
644 }
645 hyphae_storage::MaterializedIndexError::VectorByteBudgetExceeded { maximum } => {
646 return Err(ExactRetrievalError::CandidateByteBudgetExceeded { maximum }.into());
647 }
648 source => {
649 return Err(StorageError::Index {
650 source: Box::new(source),
651 }
652 .into());
653 }
654 },
655 Err(VectorEntriesError::Storage(error)) => return Err(error.into()),
656 };
657 let mut durable_candidates = Vec::with_capacity(candidates.len());
658 for entry in candidates {
659 remaining_exact_timeout(started, limits.timeout)?;
660 durable_candidates.push(DurableVectorRecord {
661 key: entry.key,
662 vector: entry.vector,
663 });
664 }
665 let execution_limits = ExactRetrievalLimits {
666 timeout: remaining_exact_timeout(started, limits.timeout)?,
667 ..limits.clone()
668 };
669 Ok(retrieve_exact(
670 &durable_candidates,
671 request,
672 &execution_limits,
673 )?)
674 }
675
676 pub fn retrieve_exact_with_proof(
684 &self,
685 request: &ExactRetrievalRequest,
686 limits: &ExactRetrievalLimits,
687 ) -> Result<ExactRetrievalProofArtifact, EngineError> {
688 let outcome = self.retrieve_exact(request, limits)?;
689 let snapshot = self.snapshot()?;
690 let proof = ExactRetrievalProof::new(&snapshot, request.clone(), outcome)?;
691 Ok(ExactRetrievalProofArtifact { proof, snapshot })
692 }
693
694 pub fn retrieve_exact_with_proof_with_limits(
703 &self,
704 request: &ExactRetrievalRequest,
705 limits: &ExactRetrievalLimits,
706 maintenance: &MaintenanceLimits,
707 ) -> Result<ExactRetrievalProofArtifact, EngineError> {
708 let started = Instant::now();
709 let outcome = self.retrieve_exact(request, limits)?;
710 let maintenance = remaining_maintenance_limits(maintenance, started, limits.timeout)?;
711 let snapshot = self.snapshot_with_limits(&maintenance)?;
712 let proof = ExactRetrievalProof::new(&snapshot, request.clone(), outcome)?;
713 ensure_total_timeout(started, limits.timeout)?;
714 Ok(ExactRetrievalProofArtifact { proof, snapshot })
715 }
716
717 pub fn define_lexical_index(
726 &mut self,
727 transaction_id: Uuid,
728 definition: LexicalIndexDefinition,
729 ) -> Result<AppendOutcome, EngineError> {
730 Ok(self.storage.write(
731 transaction_id,
732 &[Mutation::define_lexical_index(definition)],
733 )?)
734 }
735
736 pub fn retrieve_lexical(
746 &self,
747 request: &LexicalRequest,
748 limits: &LexicalLimits,
749 ) -> Result<LexicalOutcome, EngineError> {
750 let started = Instant::now();
751 let Some(definition) = self.storage.lexical_index(&request.index)? else {
752 return Err(StorageError::from(
753 hyphae_storage::MaterializedIndexError::UnknownLexicalIndex {
754 name: request.index.as_str().to_owned(),
755 },
756 )
757 .into());
758 };
759 let query_tokens = tokenize_v1_checked(
760 &request.query,
761 || ensure_lexical_timeout(started, limits.timeout),
762 || ensure_lexical_timeout(started, limits.timeout),
763 )?
764 .into_iter()
765 .collect::<BTreeSet<_>>()
766 .into_iter()
767 .collect::<Vec<_>>();
768 if query_tokens.is_empty() {
769 return Err(LexicalError::EmptyQuery.into());
770 }
771 if u64::try_from(query_tokens.len()).unwrap_or(u64::MAX) > limits.max_tokens {
772 return Err(LexicalError::TokenBudgetExceeded {
773 maximum: limits.max_tokens,
774 }
775 .into());
776 }
777 let timeout = remaining_lexical_timeout(started, limits.timeout)?;
778 let corpus = match self.storage.lexical_corpus(
779 &definition,
780 &query_tokens,
781 limits.max_candidates,
782 timeout,
783 ) {
784 Ok(corpus) => corpus,
785 Err(StorageError::Index { source }) => match *source {
786 hyphae_storage::MaterializedIndexError::Lexical(error) => {
787 return Err(error.into());
788 }
789 source => {
790 return Err(StorageError::Index {
791 source: Box::new(source),
792 }
793 .into());
794 }
795 },
796 Err(error) => return Err(error.into()),
797 };
798 let Some(timeout) = limits.timeout.checked_sub(started.elapsed()) else {
799 return Err(LexicalError::TimedOut.into());
800 };
801 if timeout.is_zero() {
802 return Err(LexicalError::TimedOut.into());
803 }
804 let execution_limits = LexicalLimits {
805 timeout,
806 ..limits.clone()
807 };
808 Ok(retrieve_lexical_materialized(
809 &corpus,
810 &definition,
811 request,
812 &execution_limits,
813 )?)
814 }
815
816 pub fn retrieve_lexical_with_proof(
823 &self,
824 request: &LexicalRequest,
825 limits: &LexicalLimits,
826 ) -> Result<LexicalRetrievalProofArtifact, EngineError> {
827 let outcome = self.retrieve_lexical(request, limits)?;
828 let snapshot = self.snapshot()?;
829 let proof = LexicalRetrievalProof::new(&snapshot, request.clone(), outcome)?;
830 Ok(LexicalRetrievalProofArtifact { proof, snapshot })
831 }
832
833 pub fn retrieve_lexical_with_proof_with_limits(
842 &self,
843 request: &LexicalRequest,
844 limits: &LexicalLimits,
845 maintenance: &MaintenanceLimits,
846 ) -> Result<LexicalRetrievalProofArtifact, EngineError> {
847 let started = Instant::now();
848 let outcome = self.retrieve_lexical(request, limits)?;
849 let maintenance = remaining_maintenance_limits(maintenance, started, limits.timeout)?;
850 let snapshot = self.snapshot_with_limits(&maintenance)?;
851 let proof = LexicalRetrievalProof::new(&snapshot, request.clone(), outcome)?;
852 ensure_total_timeout(started, limits.timeout)?;
853 Ok(LexicalRetrievalProofArtifact { proof, snapshot })
854 }
855
856 pub fn retrieve_hybrid(
865 &self,
866 lexical_request: &LexicalRequest,
867 lexical_limits: &LexicalLimits,
868 vector_request: &ExactRetrievalRequest,
869 vector_limits: &ExactRetrievalLimits,
870 hybrid_request: &HybridRequest,
871 ) -> Result<HybridOutcome, EngineError> {
872 Ok(self
873 .execute_hybrid(
874 lexical_request,
875 lexical_limits,
876 vector_request,
877 vector_limits,
878 hybrid_request,
879 )?
880 .outcome)
881 }
882
883 pub fn retrieve_hybrid_with_proof(
890 &self,
891 lexical_request: &LexicalRequest,
892 lexical_limits: &LexicalLimits,
893 vector_request: &ExactRetrievalRequest,
894 vector_limits: &ExactRetrievalLimits,
895 hybrid_request: &HybridRequest,
896 ) -> Result<HybridRetrievalProofArtifact, EngineError> {
897 let execution = self.execute_hybrid(
898 lexical_request,
899 lexical_limits,
900 vector_request,
901 vector_limits,
902 hybrid_request,
903 )?;
904 let snapshot = self.snapshot()?;
905 let proof = HybridRetrievalProof::new(
906 &snapshot,
907 lexical_request.clone(),
908 execution.lexical,
909 vector_request.clone(),
910 execution.vector,
911 hybrid_request.clone(),
912 execution.outcome,
913 )?;
914 Ok(HybridRetrievalProofArtifact { proof, snapshot })
915 }
916
917 #[allow(clippy::too_many_arguments)]
924 pub fn retrieve_hybrid_with_proof_with_limits(
925 &self,
926 lexical_request: &LexicalRequest,
927 lexical_limits: &LexicalLimits,
928 vector_request: &ExactRetrievalRequest,
929 vector_limits: &ExactRetrievalLimits,
930 hybrid_request: &HybridRequest,
931 maintenance: &MaintenanceLimits,
932 ) -> Result<HybridRetrievalProofArtifact, EngineError> {
933 let execution = self.execute_hybrid(
934 lexical_request,
935 lexical_limits,
936 vector_request,
937 vector_limits,
938 hybrid_request,
939 )?;
940 let maintenance =
941 remaining_maintenance_limits(maintenance, execution.started, execution.total_timeout)?;
942 let snapshot = self.snapshot_with_limits(&maintenance)?;
943 let proof = HybridRetrievalProof::new(
944 &snapshot,
945 lexical_request.clone(),
946 execution.lexical,
947 vector_request.clone(),
948 execution.vector,
949 hybrid_request.clone(),
950 execution.outcome,
951 )?;
952 ensure_total_timeout(execution.started, execution.total_timeout)?;
953 Ok(HybridRetrievalProofArtifact { proof, snapshot })
954 }
955
956 fn execute_hybrid(
957 &self,
958 lexical_request: &LexicalRequest,
959 lexical_limits: &LexicalLimits,
960 vector_request: &ExactRetrievalRequest,
961 vector_limits: &ExactRetrievalLimits,
962 hybrid_request: &HybridRequest,
963 ) -> Result<HybridExecution, EngineError> {
964 let started = Instant::now();
965 self.execute_hybrid_with_elapsed(
966 lexical_request,
967 lexical_limits,
968 vector_request,
969 vector_limits,
970 hybrid_request,
971 started,
972 || started.elapsed(),
973 )
974 }
975
976 #[allow(clippy::too_many_arguments)]
977 fn execute_hybrid_with_elapsed(
978 &self,
979 lexical_request: &LexicalRequest,
980 lexical_limits: &LexicalLimits,
981 vector_request: &ExactRetrievalRequest,
982 vector_limits: &ExactRetrievalLimits,
983 hybrid_request: &HybridRequest,
984 started: Instant,
985 mut elapsed: impl FnMut() -> Duration,
986 ) -> Result<HybridExecution, EngineError> {
987 let total_timeout = lexical_limits
988 .timeout
989 .checked_add(vector_limits.timeout)
990 .unwrap_or(Duration::MAX);
991
992 let mut bounded_lexical = lexical_limits.clone();
993 bounded_lexical.timeout = bounded_lexical
994 .timeout
995 .min(remaining_exact_timeout_after(total_timeout, elapsed())?);
996 let lexical = self.retrieve_lexical(lexical_request, &bounded_lexical)?;
997
998 let mut bounded_vector = vector_limits.clone();
999 bounded_vector.timeout = bounded_vector
1000 .timeout
1001 .min(remaining_exact_timeout_after(total_timeout, elapsed())?);
1002 let vector = self.retrieve_exact(vector_request, &bounded_vector)?;
1003
1004 let outcome = fuse_hybrid(&lexical, &vector, hybrid_request)?;
1005 remaining_exact_timeout_after(total_timeout, elapsed())?;
1006 Ok(HybridExecution {
1007 started,
1008 total_timeout,
1009 lexical,
1010 vector,
1011 outcome,
1012 })
1013 }
1014
1015 pub fn snapshot(&self) -> Result<SnapshotInfo, EngineError> {
1021 Ok(self.storage.snapshot()?)
1022 }
1023
1024 pub fn snapshot_with_limits(
1030 &self,
1031 limits: &MaintenanceLimits,
1032 ) -> Result<SnapshotInfo, EngineError> {
1033 Ok(self.storage.snapshot_with_limits(limits)?)
1034 }
1035
1036 pub fn compact(&mut self) -> Result<CompactionOutcome, EngineError> {
1042 Ok(self.storage.compact()?)
1043 }
1044
1045 pub fn compact_with_limits(
1052 &mut self,
1053 limits: &MaintenanceLimits,
1054 ) -> Result<CompactionOutcome, EngineError> {
1055 Ok(self.storage.compact_with_limits(limits)?)
1056 }
1057
1058 pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
1064 Ok(self.storage.backup(destination)?)
1065 }
1066
1067 pub fn verify_backup(path: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
1074 Ok(verify_backup(path)?)
1075 }
1076
1077 pub fn restore_backup(
1084 backup: impl AsRef<Path>,
1085 destination: impl AsRef<Path>,
1086 ) -> Result<RestoreInfo, EngineError> {
1087 Ok(restore_backup(backup, destination)?)
1088 }
1089}
1090
1091fn remaining_exact_timeout(
1092 started: Instant,
1093 total_timeout: Duration,
1094) -> Result<Duration, ExactRetrievalError> {
1095 remaining_exact_timeout_after(total_timeout, started.elapsed())
1096}
1097
1098fn remaining_exact_timeout_after(
1099 total_timeout: Duration,
1100 elapsed: Duration,
1101) -> Result<Duration, ExactRetrievalError> {
1102 total_timeout
1103 .checked_sub(elapsed)
1104 .filter(|remaining| !remaining.is_zero())
1105 .ok_or(ExactRetrievalError::TimedOut)
1106}
1107
1108fn ensure_lexical_timeout(started: Instant, total_timeout: Duration) -> Result<(), LexicalError> {
1109 remaining_lexical_timeout(started, total_timeout)?;
1110 Ok(())
1111}
1112
1113fn remaining_lexical_timeout(
1114 started: Instant,
1115 total_timeout: Duration,
1116) -> Result<Duration, LexicalError> {
1117 total_timeout
1118 .checked_sub(started.elapsed())
1119 .filter(|remaining| !remaining.is_zero())
1120 .ok_or(LexicalError::TimedOut)
1121}
1122
1123fn ensure_total_timeout(started: Instant, total_timeout: Duration) -> Result<(), EngineError> {
1124 if started.elapsed() >= total_timeout {
1125 Err(StorageError::from(SnapshotError::from(StorageLimitError::TimedOut)).into())
1126 } else {
1127 Ok(())
1128 }
1129}
1130
1131fn remaining_maintenance_limits(
1132 template: &MaintenanceLimits,
1133 started: Instant,
1134 total_timeout: Duration,
1135) -> Result<MaintenanceLimits, EngineError> {
1136 let Some(remaining) = total_timeout.checked_sub(started.elapsed()) else {
1137 return Err(StorageError::from(SnapshotError::from(StorageLimitError::TimedOut)).into());
1138 };
1139 if remaining.is_zero() {
1140 return Err(StorageError::from(SnapshotError::from(StorageLimitError::TimedOut)).into());
1141 }
1142 Ok(MaintenanceLimits {
1143 timeout: template.timeout.min(remaining),
1144 snapshot: template.snapshot.clone(),
1145 })
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use std::{
1151 collections::BTreeMap,
1152 fs,
1153 path::PathBuf,
1154 time::{Duration, Instant},
1155 };
1156
1157 use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName};
1158 use hyphae_query::{
1159 AggregationPlan, CompareOperator, FieldPath, Filter, Metric, MetricValue, NamedMetric,
1160 NullPlacement, SortDirection, SortField, Value, encoded_document_len,
1161 };
1162 use uuid::Uuid;
1163
1164 use hyphae_retrieval::{
1165 ExactRetrievalError, ExactRetrievalLimits, ExactRetrievalOutcome, ExactRetrievalRequest,
1166 HybridOutcome, HybridRequest, LexicalField, LexicalIndexDefinition, LexicalLimits,
1167 LexicalOutcome, LexicalRequest, retrieve_lexical,
1168 };
1169
1170 use super::{
1171 BoundedEngineQueryError, EngineError, ExecutionLimits, HyphaeEngine, Query, Record,
1172 };
1173
1174 struct TestDirectory {
1175 path: PathBuf,
1176 }
1177
1178 impl TestDirectory {
1179 fn new(name: &str) -> std::io::Result<Self> {
1180 let path = std::env::temp_dir().join(format!(
1181 "hyphae-engine-{name}-{}-{}",
1182 std::process::id(),
1183 Uuid::now_v7()
1184 ));
1185 fs::create_dir_all(&path)?;
1186 Ok(Self { path })
1187 }
1188
1189 fn path(&self) -> &std::path::Path {
1190 &self.path
1191 }
1192 }
1193
1194 impl Drop for TestDirectory {
1195 fn drop(&mut self) {
1196 let _ignored = fs::remove_dir_all(&self.path);
1197 }
1198 }
1199
1200 fn value(score: i64, group: &str) -> Value {
1201 Value::Object(BTreeMap::from([
1202 ("group".to_owned(), Value::String(group.to_owned())),
1203 ("score".to_owned(), Value::Integer(score)),
1204 ]))
1205 }
1206
1207 #[test]
1208 fn durable_documents_query_identically_after_compaction_and_reopen()
1209 -> Result<(), Box<dyn std::error::Error>> {
1210 let temporary = TestDirectory::new("engine-query-reopen")?;
1211 let root = temporary.path().join("data");
1212 let mut opened = HyphaeEngine::open(&root)?;
1213 opened.engine.put_records(
1214 Uuid::now_v7(),
1215 &[
1216 Record::new(b"a", value(10, "x")),
1217 Record::new(b"b", value(8, "x")),
1218 Record::new(b"c", value(7, "y")),
1219 Record::new(b"d", value(2, "y")),
1220 ],
1221 )?;
1222 let request = Query {
1223 filter: Filter::Compare {
1224 path: FieldPath::field("score"),
1225 operator: CompareOperator::GreaterOrEqual,
1226 value: Value::Integer(7),
1227 },
1228 sort: vec![SortField {
1229 path: FieldPath::field("score"),
1230 direction: SortDirection::Descending,
1231 nulls: NullPlacement::Last,
1232 }],
1233 cursor: None,
1234 limit: 2,
1235 aggregation: Some(AggregationPlan {
1236 group_by: Vec::new(),
1237 metrics: vec![NamedMetric {
1238 name: "count".to_owned(),
1239 metric: Metric::Count,
1240 }],
1241 }),
1242 };
1243 let before = opened.engine.query(&request, &ExecutionLimits::default())?;
1244 assert_eq!(before.rows.len(), 2);
1245 assert_eq!(
1246 before
1247 .aggregation
1248 .as_ref()
1249 .map(|aggregation| { aggregation.groups[0].metrics[0].value.clone() }),
1250 Some(MetricValue::Count(3))
1251 );
1252 opened.engine.compact()?;
1253 drop(opened);
1254
1255 let reopened = HyphaeEngine::open(&root)?;
1256 let after = reopened
1257 .engine
1258 .query(&request, &ExecutionLimits::default())?;
1259 assert_eq!(before, after);
1260 assert_eq!(
1261 reopened.engine.get_record(b"a")?.map(|record| record.value),
1262 Some(value(10, "x"))
1263 );
1264 Ok(())
1265 }
1266
1267 #[test]
1268 fn facade_enforces_scan_budget_before_building_a_partial_page()
1269 -> Result<(), Box<dyn std::error::Error>> {
1270 let temporary = TestDirectory::new("engine-query-budget")?;
1271 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1272 opened.engine.put_records(
1273 Uuid::now_v7(),
1274 &[
1275 Record::new(b"a", Value::Null),
1276 Record::new(b"b", Value::Null),
1277 ],
1278 )?;
1279 let limits = ExecutionLimits {
1280 max_scanned_records: 1,
1281 ..ExecutionLimits::default()
1282 };
1283 let result = opened.engine.query(
1284 &Query {
1285 filter: Filter::MatchAll,
1286 sort: Vec::new(),
1287 cursor: None,
1288 limit: 1,
1289 aggregation: None,
1290 },
1291 &limits,
1292 );
1293 assert!(matches!(
1294 result,
1295 Err(EngineError::Query(
1296 hyphae_query::QueryError::ScannedBudgetExceeded { maximum: 1 }
1297 ))
1298 ));
1299 Ok(())
1300 }
1301
1302 #[test]
1303 fn facade_enforces_query_scan_byte_budget_before_decode()
1304 -> Result<(), Box<dyn std::error::Error>> {
1305 let temporary = TestDirectory::new("engine-query-byte-budget")?;
1306 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1307 let records = [
1308 Record::new(b"a", Value::Null),
1309 Record::new(b"b", Value::String("bounded".to_owned())),
1310 ];
1311 let total_bytes = records.iter().try_fold(0_u64, |total, record| {
1312 let document = crate::encode_document(&record.value)?;
1313 let bytes = u64::try_from(record.key.len() + document.len())?;
1314 Ok::<_, Box<dyn std::error::Error>>(total.checked_add(bytes).ok_or("byte overflow")?)
1315 })?;
1316 opened.engine.put_records(Uuid::now_v7(), &records)?;
1317 let request = Query {
1318 filter: Filter::MatchAll,
1319 sort: Vec::new(),
1320 cursor: None,
1321 limit: 2,
1322 aggregation: None,
1323 };
1324 let result = opened.engine.query_with_byte_limit(
1325 &request,
1326 &ExecutionLimits::default(),
1327 total_bytes,
1328 )?;
1329 assert_eq!(result.rows, records);
1330 assert!(matches!(
1331 opened.engine.query_with_byte_limit(
1332 &request,
1333 &ExecutionLimits::default(),
1334 total_bytes - 1,
1335 ),
1336 Err(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum })
1337 if maximum == total_bytes - 1
1338 ));
1339 Ok(())
1340 }
1341
1342 #[test]
1343 fn durable_scan_byte_budget_is_exact_across_storage_pages()
1344 -> Result<(), Box<dyn std::error::Error>> {
1345 let temporary = TestDirectory::new("engine-query-byte-pages")?;
1346 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1347 let records = (0_u64..4_097)
1348 .map(|value| Record::new(value.to_be_bytes(), Value::Null))
1349 .collect::<Vec<_>>();
1350 let total_bytes = records.iter().try_fold(0_u64, |total, record| {
1351 let key_bytes = u64::try_from(record.key.len())?;
1352 let document_bytes = u64::try_from(encoded_document_len(&record.value)?)?;
1353 Ok::<_, Box<dyn std::error::Error>>(
1354 total
1355 .checked_add(key_bytes)
1356 .and_then(|next| next.checked_add(document_bytes))
1357 .ok_or_else(|| std::io::Error::other("scan byte total overflow"))?,
1358 )
1359 })?;
1360 opened.engine.put_records(Uuid::now_v7(), &records)?;
1361 let query = Query {
1362 filter: Filter::MatchAll,
1363 sort: Vec::new(),
1364 cursor: None,
1365 limit: 1,
1366 aggregation: None,
1367 };
1368
1369 let exact = opened.engine.query_with_byte_limit(
1370 &query,
1371 &ExecutionLimits::default(),
1372 total_bytes,
1373 )?;
1374 assert_eq!(exact.rows.len(), 1);
1375 assert!(matches!(
1376 opened.engine.query_with_byte_limit(
1377 &query,
1378 &ExecutionLimits::default(),
1379 total_bytes - 1,
1380 ),
1381 Err(BoundedEngineQueryError::ScannedByteBudgetExceeded { maximum })
1382 if maximum == total_bytes - 1
1383 ));
1384 Ok(())
1385 }
1386
1387 #[test]
1388 fn durable_vectors_survive_compaction_backup_restore_and_index_rebuild()
1389 -> Result<(), Box<dyn std::error::Error>> {
1390 let temporary = TestDirectory::new("durable-vectors-lifecycle")?;
1391 let root = temporary.path().join("data");
1392 let backup = temporary.path().join("backup");
1393 let restored = temporary.path().join("restored");
1394 let space = VectorSpaceName::new("semantic.v1")?;
1395 let definition = VectorSpaceDefinition::cosine(space.clone(), 3)?;
1396 let mut opened = HyphaeEngine::open(&root)?;
1397 opened
1398 .engine
1399 .define_vector_space(Uuid::now_v7(), definition.clone())?;
1400 opened.engine.put_vectors(
1401 Uuid::now_v7(),
1402 &space,
1403 &[
1404 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0, 0])?),
1405 (b"beta".to_vec(), Q15Vector::new(vec![0, 32_767, 0])?),
1406 ],
1407 )?;
1408 let request = ExactRetrievalRequest {
1409 vector_space: space.clone(),
1410 query: Q15Vector::new(vec![32_767, 0, 0])?,
1411 limit: 2,
1412 minimum_score_nanos: -1_000_000_000,
1413 minimum_margin_nanos: 0,
1414 };
1415 let limits = ExactRetrievalLimits {
1416 max_candidates: 10,
1417 max_candidate_bytes: 64 * 1024,
1418 max_returned: 10,
1419 timeout: Duration::from_secs(1),
1420 };
1421 let expected = opened.engine.retrieve_exact(&request, &limits)?;
1422 assert!(matches!(
1423 &expected,
1424 ExactRetrievalOutcome::Matches { matches, .. }
1425 if matches.first().is_some_and(|matched| matched.key == b"alpha")
1426 ));
1427 let exact_candidate_bytes =
1428 u64::try_from(b"alpha".len() + b"beta".len() + (2 * 3 * std::mem::size_of::<i16>()))?;
1429 let exact_limits = ExactRetrievalLimits {
1430 max_candidate_bytes: exact_candidate_bytes,
1431 ..limits.clone()
1432 };
1433 assert_eq!(
1434 opened.engine.retrieve_exact(&request, &exact_limits)?,
1435 expected
1436 );
1437 let one_byte_short = ExactRetrievalLimits {
1438 max_candidate_bytes: exact_candidate_bytes - 1,
1439 ..limits.clone()
1440 };
1441 assert!(matches!(
1442 opened.engine.retrieve_exact(&request, &one_byte_short),
1443 Err(EngineError::ExactRetrieval(
1444 hyphae_retrieval::ExactRetrievalError::CandidateByteBudgetExceeded {
1445 maximum
1446 }
1447 )) if maximum == exact_candidate_bytes - 1
1448 ));
1449 assert!(matches!(
1450 opened.engine.retrieve_exact(
1451 &request,
1452 &ExactRetrievalLimits {
1453 timeout: Duration::ZERO,
1454 ..limits.clone()
1455 }
1456 ),
1457 Err(EngineError::ExactRetrieval(
1458 hyphae_retrieval::ExactRetrievalError::TimedOut
1459 ))
1460 ));
1461 opened.engine.compact()?;
1462 assert_eq!(opened.engine.retrieve_exact(&request, &limits)?, expected);
1463 opened.engine.backup(&backup)?;
1464 drop(opened);
1465
1466 let reopened = HyphaeEngine::open(&root)?;
1467 assert_eq!(reopened.engine.retrieve_exact(&request, &limits)?, expected);
1468 drop(reopened);
1469 fs::remove_file(root.join("indexes/primary.redb"))?;
1470 let rebuilt = HyphaeEngine::open(&root)?;
1471 assert_eq!(rebuilt.engine.retrieve_exact(&request, &limits)?, expected);
1472 drop(rebuilt);
1473
1474 HyphaeEngine::restore_backup(&backup, &restored)?;
1475 let restored = HyphaeEngine::open(&restored)?;
1476 assert_eq!(restored.engine.retrieve_exact(&request, &limits)?, expected);
1477 Ok(())
1478 }
1479
1480 #[test]
1481 fn mixed_validity_vector_batch_is_rejected_without_partial_visibility()
1482 -> Result<(), Box<dyn std::error::Error>> {
1483 let temporary = TestDirectory::new("vector-batch-rollback")?;
1484 let root = temporary.path().join("data");
1485 let space = VectorSpaceName::new("semantic")?;
1486 let mut opened = HyphaeEngine::open(&root)?;
1487 opened.engine.define_vector_space(
1488 Uuid::now_v7(),
1489 VectorSpaceDefinition::cosine(space.clone(), 2)?,
1490 )?;
1491 let result = opened.engine.put_vectors(
1492 Uuid::now_v7(),
1493 &space,
1494 &[
1495 (b"valid".to_vec(), Q15Vector::new(vec![32_767, 0])?),
1496 (b"wrong".to_vec(), Q15Vector::new(vec![32_767, 0, 0])?),
1497 ],
1498 );
1499 assert!(result.is_err());
1500 let request = ExactRetrievalRequest {
1501 vector_space: space,
1502 query: Q15Vector::new(vec![32_767, 0])?,
1503 limit: 10,
1504 minimum_score_nanos: -1_000_000_000,
1505 minimum_margin_nanos: 0,
1506 };
1507 assert!(matches!(
1508 opened
1509 .engine
1510 .retrieve_exact(&request, &ExactRetrievalLimits::default())?,
1511 ExactRetrievalOutcome::Abstained(_)
1512 ));
1513 Ok(())
1514 }
1515
1516 fn lexical_value(title: &str, body: &str) -> Value {
1517 Value::Object(BTreeMap::from([
1518 ("body".to_owned(), Value::String(body.to_owned())),
1519 ("title".to_owned(), Value::String(title.to_owned())),
1520 ]))
1521 }
1522
1523 #[test]
1524 fn hybrid_deadline_includes_fusion_after_both_branches()
1525 -> Result<(), Box<dyn std::error::Error>> {
1526 let temporary = TestDirectory::new("hybrid-total-deadline")?;
1527 let name = VectorSpaceName::new("documents.v1")?;
1528 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1529 opened.engine.define_lexical_index(
1530 Uuid::now_v7(),
1531 LexicalIndexDefinition::new(
1532 name.clone(),
1533 vec![LexicalField {
1534 path: FieldPath::field("title"),
1535 weight_micros: 1_000_000,
1536 }],
1537 )?,
1538 )?;
1539 opened.engine.define_vector_space(
1540 Uuid::now_v7(),
1541 VectorSpaceDefinition::cosine(name.clone(), 2)?,
1542 )?;
1543 let lexical_request = LexicalRequest {
1544 index: name.clone(),
1545 query: "durable".into(),
1546 limit: 1,
1547 };
1548 let lexical_limits = LexicalLimits {
1549 timeout: Duration::from_secs(5),
1550 ..LexicalLimits::default()
1551 };
1552 let vector_request = ExactRetrievalRequest {
1553 vector_space: name,
1554 query: Q15Vector::new(vec![32_767, 0])?,
1555 limit: 1,
1556 minimum_score_nanos: -1_000_000_000,
1557 minimum_margin_nanos: 0,
1558 };
1559 let vector_limits = ExactRetrievalLimits {
1560 timeout: Duration::from_secs(5),
1561 ..ExactRetrievalLimits::default()
1562 };
1563 let hybrid_request = HybridRequest {
1564 lexical_weight: 1,
1565 vector_weight: 1,
1566 limit: 1,
1567 };
1568 let mut elapsed = [
1569 Duration::ZERO,
1570 Duration::from_secs(4),
1571 Duration::from_secs(10),
1572 ]
1573 .into_iter();
1574 let Err(error) = opened.engine.execute_hybrid_with_elapsed(
1575 &lexical_request,
1576 &lexical_limits,
1577 &vector_request,
1578 &vector_limits,
1579 &hybrid_request,
1580 Instant::now(),
1581 || elapsed.next().unwrap_or(Duration::MAX),
1582 ) else {
1583 return Err("fusion at the total deadline unexpectedly succeeded".into());
1584 };
1585 assert!(matches!(
1586 error,
1587 EngineError::ExactRetrieval(ExactRetrievalError::TimedOut)
1588 ));
1589 assert!(elapsed.next().is_none());
1590 Ok(())
1591 }
1592
1593 #[test]
1594 #[allow(clippy::too_many_lines)]
1595 fn lexical_and_hybrid_retrieval_survive_every_durable_lifecycle()
1596 -> Result<(), Box<dyn std::error::Error>> {
1597 let temporary = TestDirectory::new("lexical-hybrid-lifecycle")?;
1598 let root = temporary.path().join("data");
1599 let backup = temporary.path().join("backup");
1600 let restored = temporary.path().join("restored");
1601 let name = VectorSpaceName::new("documents.v1")?;
1602 let lexical_definition = LexicalIndexDefinition::new(
1603 name.clone(),
1604 vec![
1605 LexicalField {
1606 path: FieldPath::field("body"),
1607 weight_micros: 1_000_000,
1608 },
1609 LexicalField {
1610 path: FieldPath::field("title"),
1611 weight_micros: 2_000_000,
1612 },
1613 ],
1614 )?;
1615 let vector_definition = VectorSpaceDefinition::cosine(name.clone(), 2)?;
1616 let mut opened = HyphaeEngine::open(&root)?;
1617 opened.engine.put_records(
1618 Uuid::now_v7(),
1619 &[
1620 Record::new(b"alpha", lexical_value("Durable Rust", "offline engine")),
1621 Record::new(b"beta", lexical_value("Other", "durable storage")),
1622 Record::new(b"gamma", lexical_value("Unrelated", "nothing")),
1623 ],
1624 )?;
1625 opened
1626 .engine
1627 .define_lexical_index(Uuid::now_v7(), lexical_definition)?;
1628 opened
1629 .engine
1630 .define_vector_space(Uuid::now_v7(), vector_definition)?;
1631 opened.engine.put_vectors(
1632 Uuid::now_v7(),
1633 &name,
1634 &[
1635 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0])?),
1636 (b"beta".to_vec(), Q15Vector::new(vec![30_000, 2_000])?),
1637 (b"gamma".to_vec(), Q15Vector::new(vec![0, 32_767])?),
1638 ],
1639 )?;
1640 let lexical_request = LexicalRequest {
1641 index: name.clone(),
1642 query: "durable".into(),
1643 limit: 3,
1644 };
1645 let lexical_limits = LexicalLimits {
1646 max_documents: 10,
1647 max_tokens: 100,
1648 max_candidates: 10,
1649 max_returned: 10,
1650 timeout: Duration::from_secs(2),
1651 };
1652 let vector_request = ExactRetrievalRequest {
1653 vector_space: name,
1654 query: Q15Vector::new(vec![32_767, 0])?,
1655 limit: 3,
1656 minimum_score_nanos: -1_000_000_000,
1657 minimum_margin_nanos: 0,
1658 };
1659 let vector_limits = ExactRetrievalLimits {
1660 max_candidates: 10,
1661 max_candidate_bytes: 64 * 1024,
1662 max_returned: 10,
1663 timeout: Duration::from_secs(2),
1664 };
1665 let hybrid_request = HybridRequest {
1666 lexical_weight: 1,
1667 vector_weight: 1,
1668 limit: 3,
1669 };
1670 let expected_lexical = opened
1671 .engine
1672 .retrieve_lexical(&lexical_request, &lexical_limits)?;
1673 assert!(matches!(
1674 &expected_lexical,
1675 LexicalOutcome::Matches { matches, .. }
1676 if matches.first().is_some_and(|matched| matched.key == b"alpha")
1677 ));
1678 let expected_hybrid = opened.engine.retrieve_hybrid(
1679 &lexical_request,
1680 &lexical_limits,
1681 &vector_request,
1682 &vector_limits,
1683 &hybrid_request,
1684 )?;
1685 assert!(matches!(
1686 &expected_hybrid,
1687 HybridOutcome::Matches { matches, .. }
1688 if matches.first().is_some_and(|matched| matched.key == b"alpha")
1689 ));
1690 opened.engine.compact()?;
1691 assert_eq!(
1692 opened
1693 .engine
1694 .retrieve_lexical(&lexical_request, &lexical_limits)?,
1695 expected_lexical
1696 );
1697 opened.engine.backup(&backup)?;
1698 drop(opened);
1699
1700 let reopened = HyphaeEngine::open(&root)?;
1701 assert_eq!(
1702 reopened.engine.retrieve_hybrid(
1703 &lexical_request,
1704 &lexical_limits,
1705 &vector_request,
1706 &vector_limits,
1707 &hybrid_request,
1708 )?,
1709 expected_hybrid
1710 );
1711 drop(reopened);
1712 fs::remove_file(root.join("indexes/primary.redb"))?;
1713 let rebuilt = HyphaeEngine::open(&root)?;
1714 assert_eq!(
1715 rebuilt
1716 .engine
1717 .retrieve_lexical(&lexical_request, &lexical_limits)?,
1718 expected_lexical
1719 );
1720 drop(rebuilt);
1721
1722 HyphaeEngine::restore_backup(&backup, &restored)?;
1723 let restored = HyphaeEngine::open(&restored)?;
1724 assert_eq!(
1725 restored.engine.retrieve_hybrid(
1726 &lexical_request,
1727 &lexical_limits,
1728 &vector_request,
1729 &vector_limits,
1730 &hybrid_request,
1731 )?,
1732 expected_hybrid
1733 );
1734 assert_eq!(restored.engine.snapshot()?.lexical_index_count, 1);
1735 Ok(())
1736 }
1737
1738 #[test]
1739 fn lexical_document_budget_returns_no_partial_ranking() -> Result<(), Box<dyn std::error::Error>>
1740 {
1741 let temporary = TestDirectory::new("lexical-budget")?;
1742 let name = VectorSpaceName::new("documents")?;
1743 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1744 opened.engine.put_records(
1745 Uuid::now_v7(),
1746 &[
1747 Record::new(b"a", lexical_value("one", "durable")),
1748 Record::new(b"b", lexical_value("two", "durable")),
1749 ],
1750 )?;
1751 opened.engine.define_lexical_index(
1752 Uuid::now_v7(),
1753 LexicalIndexDefinition::new(
1754 name.clone(),
1755 vec![LexicalField {
1756 path: FieldPath::field("body"),
1757 weight_micros: 1_000_000,
1758 }],
1759 )?,
1760 )?;
1761 let outcome = opened.engine.retrieve_lexical(
1762 &LexicalRequest {
1763 index: name,
1764 query: "durable".into(),
1765 limit: 2,
1766 },
1767 &LexicalLimits {
1768 max_documents: 1,
1769 ..LexicalLimits::default()
1770 },
1771 );
1772 assert!(matches!(
1773 outcome,
1774 Err(EngineError::Lexical(
1775 hyphae_retrieval::LexicalError::DocumentBudgetExceeded { maximum: 1 }
1776 ))
1777 ));
1778 Ok(())
1779 }
1780
1781 #[test]
1782 fn lexical_materialization_timeout_returns_typed_timeout()
1783 -> Result<(), Box<dyn std::error::Error>> {
1784 let temporary = TestDirectory::new("lexical-timeout")?;
1785 let name = VectorSpaceName::new("documents.timeout")?;
1786 let mut opened = HyphaeEngine::open(temporary.path().join("data"))?;
1787 opened.engine.put_record(
1788 Uuid::now_v7(),
1789 &Record::new(b"a", lexical_value("one", "durable")),
1790 )?;
1791 opened.engine.define_lexical_index(
1792 Uuid::now_v7(),
1793 LexicalIndexDefinition::new(
1794 name.clone(),
1795 vec![LexicalField {
1796 path: FieldPath::field("body"),
1797 weight_micros: 1_000_000,
1798 }],
1799 )?,
1800 )?;
1801
1802 let outcome = opened.engine.retrieve_lexical(
1803 &LexicalRequest {
1804 index: name,
1805 query: "durable".into(),
1806 limit: 1,
1807 },
1808 &LexicalLimits {
1809 timeout: Duration::ZERO,
1810 ..LexicalLimits::default()
1811 },
1812 );
1813
1814 assert!(matches!(
1815 outcome,
1816 Err(EngineError::Lexical(
1817 hyphae_retrieval::LexicalError::TimedOut
1818 ))
1819 ));
1820 Ok(())
1821 }
1822
1823 #[test]
1824 fn materialized_lexical_index_matches_reference_after_update_delete_and_rebuild()
1825 -> Result<(), Box<dyn std::error::Error>> {
1826 let temporary = TestDirectory::new("lexical-reference-equivalence")?;
1827 let root = temporary.path().join("data");
1828 let name = VectorSpaceName::new("documents.reference")?;
1829 let definition = LexicalIndexDefinition::new(
1830 name.clone(),
1831 vec![
1832 LexicalField {
1833 path: FieldPath::field("body"),
1834 weight_micros: 1_000_000,
1835 },
1836 LexicalField {
1837 path: FieldPath::field("title"),
1838 weight_micros: 2_000_000,
1839 },
1840 ],
1841 )?;
1842 let request = LexicalRequest {
1843 index: name,
1844 query: "durable rust engine".into(),
1845 limit: 10,
1846 };
1847 let limits = LexicalLimits {
1848 max_documents: 100,
1849 max_tokens: 10_000,
1850 max_candidates: 100,
1851 max_returned: 100,
1852 timeout: Duration::from_secs(2),
1853 };
1854 let mut records = vec![
1855 Record::new(
1856 b"alpha",
1857 lexical_value("Durable Rust", "offline engine durable durable"),
1858 ),
1859 Record::new(
1860 b"beta",
1861 lexical_value("Storage Engine", "rust transactions"),
1862 ),
1863 Record::new(b"gamma", lexical_value("Unrelated", "nothing relevant")),
1864 Record::new(
1865 b"delta",
1866 lexical_value("Rust Engine", "durable local search"),
1867 ),
1868 ];
1869 let mut opened = HyphaeEngine::open(&root)?;
1870 opened.engine.put_records(Uuid::now_v7(), &records)?;
1871 opened
1872 .engine
1873 .define_lexical_index(Uuid::now_v7(), definition.clone())?;
1874
1875 let reference = retrieve_lexical(&records, &definition, &request, &limits)?;
1876 assert_eq!(
1877 opened.engine.retrieve_lexical(&request, &limits)?,
1878 reference
1879 );
1880
1881 let updated = Record::new(
1882 b"gamma",
1883 lexical_value("Durable Engine", "rust rust offline"),
1884 );
1885 opened.engine.put_record(Uuid::now_v7(), &updated)?;
1886 records.retain(|record| record.key != b"gamma");
1887 records.push(updated);
1888 opened.engine.delete_record(Uuid::now_v7(), b"beta")?;
1889 records.retain(|record| record.key != b"beta");
1890
1891 let updated_reference = retrieve_lexical(&records, &definition, &request, &limits)?;
1892 assert_eq!(
1893 opened.engine.retrieve_lexical(&request, &limits)?,
1894 updated_reference
1895 );
1896 drop(opened);
1897
1898 fs::remove_file(root.join("indexes/primary.redb"))?;
1899 let rebuilt = HyphaeEngine::open(&root)?;
1900 assert_eq!(
1901 rebuilt.engine.retrieve_lexical(&request, &limits)?,
1902 updated_reference
1903 );
1904 Ok(())
1905 }
1906}