Skip to main content

hyphae_engine/
facade.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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/// Failure while operating the embeddable Hyphae facade.
31#[derive(Debug, Error)]
32pub enum EngineError {
33    /// Durable embedded storage failed.
34    #[error(transparent)]
35    Storage(#[from] StorageError),
36
37    /// Portable backup creation, verification, or restore failed.
38    #[error(transparent)]
39    Backup(#[from] BackupError),
40
41    /// Canonical document encoding or verification failed.
42    #[error(transparent)]
43    Document(#[from] DocumentError),
44
45    /// Structured query validation or execution failed.
46    #[error(transparent)]
47    Query(#[from] QueryError),
48
49    /// Exact semantic retrieval failed.
50    #[error(transparent)]
51    Retrieval(#[from] RetrievalError),
52
53    /// Durable exact retrieval failed.
54    #[error(transparent)]
55    ExactRetrieval(#[from] ExactRetrievalError),
56
57    /// Canonical result-proof creation failed.
58    #[error(transparent)]
59    Proof(#[from] ProofError),
60
61    /// Canonical retrieval-proof creation failed.
62    #[error(transparent)]
63    RetrievalProof(#[from] RetrievalProofError),
64
65    /// Provider-free lexical retrieval failed.
66    #[error(transparent)]
67    Lexical(#[from] LexicalError),
68
69    /// Deterministic hybrid fusion failed.
70    #[error(transparent)]
71    Hybrid(#[from] HybridError),
72
73    /// One atomic document batch repeats a key.
74    #[error("atomic document batch contains a duplicate key")]
75    DuplicateDocumentKey,
76
77    /// An atomic batch must contain at least one item.
78    #[error("atomic batch must contain at least one item")]
79    EmptyBatch,
80}
81
82/// Newly opened embeddable engine and durable recovery evidence.
83#[derive(Debug)]
84pub struct OpenedEngine {
85    /// Ready engine facade.
86    pub engine: HyphaeEngine,
87    /// Log verification and index replay evidence.
88    pub recovery: StorageRecoveryReport,
89}
90
91/// Embeddable autonomous Hyphae engine.
92#[derive(Debug)]
93pub struct HyphaeEngine {
94    storage: StorageEngine,
95}
96
97impl HyphaeEngine {
98    /// Opens one exclusively owned data directory and completes recovery.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error for directory contention, corruption, unsupported
103    /// formats, snapshot mismatch, or failed index replay.
104    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    /// Returns the owned data-directory path.
115    pub fn data_path(&self) -> &Path {
116        self.storage.data_path()
117    }
118
119    /// Atomically stores one canonical structured record.
120    ///
121    /// # Errors
122    ///
123    /// Returns a document codec or durable storage error.
124    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    /// Atomically stores a batch of canonical structured records.
133    ///
134    /// Encoding every document and checking duplicate keys happens before the
135    /// log append, so a codec failure cannot partially commit the batch.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error for duplicate batch keys, document bounds, key bounds,
140    /// idempotency conflicts, or durable storage failures.
141    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    /// Atomically deletes one structured record.
164    ///
165    /// # Errors
166    ///
167    /// Returns a key-validation, idempotency, or durable storage error.
168    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    /// Atomically deletes a batch of structured records.
177    ///
178    /// Duplicate keys are rejected before the log append. Deleting a missing
179    /// key remains a successful durable operation.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error for duplicate keys, invalid key bounds, idempotency
184    /// conflicts, or durable storage failures.
185    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    /// Gets and verifies one structured record by binary key.
205    ///
206    /// # Errors
207    ///
208    /// Returns a key, storage, or canonical document verification error.
209    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    /// Gets one structured record and binds the complete result, including
222    /// absence, to a canonical snapshot witness.
223    ///
224    /// # Errors
225    ///
226    /// Returns a key, storage, document, snapshot, or result-proof error.
227    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    /// Executes deterministic structured query over all durable documents.
235    ///
236    /// Storage scan and document decoding consume the same wall-clock timeout;
237    /// the reference executor receives only the remaining duration.
238    ///
239    /// # Errors
240    ///
241    /// Returns a storage, document, query validation, global budget, aggregate,
242    /// or timeout error. No partial page is returned.
243    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    /// Executes one structured query and binds its complete logical result to
299    /// a canonical snapshot witness at the same locked checkpoint.
300    ///
301    /// # Errors
302    ///
303    /// Returns any ordinary query error plus snapshot or proof creation
304    /// failures. No proof is returned for a partial or failed query.
305    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    /// Executes exact provider-neutral vector retrieval without persisting or
317    /// producing embeddings.
318    ///
319    /// # Errors
320    ///
321    /// Returns vector, shape, duplicate-key, budget, or timeout errors.
322    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    /// Defines one immutable durable named vector space.
331    ///
332    /// Repeating the identical definition is idempotent; changing dimension
333    /// or metric for an existing name fails before the log append.
334    ///
335    /// # Errors
336    ///
337    /// Returns an idempotency, immutable-definition, or durable storage error.
338    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    /// Atomically stores vectors in one named space.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error before append for duplicate keys, an unknown space,
353    /// wrong dimensions, invalid keys, or invalid vectors.
354    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    /// Atomically deletes vectors from one named space.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error before append for duplicate/invalid keys or an unknown
383    /// vector space.
384    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    /// Executes exact retrieval over the latest caught-up durable vector
405    /// state. Storage budgets are enforced before returning candidates, then
406    /// the canonical executor applies scoring and timeout policy.
407    ///
408    /// # Errors
409    ///
410    /// Returns an error for an unknown space, wrong query dimension, exhausted
411    /// budget, timeout, stale storage, or malformed durable state. No partial
412    /// ranking is returned.
413    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    /// Executes exact durable retrieval and binds its complete outcome to a
447    /// canonical format-2 snapshot witness.
448    ///
449    /// # Errors
450    ///
451    /// Returns any exact-retrieval, snapshot, or retrieval-proof error. No
452    /// proof is emitted for failed or partial execution.
453    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    /// Defines one immutable provider-free lexical index.
465    ///
466    /// Repeating the identical definition is idempotent. Any change to an
467    /// existing definition fails before the durable append.
468    ///
469    /// # Errors
470    ///
471    /// Returns an immutable-definition, idempotency, or storage error.
472    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    /// Executes provider-free lexical retrieval from the rebuildable durable
484    /// posting projection.
485    ///
486    /// Posting lookup, candidate materialization, and reference scoring share
487    /// one lexical timeout and never return a partial ranking.
488    ///
489    /// # Errors
490    ///
491    /// Returns an unknown-index, document, budget, timeout, or storage error.
492    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    /// Executes lexical retrieval and binds the complete outcome to a
553    /// canonical format-2 snapshot witness.
554    ///
555    /// # Errors
556    ///
557    /// Returns any lexical, snapshot, or retrieval-proof error.
558    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    /// Executes both durable branches and fuses their complete outcomes using
570    /// deterministic RRF semantics.
571    ///
572    /// # Errors
573    ///
574    /// Returns any lexical, exact-vector, storage, budget, timeout, or fusion
575    /// error. Branch failures never silently downgrade to single-modality
576    /// success.
577    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    /// Executes lexical and exact-vector branches, fuses their complete
591    /// outcomes, and binds all three outcomes to one canonical snapshot.
592    ///
593    /// # Errors
594    ///
595    /// Returns any branch, fusion, snapshot, or retrieval-proof error.
596    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    /// Creates or reuses a verified logical snapshot.
621    ///
622    /// # Errors
623    ///
624    /// Returns a stale-handle, index, or snapshot error.
625    pub fn snapshot(&self) -> Result<SnapshotInfo, EngineError> {
626        Ok(self.storage.snapshot()?)
627    }
628
629    /// Commits an anchored compaction generation.
630    ///
631    /// # Errors
632    ///
633    /// Returns a stale-handle, snapshot, segment, or manifest error.
634    pub fn compact(&mut self) -> Result<CompactionOutcome, EngineError> {
635        Ok(self.storage.compact()?)
636    }
637
638    /// Creates an atomic portable backup at the locked logical checkpoint.
639    ///
640    /// # Errors
641    ///
642    /// Returns a snapshot, destination, synchronization, or promotion error.
643    pub fn backup(&self, destination: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
644        Ok(self.storage.backup(destination)?)
645    }
646
647    /// Verifies a portable backup without opening a live data directory.
648    ///
649    /// # Errors
650    ///
651    /// Returns an error for a malformed layout, metadata mismatch, or corrupt
652    /// snapshot.
653    pub fn verify_backup(path: impl AsRef<Path>) -> Result<BackupInfo, EngineError> {
654        Ok(verify_backup(path)?)
655    }
656
657    /// Restores a backup to a new atomically activated data directory.
658    ///
659    /// # Errors
660    ///
661    /// Returns an error before destination activation if verification, index
662    /// reconstruction, reopen, or filesystem synchronization fails.
663    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}