1use std::{
4 fs::{File, Metadata, OpenOptions},
5 io::{Read, Write},
6 path::Path,
7 time::{Duration, Instant},
8};
9
10use hyphae_query::Record;
11use hyphae_retrieval::{
12 DurableVectorRecord, ExactRetrievalError, ExactRetrievalLimits, ExactRetrievalRequest,
13 HybridRequest, LexicalLimits, fuse_hybrid, retrieve_exact, retrieve_lexical,
14};
15use hyphae_storage::{SnapshotContents, load_snapshot_with_timeout};
16
17use super::{
18 ExactRetrievalProof, ExactRetrievalVerificationReport, HybridRetrievalProof,
19 HybridRetrievalVerificationReport, LexicalRetrievalProof, LexicalRetrievalVerificationReport,
20 MAX_RETRIEVAL_PROOF_BYTES, RetrievalProofAnchor, RetrievalProofError,
21 RetrievalVerificationLimits, decode_hybrid_proof, decode_lexical_proof, decode_proof,
22 encode_hybrid_proof, encode_lexical_proof, encode_proof,
23};
24use crate::decode_document;
25
26const PROOF_READ_BUFFER_BYTES: usize = 64 * 1024;
27
28pub fn write_exact_retrieval_proof(
36 path: impl AsRef<Path>,
37 proof: &ExactRetrievalProof,
38) -> Result<(), RetrievalProofError> {
39 let encoded = encode_proof(proof)?;
40 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
41 file.write_all(&encoded)?;
42 file.sync_all()?;
43 Ok(())
44}
45
46pub fn write_lexical_retrieval_proof(
52 path: impl AsRef<Path>,
53 proof: &LexicalRetrievalProof,
54) -> Result<(), RetrievalProofError> {
55 write_new(path, &encode_lexical_proof(proof)?)
56}
57
58pub fn write_hybrid_retrieval_proof(
64 path: impl AsRef<Path>,
65 proof: &HybridRetrievalProof,
66) -> Result<(), RetrievalProofError> {
67 write_new(path, &encode_hybrid_proof(proof)?)
68}
69
70pub fn read_exact_retrieval_proof(
77 path: impl AsRef<Path>,
78 maximum_bytes: u64,
79) -> Result<ExactRetrievalProof, RetrievalProofError> {
80 let mut no_deadline = || Ok(());
81 decode_proof(&read_bounded(path, maximum_bytes, &mut no_deadline)?)
82}
83
84pub fn read_lexical_retrieval_proof(
91 path: impl AsRef<Path>,
92 maximum_bytes: u64,
93) -> Result<LexicalRetrievalProof, RetrievalProofError> {
94 let mut no_deadline = || Ok(());
95 decode_lexical_proof(&read_bounded(path, maximum_bytes, &mut no_deadline)?)
96}
97
98pub fn read_hybrid_retrieval_proof(
105 path: impl AsRef<Path>,
106 maximum_bytes: u64,
107) -> Result<HybridRetrievalProof, RetrievalProofError> {
108 let mut no_deadline = || Ok(());
109 decode_hybrid_proof(&read_bounded(path, maximum_bytes, &mut no_deadline)?)
110}
111
112pub fn verify_exact_retrieval_proof(
121 proof_path: impl AsRef<Path>,
122 snapshot_path: impl AsRef<Path>,
123 expected_anchor_digest: [u8; 32],
124 limits: &RetrievalVerificationLimits,
125) -> Result<ExactRetrievalVerificationReport, RetrievalProofError> {
126 let started = Instant::now();
127 let mut check_read_deadline = || check_timeout(started, limits);
128 let proof = decode_proof(&read_bounded(
129 proof_path,
130 limits.proof_bytes,
131 &mut check_read_deadline,
132 )?)?;
133 check_timeout(started, limits)?;
134
135 let anchor_digest = proof.anchor_digest();
136 if anchor_digest != expected_anchor_digest {
137 return Err(RetrievalProofError::AnchorMismatch);
138 }
139
140 let snapshot = load_snapshot_before_deadline(snapshot_path, limits, started)?;
141 check_timeout(started, limits)?;
142 if snapshot.info.disk_format_version != 2 {
143 return Err(RetrievalProofError::SnapshotFormatMismatch);
144 }
145 if RetrievalProofAnchor::from_snapshot(&snapshot.info) != *proof.anchor() {
146 return Err(RetrievalProofError::SnapshotAnchorMismatch);
147 }
148
149 let Some(definition) = snapshot
150 .vector_spaces
151 .iter()
152 .find(|definition| definition.name == proof.request().vector_space)
153 else {
154 return Err(RetrievalProofError::Invalid {
155 reason: "proof references an unknown vector space",
156 });
157 };
158 definition.validate_vector(&proof.request().query)?;
159
160 let candidates = materialize_exact_candidates(&snapshot, proof.request(), limits, || {
161 check_timeout(started, limits)
162 })?;
163
164 let remaining = limits
165 .timeout
166 .checked_sub(started.elapsed())
167 .ok_or(RetrievalProofError::TimedOut)?;
168 if remaining.is_zero() {
169 return Err(RetrievalProofError::TimedOut);
170 }
171 let execution_limits = ExactRetrievalLimits {
172 max_candidates: limits.max_candidates,
173 max_candidate_bytes: limits.max_candidate_bytes,
174 max_returned: limits.max_returned,
175 timeout: remaining,
176 };
177 let actual = retrieve_exact(&candidates, proof.request(), &execution_limits)?;
178 if &actual != proof.outcome() {
179 return Err(RetrievalProofError::ReexecutionMismatch);
180 }
181 check_timeout(started, limits)?;
182
183 Ok(ExactRetrievalVerificationReport {
184 anchor: proof.anchor().clone(),
185 anchor_digest,
186 proof_digest: proof.proof_digest(),
187 outcome: actual,
188 })
189}
190
191pub fn verify_lexical_retrieval_proof(
198 proof_path: impl AsRef<Path>,
199 snapshot_path: impl AsRef<Path>,
200 expected_anchor_digest: [u8; 32],
201 limits: &RetrievalVerificationLimits,
202) -> Result<LexicalRetrievalVerificationReport, RetrievalProofError> {
203 let started = Instant::now();
204 let mut check_read_deadline = || check_timeout(started, limits);
205 let proof = decode_lexical_proof(&read_bounded(
206 proof_path,
207 limits.proof_bytes,
208 &mut check_read_deadline,
209 )?)?;
210 let snapshot = load_bound_snapshot(
211 snapshot_path,
212 proof.anchor(),
213 expected_anchor_digest,
214 limits,
215 started,
216 )?;
217 let definition = snapshot
218 .lexical_indexes
219 .iter()
220 .find(|definition| definition.name == proof.request().index)
221 .ok_or(RetrievalProofError::Invalid {
222 reason: "proof references an unknown lexical index",
223 })?;
224 let records = decode_records(&snapshot, started, limits)?;
225 let remaining = remaining_timeout(started, limits)?;
226 let execution_limits = LexicalLimits {
227 max_documents: limits.max_documents,
228 max_tokens: limits.max_tokens,
229 max_candidates: limits.max_lexical_candidates,
230 max_returned: limits.max_lexical_returned,
231 timeout: remaining,
232 };
233 let actual = retrieve_lexical(&records, definition, proof.request(), &execution_limits)?;
234 if &actual != proof.outcome() {
235 return Err(RetrievalProofError::ReexecutionMismatch);
236 }
237 check_timeout(started, limits)?;
238 Ok(LexicalRetrievalVerificationReport {
239 anchor: proof.anchor().clone(),
240 anchor_digest: proof.anchor_digest(),
241 proof_digest: proof.proof_digest(),
242 outcome: actual,
243 })
244}
245
246pub fn verify_hybrid_retrieval_proof(
254 proof_path: impl AsRef<Path>,
255 snapshot_path: impl AsRef<Path>,
256 expected_anchor_digest: [u8; 32],
257 limits: &RetrievalVerificationLimits,
258) -> Result<HybridRetrievalVerificationReport, RetrievalProofError> {
259 let started = Instant::now();
260 let mut check_read_deadline = || check_timeout(started, limits);
261 let proof = decode_hybrid_proof(&read_bounded(
262 proof_path,
263 limits.proof_bytes,
264 &mut check_read_deadline,
265 )?)?;
266 let snapshot = load_bound_snapshot(
267 snapshot_path,
268 proof.anchor(),
269 expected_anchor_digest,
270 limits,
271 started,
272 )?;
273 let definition = snapshot
274 .lexical_indexes
275 .iter()
276 .find(|definition| definition.name == proof.lexical_request().index)
277 .ok_or(RetrievalProofError::Invalid {
278 reason: "proof references an unknown lexical index",
279 })?;
280 let records = decode_records(&snapshot, started, limits)?;
281 let lexical = retrieve_lexical(
282 &records,
283 definition,
284 proof.lexical_request(),
285 &LexicalLimits {
286 max_documents: limits.max_documents,
287 max_tokens: limits.max_tokens,
288 max_candidates: limits.max_lexical_candidates,
289 max_returned: limits.max_lexical_returned,
290 timeout: remaining_timeout(started, limits)?,
291 },
292 )?;
293 if &lexical != proof.lexical_outcome() {
294 return Err(RetrievalProofError::ReexecutionMismatch);
295 }
296 let vector = replay_exact(&snapshot, proof.vector_request(), started, limits)?;
297 if &vector != proof.vector_outcome() {
298 return Err(RetrievalProofError::ReexecutionMismatch);
299 }
300 if proof.fusion_request().limit > limits.max_hybrid_returned {
301 return Err(RetrievalProofError::Invalid {
302 reason: "hybrid result limit exceeds verifier policy",
303 });
304 }
305 let fusion_request = HybridRequest {
306 lexical_weight: proof.fusion_request().lexical_weight,
307 vector_weight: proof.fusion_request().vector_weight,
308 limit: proof.fusion_request().limit,
309 };
310 let actual = fuse_hybrid(&lexical, &vector, &fusion_request)?;
311 if &actual != proof.outcome() {
312 return Err(RetrievalProofError::ReexecutionMismatch);
313 }
314 check_timeout(started, limits)?;
315 Ok(HybridRetrievalVerificationReport {
316 anchor: proof.anchor().clone(),
317 anchor_digest: proof.anchor_digest(),
318 proof_digest: proof.proof_digest(),
319 outcome: actual,
320 })
321}
322
323fn write_new(path: impl AsRef<Path>, encoded: &[u8]) -> Result<(), RetrievalProofError> {
324 let mut file = OpenOptions::new().create_new(true).write(true).open(path)?;
325 file.write_all(encoded)?;
326 file.sync_all()?;
327 Ok(())
328}
329
330fn read_bounded(
331 path: impl AsRef<Path>,
332 maximum_bytes: u64,
333 check_deadline: &mut impl FnMut() -> Result<(), RetrievalProofError>,
334) -> Result<Vec<u8>, RetrievalProofError> {
335 check_deadline()?;
336 let path = path.as_ref();
337 let path_metadata = std::fs::metadata(path)?;
338 check_deadline()?;
339 ensure_regular_proof_file(&path_metadata)?;
340
341 let file = File::open(path)?;
342 check_deadline()?;
343 let initial_metadata = file.metadata()?;
344 check_deadline()?;
345 ensure_regular_proof_file(&initial_metadata)?;
346
347 read_open_bounded(
348 file,
349 &initial_metadata,
350 maximum_bytes.min(MAX_RETRIEVAL_PROOF_BYTES),
351 check_deadline,
352 )
353}
354
355fn read_open_bounded(
356 mut file: File,
357 initial_metadata: &Metadata,
358 maximum_bytes: u64,
359 check_deadline: &mut impl FnMut() -> Result<(), RetrievalProofError>,
360) -> Result<Vec<u8>, RetrievalProofError> {
361 let initial_length = initial_metadata.len();
362 if initial_length > maximum_bytes {
363 return Err(RetrievalProofError::ProofLimitExceeded {
364 actual: initial_length,
365 maximum: maximum_bytes,
366 });
367 }
368 let capacity =
369 usize::try_from(initial_length).map_err(|_| RetrievalProofError::LengthOverflow)?;
370 let mut encoded = Vec::with_capacity(capacity);
371 let mut remaining = maximum_bytes
372 .checked_add(1)
373 .ok_or(RetrievalProofError::LengthOverflow)?;
374 let mut buffer = vec![0_u8; PROOF_READ_BUFFER_BYTES];
375 while remaining > 0 {
376 check_deadline()?;
377 let read_length = usize::try_from(remaining.min(PROOF_READ_BUFFER_BYTES as u64))
378 .map_err(|_| RetrievalProofError::LengthOverflow)?;
379 let read = file.read(&mut buffer[..read_length])?;
380 check_deadline()?;
381 if read == 0 {
382 break;
383 }
384 encoded.extend_from_slice(&buffer[..read]);
385 remaining = remaining
386 .checked_sub(u64::try_from(read).map_err(|_| RetrievalProofError::LengthOverflow)?)
387 .ok_or(RetrievalProofError::LengthOverflow)?;
388 }
389
390 let final_metadata = file.metadata()?;
391 check_deadline()?;
392 ensure_regular_proof_file(&final_metadata)?;
393 let actual = u64::try_from(encoded.len()).map_err(|_| RetrievalProofError::LengthOverflow)?;
394 let observed = actual.max(final_metadata.len());
395 if observed > maximum_bytes {
396 return Err(RetrievalProofError::ProofLimitExceeded {
397 actual: observed,
398 maximum: maximum_bytes,
399 });
400 }
401 if actual != initial_length || final_metadata.len() != initial_length {
402 return Err(RetrievalProofError::Invalid {
403 reason: "proof changed while being read",
404 });
405 }
406 Ok(encoded)
407}
408
409fn ensure_regular_proof_file(metadata: &Metadata) -> Result<(), RetrievalProofError> {
410 if metadata.is_file() {
411 Ok(())
412 } else {
413 Err(RetrievalProofError::Invalid {
414 reason: "proof path is not a regular file",
415 })
416 }
417}
418
419fn load_bound_snapshot(
420 path: impl AsRef<Path>,
421 anchor: &RetrievalProofAnchor,
422 expected_anchor_digest: [u8; 32],
423 limits: &RetrievalVerificationLimits,
424 started: Instant,
425) -> Result<SnapshotContents, RetrievalProofError> {
426 if anchor.digest() != expected_anchor_digest {
427 return Err(RetrievalProofError::AnchorMismatch);
428 }
429 let snapshot = load_snapshot_before_deadline(path, limits, started)?;
430 check_timeout(started, limits)?;
431 if snapshot.info.disk_format_version != 2 {
432 return Err(RetrievalProofError::SnapshotFormatMismatch);
433 }
434 if RetrievalProofAnchor::from_snapshot(&snapshot.info) != *anchor {
435 return Err(RetrievalProofError::SnapshotAnchorMismatch);
436 }
437 Ok(snapshot)
438}
439
440fn load_snapshot_before_deadline(
441 path: impl AsRef<Path>,
442 limits: &RetrievalVerificationLimits,
443 started: Instant,
444) -> Result<SnapshotContents, RetrievalProofError> {
445 match load_snapshot_with_timeout(path, &limits.snapshot, remaining_timeout(started, limits)?) {
446 Err(error) if error.is_timeout() => Err(RetrievalProofError::TimedOut),
447 Err(error) => Err(error.into()),
448 Ok(snapshot) => Ok(snapshot),
449 }
450}
451
452fn decode_records(
453 snapshot: &SnapshotContents,
454 started: Instant,
455 limits: &RetrievalVerificationLimits,
456) -> Result<Vec<Record>, RetrievalProofError> {
457 if u64::try_from(snapshot.entries.len()).unwrap_or(u64::MAX) > limits.max_documents {
458 return Err(RetrievalProofError::Invalid {
459 reason: "snapshot document count exceeds verifier policy",
460 });
461 }
462 snapshot
463 .entries
464 .iter()
465 .map(|entry| {
466 check_timeout(started, limits)?;
467 Ok(Record {
468 key: entry.key.clone(),
469 value: decode_document(&entry.value)?,
470 })
471 })
472 .collect()
473}
474
475fn replay_exact(
476 snapshot: &SnapshotContents,
477 request: &ExactRetrievalRequest,
478 started: Instant,
479 limits: &RetrievalVerificationLimits,
480) -> Result<hyphae_retrieval::ExactRetrievalOutcome, RetrievalProofError> {
481 let definition = snapshot
482 .vector_spaces
483 .iter()
484 .find(|definition| definition.name == request.vector_space)
485 .ok_or(RetrievalProofError::Invalid {
486 reason: "proof references an unknown vector space",
487 })?;
488 definition.validate_vector(&request.query)?;
489 let candidates =
490 materialize_exact_candidates(snapshot, request, limits, || check_timeout(started, limits))?;
491 Ok(retrieve_exact(
492 &candidates,
493 request,
494 &ExactRetrievalLimits {
495 max_candidates: limits.max_candidates,
496 max_candidate_bytes: limits.max_candidate_bytes,
497 max_returned: limits.max_returned,
498 timeout: remaining_timeout(started, limits)?,
499 },
500 )?)
501}
502
503fn materialize_exact_candidates(
504 snapshot: &SnapshotContents,
505 request: &ExactRetrievalRequest,
506 limits: &RetrievalVerificationLimits,
507 mut check_deadline: impl FnMut() -> Result<(), RetrievalProofError>,
508) -> Result<Vec<DurableVectorRecord>, RetrievalProofError> {
509 let matching = || {
510 snapshot
511 .vectors
512 .iter()
513 .filter(|vector| vector.space == request.vector_space)
514 };
515 let mut candidate_count = 0_u64;
516 let mut candidate_bytes = 0_u64;
517 for vector in matching() {
518 check_deadline()?;
519 if candidate_count >= limits.max_candidates {
520 return Err(ExactRetrievalError::CandidateBudgetExceeded {
521 maximum: limits.max_candidates,
522 }
523 .into());
524 }
525 candidate_count =
526 candidate_count
527 .checked_add(1)
528 .ok_or(ExactRetrievalError::CandidateBudgetExceeded {
529 maximum: limits.max_candidates,
530 })?;
531
532 let vector_bytes = u64::try_from(vector.vector.as_slice().len())
533 .ok()
534 .and_then(|elements| elements.checked_mul(2))
535 .ok_or(ExactRetrievalError::CandidateByteBudgetExceeded {
536 maximum: limits.max_candidate_bytes,
537 })?;
538 let bytes = u64::try_from(vector.key.len())
539 .ok()
540 .and_then(|key_bytes| key_bytes.checked_add(vector_bytes))
541 .ok_or(ExactRetrievalError::CandidateByteBudgetExceeded {
542 maximum: limits.max_candidate_bytes,
543 })?;
544 candidate_bytes = candidate_bytes.checked_add(bytes).ok_or(
545 ExactRetrievalError::CandidateByteBudgetExceeded {
546 maximum: limits.max_candidate_bytes,
547 },
548 )?;
549 if candidate_bytes > limits.max_candidate_bytes {
550 return Err(ExactRetrievalError::CandidateByteBudgetExceeded {
551 maximum: limits.max_candidate_bytes,
552 }
553 .into());
554 }
555 }
556 check_deadline()?;
557
558 let capacity = usize::try_from(candidate_count).map_err(|_| {
559 ExactRetrievalError::CandidateBudgetExceeded {
560 maximum: limits.max_candidates,
561 }
562 })?;
563 let mut candidates = Vec::with_capacity(capacity);
564 for vector in matching() {
565 check_deadline()?;
566 candidates.push(DurableVectorRecord {
567 key: vector.key.clone(),
568 vector: vector.vector.clone(),
569 });
570 }
571 check_deadline()?;
572 Ok(candidates)
573}
574
575fn remaining_timeout(
576 started: Instant,
577 limits: &RetrievalVerificationLimits,
578) -> Result<Duration, RetrievalProofError> {
579 let remaining = limits
580 .timeout
581 .checked_sub(started.elapsed())
582 .ok_or(RetrievalProofError::TimedOut)?;
583 if remaining.is_zero() {
584 Err(RetrievalProofError::TimedOut)
585 } else {
586 Ok(remaining)
587 }
588}
589
590fn check_timeout(
591 started: Instant,
592 limits: &RetrievalVerificationLimits,
593) -> Result<(), RetrievalProofError> {
594 if started.elapsed() >= limits.timeout {
595 Err(RetrievalProofError::TimedOut)
596 } else {
597 Ok(())
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use std::{
604 collections::BTreeMap, error::Error, fs, io::Write as _, path::PathBuf, time::Duration,
605 };
606
607 use hyphae_core::{Q15Vector, VectorSpaceDefinition, VectorSpaceName};
608 use hyphae_query::{FieldPath, Record, Value};
609 use hyphae_retrieval::{
610 ExactRetrievalError, ExactRetrievalLimits, ExactRetrievalOutcome, ExactRetrievalRequest,
611 HybridRequest, LexicalField, LexicalIndexDefinition, LexicalLimits, LexicalRequest,
612 };
613 use hyphae_storage::{SnapshotReadLimits, load_snapshot};
614 use uuid::Uuid;
615
616 use super::{
617 PROOF_READ_BUFFER_BYTES, RetrievalVerificationLimits, materialize_exact_candidates,
618 read_exact_retrieval_proof, read_open_bounded, verify_exact_retrieval_proof,
619 verify_hybrid_retrieval_proof, verify_lexical_retrieval_proof, write_exact_retrieval_proof,
620 write_hybrid_retrieval_proof, write_lexical_retrieval_proof,
621 };
622 use crate::{
623 HyphaeEngine, MAX_RETRIEVAL_PROOF_BYTES, RetrievalProofError,
624 write_exact_retrieval_proof as write,
625 };
626
627 struct TestDirectory {
628 path: PathBuf,
629 }
630
631 impl TestDirectory {
632 fn create(name: &str) -> Result<Self, Box<dyn Error>> {
633 let path = std::env::temp_dir()
634 .join(format!("hyphae-retrieval-proof-{name}-{}", Uuid::now_v7()));
635 fs::create_dir_all(&path)?;
636 Ok(Self { path })
637 }
638 }
639
640 impl Drop for TestDirectory {
641 fn drop(&mut self) {
642 let _ignored = fs::remove_dir_all(&self.path);
643 }
644 }
645
646 #[test]
647 fn retrieval_proof_reader_enforces_the_canonical_hard_limit() -> Result<(), Box<dyn Error>> {
648 let temporary = TestDirectory::create("oversized-proof")?;
649 let proof_path = temporary.path.join("oversized.hyrproof");
650 let file = fs::File::create(&proof_path)?;
651 file.set_len(MAX_RETRIEVAL_PROOF_BYTES + 1)?;
652 drop(file);
653
654 assert!(matches!(
655 read_exact_retrieval_proof(&proof_path, u64::MAX),
656 Err(RetrievalProofError::ProofLimitExceeded {
657 actual,
658 maximum: MAX_RETRIEVAL_PROOF_BYTES,
659 }) if actual == MAX_RETRIEVAL_PROOF_BYTES + 1
660 ));
661 Ok(())
662 }
663
664 #[test]
665 fn retrieval_proof_reader_detects_same_handle_growth() -> Result<(), Box<dyn Error>> {
666 let temporary = TestDirectory::create("growing-proof")?;
667 let proof_path = temporary.path.join("growing.hyrproof");
668 fs::write(&proof_path, b"initial")?;
669 let file = fs::File::open(&proof_path)?;
670 let initial_metadata = file.metadata()?;
671 let mut writer = fs::OpenOptions::new().append(true).open(&proof_path)?;
672 writer.write_all(b"-growth")?;
673 writer.sync_all()?;
674 drop(writer);
675
676 let mut no_deadline = || Ok(());
677 assert!(matches!(
678 read_open_bounded(file, &initial_metadata, 1024, &mut no_deadline),
679 Err(RetrievalProofError::Invalid {
680 reason: "proof changed while being read",
681 })
682 ));
683 Ok(())
684 }
685
686 #[test]
687 fn retrieval_proof_reader_checks_deadline_between_chunks() -> Result<(), Box<dyn Error>> {
688 let temporary = TestDirectory::create("timed-proof")?;
689 let proof_path = temporary.path.join("timed.hyrproof");
690 fs::write(&proof_path, vec![0_u8; PROOF_READ_BUFFER_BYTES * 2])?;
691 let file = fs::File::open(&proof_path)?;
692 let initial_metadata = file.metadata()?;
693 let mut checks = 0_u8;
694 let mut deadline = || {
695 checks += 1;
696 if checks == 2 {
697 Err(RetrievalProofError::TimedOut)
698 } else {
699 Ok(())
700 }
701 };
702
703 assert!(matches!(
704 read_open_bounded(
705 file,
706 &initial_metadata,
707 MAX_RETRIEVAL_PROOF_BYTES,
708 &mut deadline,
709 ),
710 Err(RetrievalProofError::TimedOut)
711 ));
712 assert_eq!(checks, 2);
713 Ok(())
714 }
715
716 #[test]
717 fn exact_candidate_preflight_rejects_count_and_bytes_before_materialization()
718 -> Result<(), Box<dyn Error>> {
719 let temporary = TestDirectory::create("candidate-preflight")?;
720 let (artifact, _) = create_artifact(&temporary.path.join("data"))?;
721 let snapshot = load_snapshot(&artifact.snapshot.path, &SnapshotReadLimits::default())?;
722
723 let count_limits = RetrievalVerificationLimits {
724 max_candidates: 1,
725 ..RetrievalVerificationLimits::default()
726 };
727 let mut count_checks = 0_u8;
728 let count_result = materialize_exact_candidates(
729 &snapshot,
730 artifact.proof.request(),
731 &count_limits,
732 || {
733 count_checks += 1;
734 Ok(())
735 },
736 );
737 assert!(matches!(
738 count_result,
739 Err(RetrievalProofError::Retrieval { source })
740 if *source
741 == ExactRetrievalError::CandidateBudgetExceeded { maximum: 1 }
742 ));
743 assert_eq!(count_checks, 2);
744
745 let byte_limits = RetrievalVerificationLimits {
746 max_candidate_bytes: 9,
747 ..RetrievalVerificationLimits::default()
748 };
749 let mut byte_checks = 0_u8;
750 let byte_result =
751 materialize_exact_candidates(&snapshot, artifact.proof.request(), &byte_limits, || {
752 byte_checks += 1;
753 Ok(())
754 });
755 assert!(matches!(
756 byte_result,
757 Err(RetrievalProofError::Retrieval { source })
758 if *source
759 == ExactRetrievalError::CandidateByteBudgetExceeded { maximum: 9 }
760 ));
761 assert_eq!(byte_checks, 2);
762 Ok(())
763 }
764
765 #[test]
766 fn exact_candidate_materialization_checks_its_deadline() -> Result<(), Box<dyn Error>> {
767 let temporary = TestDirectory::create("candidate-deadline")?;
768 let (artifact, _) = create_artifact(&temporary.path.join("data"))?;
769 let snapshot = load_snapshot(&artifact.snapshot.path, &SnapshotReadLimits::default())?;
770 let limits = RetrievalVerificationLimits::default();
771 let mut checks = 0_u8;
772
773 let result =
774 materialize_exact_candidates(&snapshot, artifact.proof.request(), &limits, || {
775 checks += 1;
776 if checks == 4 {
777 Err(RetrievalProofError::TimedOut)
778 } else {
779 Ok(())
780 }
781 });
782
783 assert!(matches!(result, Err(RetrievalProofError::TimedOut)));
784 assert_eq!(checks, 4);
785 Ok(())
786 }
787
788 fn request(space: VectorSpaceName) -> Result<ExactRetrievalRequest, Box<dyn Error>> {
789 Ok(ExactRetrievalRequest {
790 vector_space: space,
791 query: Q15Vector::new(vec![32_767, 0])?,
792 limit: 2,
793 minimum_score_nanos: -1_000_000_000,
794 minimum_margin_nanos: 0,
795 })
796 }
797
798 fn create_artifact(
799 root: &std::path::Path,
800 ) -> Result<(crate::ExactRetrievalProofArtifact, ExactRetrievalLimits), Box<dyn Error>> {
801 let space = VectorSpaceName::new("semantic")?;
802 let mut opened = HyphaeEngine::open(root)?;
803 opened.engine.define_vector_space(
804 Uuid::now_v7(),
805 VectorSpaceDefinition::cosine(space.clone(), 2)?,
806 )?;
807 opened.engine.put_vectors(
808 Uuid::now_v7(),
809 &space,
810 &[
811 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0])?),
812 (b"beta".to_vec(), Q15Vector::new(vec![0, 32_767])?),
813 ],
814 )?;
815 let limits = ExactRetrievalLimits {
816 max_candidates: 10,
817 max_candidate_bytes: 1024,
818 max_returned: 10,
819 timeout: Duration::from_secs(1),
820 };
821 let artifact = opened
822 .engine
823 .retrieve_exact_with_proof(&request(space)?, &limits)?;
824 Ok((artifact, limits))
825 }
826
827 fn lexical_record(key: &[u8], title: &str, body: &str) -> Record {
828 Record::new(
829 key,
830 Value::Object(BTreeMap::from([
831 ("title".into(), Value::String(title.into())),
832 ("body".into(), Value::String(body.into())),
833 ])),
834 )
835 }
836
837 #[allow(clippy::type_complexity)]
838 fn create_multimodal_engine(
839 root: &std::path::Path,
840 ) -> Result<
841 (
842 HyphaeEngine,
843 LexicalRequest,
844 LexicalLimits,
845 ExactRetrievalRequest,
846 ExactRetrievalLimits,
847 HybridRequest,
848 ),
849 Box<dyn Error>,
850 > {
851 let lexical_name = VectorSpaceName::new("content")?;
852 let vector_space = VectorSpaceName::new("semantic")?;
853 let mut opened = HyphaeEngine::open(root)?;
854 opened.engine.put_records(
855 Uuid::now_v7(),
856 &[
857 lexical_record(b"alpha", "Durable memory", "offline agent memory"),
858 lexical_record(b"beta", "Fast search", "exact vector retrieval"),
859 ],
860 )?;
861 opened.engine.define_lexical_index(
862 Uuid::now_v7(),
863 LexicalIndexDefinition::new(
864 lexical_name.clone(),
865 vec![
866 LexicalField {
867 path: FieldPath::field("body"),
868 weight_micros: 1_000_000,
869 },
870 LexicalField {
871 path: FieldPath::field("title"),
872 weight_micros: 2_000_000,
873 },
874 ],
875 )?,
876 )?;
877 opened.engine.define_vector_space(
878 Uuid::now_v7(),
879 VectorSpaceDefinition::cosine(vector_space.clone(), 2)?,
880 )?;
881 opened.engine.put_vectors(
882 Uuid::now_v7(),
883 &vector_space,
884 &[
885 (b"alpha".to_vec(), Q15Vector::new(vec![32_767, 0])?),
886 (b"beta".to_vec(), Q15Vector::new(vec![0, 32_767])?),
887 ],
888 )?;
889 Ok((
890 opened.engine,
891 LexicalRequest {
892 index: lexical_name,
893 query: "durable memory".into(),
894 limit: 2,
895 },
896 LexicalLimits {
897 max_documents: 10,
898 max_tokens: 1_000,
899 max_candidates: 10,
900 max_returned: 10,
901 timeout: Duration::from_secs(2),
902 },
903 ExactRetrievalRequest {
904 vector_space,
905 query: Q15Vector::new(vec![32_767, 0])?,
906 limit: 2,
907 minimum_score_nanos: -1_000_000_000,
908 minimum_margin_nanos: 0,
909 },
910 ExactRetrievalLimits {
911 max_candidates: 10,
912 max_candidate_bytes: 1_024,
913 max_returned: 10,
914 timeout: Duration::from_secs(2),
915 },
916 HybridRequest {
917 lexical_weight: 1,
918 vector_weight: 1,
919 limit: 2,
920 },
921 ))
922 }
923
924 #[test]
925 fn exact_proof_verifies_after_originating_directory_is_deleted() -> Result<(), Box<dyn Error>> {
926 let temporary = TestDirectory::create("offline")?;
927 let data = temporary.path.join("data");
928 let portable = temporary.path.join("portable");
929 fs::create_dir_all(&portable)?;
930 let (artifact, _execution_limits) = create_artifact(&data)?;
931 let proof_path = portable.join("result.hyrproof");
932 let witness_path = portable.join("witness.hysnap");
933 write_exact_retrieval_proof(&proof_path, &artifact.proof)?;
934 fs::copy(&artifact.snapshot.path, &witness_path)?;
935 fs::remove_dir_all(&data)?;
936
937 let report = verify_exact_retrieval_proof(
938 &proof_path,
939 &witness_path,
940 artifact.proof.anchor_digest(),
941 &RetrievalVerificationLimits::default(),
942 )?;
943 assert_eq!(report.outcome, artifact.proof.outcome().clone());
944 Ok(())
945 }
946
947 #[test]
948 fn self_consistently_rehashed_request_and_outcome_edits_are_rejected()
949 -> Result<(), Box<dyn Error>> {
950 let temporary = TestDirectory::create("tamper")?;
951 let (artifact, _) = create_artifact(&temporary.path.join("data"))?;
952
953 for mutation in 0..5 {
954 let mut forged = artifact.proof.clone();
955 match mutation {
956 0 => forged.request.query = Q15Vector::new(vec![0, 32_767])?,
957 1 => {
958 let ExactRetrievalOutcome::Matches { matches, .. } = &mut forged.outcome else {
959 return Err("expected matches".into());
960 };
961 matches[0].score_nanos -= 1;
962 }
963 2 => {
964 let ExactRetrievalOutcome::Matches { matches, .. } = &mut forged.outcome else {
965 return Err("expected matches".into());
966 };
967 matches.swap(0, 1);
968 }
969 3 => {
970 let ExactRetrievalOutcome::Matches { matches, .. } = &mut forged.outcome else {
971 return Err("expected matches".into());
972 };
973 matches[0].key = b"forged".to_vec();
974 }
975 4 => forged.request.vector_space = VectorSpaceName::new("other")?,
976 _ => unreachable!(),
977 }
978 let proof_path = temporary.path.join(format!("forged-{mutation}.hyrproof"));
979 if write(&proof_path, &forged).is_err() {
980 continue;
981 }
982 let result = verify_exact_retrieval_proof(
983 &proof_path,
984 &artifact.snapshot.path,
985 artifact.proof.anchor_digest(),
986 &RetrievalVerificationLimits::default(),
987 );
988 assert!(result.is_err(), "mutation {mutation} unexpectedly verified");
989 }
990 Ok(())
991 }
992
993 #[test]
994 fn wrong_witness_anchor_and_semantics_are_rejected() -> Result<(), Box<dyn Error>> {
995 let temporary = TestDirectory::create("binding")?;
996 let (artifact, _) = create_artifact(&temporary.path.join("data"))?;
997 let proof_path = temporary.path.join("proof.hyrproof");
998 write(&proof_path, &artifact.proof)?;
999
1000 assert!(matches!(
1001 verify_exact_retrieval_proof(
1002 &proof_path,
1003 &artifact.snapshot.path,
1004 [0; 32],
1005 &RetrievalVerificationLimits::default(),
1006 ),
1007 Err(RetrievalProofError::AnchorMismatch)
1008 ));
1009
1010 let mut encoded = fs::read(&proof_path)?;
1011 encoded[14..16].copy_from_slice(&99_u16.to_le_bytes());
1012 let semantics_path = temporary.path.join("semantics.hyrproof");
1013 fs::write(&semantics_path, encoded)?;
1014 assert!(matches!(
1015 super::read_exact_retrieval_proof(&semantics_path, u64::MAX),
1016 Err(RetrievalProofError::UnsupportedSemantics { found: 99, .. })
1017 ));
1018 Ok(())
1019 }
1020
1021 #[test]
1022 fn lexical_and_hybrid_proofs_verify_without_the_originating_directory()
1023 -> Result<(), Box<dyn Error>> {
1024 let temporary = TestDirectory::create("multimodal-offline")?;
1025 let data = temporary.path.join("data");
1026 let portable = temporary.path.join("portable");
1027 fs::create_dir_all(&portable)?;
1028 let (
1029 engine,
1030 lexical_request,
1031 lexical_limits,
1032 vector_request,
1033 vector_limits,
1034 hybrid_request,
1035 ) = create_multimodal_engine(&data)?;
1036 let lexical = engine.retrieve_lexical_with_proof(&lexical_request, &lexical_limits)?;
1037 let hybrid = engine.retrieve_hybrid_with_proof(
1038 &lexical_request,
1039 &lexical_limits,
1040 &vector_request,
1041 &vector_limits,
1042 &hybrid_request,
1043 )?;
1044 let lexical_proof = portable.join("lexical.hyrproof");
1045 let hybrid_proof = portable.join("hybrid.hyrproof");
1046 let witness = portable.join("witness.hysnap");
1047 write_lexical_retrieval_proof(&lexical_proof, &lexical.proof)?;
1048 write_hybrid_retrieval_proof(&hybrid_proof, &hybrid.proof)?;
1049 fs::copy(&hybrid.snapshot.path, &witness)?;
1050 drop(engine);
1051 fs::remove_dir_all(&data)?;
1052
1053 let lexical_report = verify_lexical_retrieval_proof(
1054 &lexical_proof,
1055 &witness,
1056 lexical.proof.anchor_digest(),
1057 &RetrievalVerificationLimits::default(),
1058 )?;
1059 let hybrid_report = verify_hybrid_retrieval_proof(
1060 &hybrid_proof,
1061 &witness,
1062 hybrid.proof.anchor_digest(),
1063 &RetrievalVerificationLimits::default(),
1064 )?;
1065 assert_eq!(lexical_report.outcome, lexical.proof.outcome().clone());
1066 assert_eq!(hybrid_report.outcome, hybrid.proof.outcome().clone());
1067 Ok(())
1068 }
1069
1070 #[test]
1071 fn lexical_and_hybrid_self_consistent_tampering_fails_reexecution() -> Result<(), Box<dyn Error>>
1072 {
1073 let temporary = TestDirectory::create("multimodal-tamper")?;
1074 let (
1075 engine,
1076 lexical_request,
1077 lexical_limits,
1078 vector_request,
1079 vector_limits,
1080 hybrid_request,
1081 ) = create_multimodal_engine(&temporary.path.join("data"))?;
1082 let lexical = engine.retrieve_lexical_with_proof(&lexical_request, &lexical_limits)?;
1083 let hybrid = engine.retrieve_hybrid_with_proof(
1084 &lexical_request,
1085 &lexical_limits,
1086 &vector_request,
1087 &vector_limits,
1088 &hybrid_request,
1089 )?;
1090
1091 let mut forged_lexical = lexical.proof.clone();
1092 let hyphae_retrieval::LexicalOutcome::Matches { matches, .. } = &mut forged_lexical.outcome
1093 else {
1094 return Err("expected lexical matches".into());
1095 };
1096 matches[0].score_nanos -= 1;
1097 matches[0].terms[0].score_nanos -= 1;
1098 let lexical_path = temporary.path.join("forged-lexical.hyrproof");
1099 write_lexical_retrieval_proof(&lexical_path, &forged_lexical)?;
1100 assert!(
1101 verify_lexical_retrieval_proof(
1102 &lexical_path,
1103 &lexical.snapshot.path,
1104 lexical.proof.anchor_digest(),
1105 &RetrievalVerificationLimits::default(),
1106 )
1107 .is_err()
1108 );
1109
1110 let mut forged_hybrid = hybrid.proof.clone();
1111 forged_hybrid.vector_request.query = Q15Vector::new(vec![0, 32_767])?;
1112 let hybrid_path = temporary.path.join("forged-hybrid.hyrproof");
1113 write_hybrid_retrieval_proof(&hybrid_path, &forged_hybrid)?;
1114 assert!(
1115 verify_hybrid_retrieval_proof(
1116 &hybrid_path,
1117 &hybrid.snapshot.path,
1118 hybrid.proof.anchor_digest(),
1119 &RetrievalVerificationLimits::default(),
1120 )
1121 .is_err()
1122 );
1123 Ok(())
1124 }
1125}