1use std::path::{Path, PathBuf};
8
9use graphforge_core::GfError;
10
11pub const SEARCH_MANIFEST_VERSION: u32 = 1;
13pub const MAX_SEARCH_MANIFEST_BYTES: usize = 64 * 1024;
15pub const MAX_SEARCH_SELECTOR_BYTES: usize = 128;
17pub const MAX_SEARCH_ARTIFACT_KEY_BYTES: usize = 384;
19
20#[derive(Debug, thiserror::Error)]
22pub enum SearchArtifactError {
23 #[error("invalid search {field}: {reason}")]
25 InvalidSelector {
26 field: &'static str,
28 reason: String,
30 },
31 #[error("search artifact is missing at {}", path.display())]
33 Missing {
34 path: PathBuf,
36 },
37 #[error("corrupt search manifest at {}: {reason}", path.display())]
39 CorruptManifest {
40 path: PathBuf,
42 reason: String,
44 },
45 #[error("corrupt derived search index at {}: {reason}", path.display())]
47 CorruptDerivedIndex {
48 path: PathBuf,
50 reason: String,
52 },
53 #[error(
55 "incompatible search manifest at {}: version {found}, supported {supported}",
56 path.display()
57 )]
58 IncompatibleManifest {
59 path: PathBuf,
61 found: u64,
63 supported: u32,
65 },
66 #[error("stale search artifact: {reason}")]
68 Stale {
69 reason: String,
71 },
72 #[error("corrupt primary vector data at {}: {reason}", path.display())]
74 CorruptPrimaryVectors {
75 path: PathBuf,
77 reason: String,
79 },
80 #[error("graph changed during both search publication attempts")]
82 ConcurrentMutation,
83 #[error("search writer lock failed at {}: {reason}", path.display())]
85 Lock {
86 path: PathBuf,
88 reason: String,
90 },
91 #[error("search operation cancelled")]
93 Cancelled,
94 #[error("search resource limit exceeded for {resource}: limit {limit}")]
96 ResourceExhausted {
97 resource: &'static str,
99 limit: u64,
101 },
102 #[error("search artifact build failed: {0}")]
104 Build(String),
105 #[error("search source snapshot failed: {reason}")]
107 SourceSnapshot {
108 reason: String,
110 },
111 #[error("search storage {operation} failed at {}: {source}", path.display())]
113 Io {
114 operation: &'static str,
116 path: PathBuf,
118 #[source]
120 source: std::io::Error,
121 },
122}
123
124impl From<SearchArtifactError> for GfError {
125 fn from(error: SearchArtifactError) -> Self {
126 let message = error.to_string();
127 match error {
128 SearchArtifactError::InvalidSelector { .. } => Self::Validation(message),
129 SearchArtifactError::Cancelled
130 | SearchArtifactError::ResourceExhausted { .. }
131 | SearchArtifactError::Build(_) => Self::Execution(message),
132 SearchArtifactError::ConcurrentMutation => Self::Lifecycle(message),
133 SearchArtifactError::Missing { .. }
134 | SearchArtifactError::CorruptManifest { .. }
135 | SearchArtifactError::CorruptDerivedIndex { .. }
136 | SearchArtifactError::IncompatibleManifest { .. }
137 | SearchArtifactError::Stale { .. }
138 | SearchArtifactError::CorruptPrimaryVectors { .. }
139 | SearchArtifactError::SourceSnapshot { .. }
140 | SearchArtifactError::Lock { .. }
141 | SearchArtifactError::Io { .. } => Self::Storage(message),
142 }
143 }
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub enum SearchIndexKind {
149 Text,
151 Vector,
153}
154
155impl SearchIndexKind {
156 fn as_str(self) -> &'static str {
157 match self {
158 Self::Text => "text",
159 Self::Vector => "vector",
160 }
161 }
162
163 fn parse(value: &str, path: &Path) -> Result<Self, SearchArtifactError> {
164 match value {
165 "text" => Ok(Self::Text),
166 "vector" => Ok(Self::Vector),
167 other => Err(corrupt(path, format!("unknown index_kind {other:?}"))),
168 }
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct SearchArtifactKey {
175 kind: SearchIndexKind,
176 label: String,
177 properties: Option<Vec<String>>,
178 space: Option<String>,
179}
180
181impl SearchArtifactKey {
182 pub fn text<I, S>(label: &str, properties: I) -> Result<Self, SearchArtifactError>
188 where
189 I: IntoIterator<Item = S>,
190 S: AsRef<str>,
191 {
192 let label = normalize_selector("label", label)?;
193 let mut properties = properties
194 .into_iter()
195 .map(|property| normalize_selector("property", property.as_ref()))
196 .collect::<Result<Vec<_>, _>>()?;
197 properties.sort_unstable();
198 properties.dedup();
199 if properties.is_empty() {
200 return Err(invalid("properties", "at least one property is required"));
201 }
202 let key = Self {
203 kind: SearchIndexKind::Text,
204 label,
205 properties: Some(properties),
206 space: None,
207 };
208 key.validate_total_size()?;
209 Ok(key)
210 }
211
212 pub fn vector(label: &str, space: &str) -> Result<Self, SearchArtifactError> {
217 let key = Self {
218 kind: SearchIndexKind::Vector,
219 label: normalize_selector("label", label)?,
220 properties: None,
221 space: Some(normalize_selector("space", space)?),
222 };
223 key.validate_total_size()?;
224 Ok(key)
225 }
226
227 #[must_use]
229 pub const fn kind(&self) -> SearchIndexKind {
230 self.kind
231 }
232
233 #[must_use]
235 pub fn label(&self) -> &str {
236 &self.label
237 }
238
239 #[must_use]
241 pub fn properties(&self) -> Option<&[String]> {
242 self.properties.as_deref()
243 }
244
245 #[must_use]
247 pub fn space(&self) -> Option<&str> {
248 self.space.as_deref()
249 }
250
251 #[must_use]
253 pub fn artifact_root(&self, project_dir: &Path) -> PathBuf {
254 match self.kind {
255 SearchIndexKind::Text => {
256 let mut path = project_dir.join("indexes").join("search").join("text");
257 push_encoded(&mut path, "label", self.label.as_bytes());
258 let properties = self
259 .properties
260 .as_deref()
261 .expect("text keys always carry properties");
262 push_encoded(&mut path, "properties", &encode_sequence(properties));
263 path
264 }
265 SearchIndexKind::Vector => {
266 let mut path = project_dir.join("embeddings");
267 push_encoded(
268 &mut path,
269 "space",
270 self.space
271 .as_deref()
272 .expect("vector keys always carry a space")
273 .as_bytes(),
274 );
275 push_encoded(&mut path, "label", self.label.as_bytes());
276 path
277 }
278 }
279 }
280
281 fn validate_total_size(&self) -> Result<(), SearchArtifactError> {
282 let property_bytes = self
283 .properties
284 .as_deref()
285 .unwrap_or_default()
286 .iter()
287 .map(String::len)
288 .sum::<usize>();
289 let bytes = self.label.len() + property_bytes + self.space.as_deref().map_or(0, str::len);
290 if bytes > MAX_SEARCH_ARTIFACT_KEY_BYTES {
291 return Err(invalid(
292 "artifact key",
293 format!("{bytes} normalized bytes exceeds {MAX_SEARCH_ARTIFACT_KEY_BYTES}"),
294 ));
295 }
296 Ok(())
297 }
298}
299
300#[derive(Clone, Debug, PartialEq, Eq)]
302pub struct SearchSourceSnapshot {
303 pub generation: u64,
305 pub fingerprint: String,
307}
308
309impl SearchSourceSnapshot {
310 pub fn capture(
317 project_dir: &Path,
318 parts: &[SearchSourcePart<'_>],
319 ) -> Result<Self, SearchArtifactError> {
320 let generation =
321 crate::generation::read_search_generation(project_dir).map_err(|error| {
322 SearchArtifactError::SourceSnapshot {
323 reason: error.to_string(),
324 }
325 })?;
326 let fingerprint = canonical_source_fingerprint(parts)?;
327 Ok(Self {
328 generation,
329 fingerprint,
330 })
331 }
332}
333
334#[derive(Clone, Copy, Debug)]
336pub struct SearchSourcePart<'a> {
337 pub name: &'a str,
339 pub bytes: &'a [u8],
341}
342
343pub fn canonical_source_fingerprint(
353 parts: &[SearchSourcePart<'_>],
354) -> Result<String, SearchArtifactError> {
355 let mut ordered = parts.to_vec();
356 ordered.sort_unstable_by(|left, right| left.name.as_bytes().cmp(right.name.as_bytes()));
357 for pair in ordered.windows(2) {
358 if pair[0].name == pair[1].name {
359 return Err(invalid(
360 "source fingerprint",
361 format!("duplicate source part {:?}", pair[0].name),
362 ));
363 }
364 }
365
366 let mut states = [
367 0xcbf2_9ce4_8422_2325_u64,
368 0x8422_2325_cbf2_9ce4_u64,
369 0x9e37_79b9_7f4a_7c15_u64,
370 0xd6e8_feb8_6659_fd93_u64,
371 ];
372 for part in ordered {
373 let name = normalize_source_name(part.name)?;
374 hash_frame(&mut states, name.as_bytes());
375 hash_frame(&mut states, part.bytes);
376 }
377 Ok(format!(
378 "gf-fnv1a256:{:016x}{:016x}{:016x}{:016x}",
379 states[0], states[1], states[2], states[3]
380 ))
381}
382
383#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct SearchManifest {
386 pub manifest_version: u32,
388 pub index_kind: SearchIndexKind,
390 pub backend_version: String,
392 pub label: String,
394 pub properties: Option<Vec<String>>,
396 pub space: Option<String>,
398 pub dimension: Option<u32>,
400 pub source_generation: u64,
402 pub source_fingerprint: String,
404 pub contract_version: String,
406 pub completed: bool,
408}
409
410impl SearchManifest {
411 pub fn for_key(
416 key: &SearchArtifactKey,
417 backend_version: &str,
418 contract_version: &str,
419 dimension: Option<u32>,
420 source: &SearchSourceSnapshot,
421 completed: bool,
422 ) -> Result<Self, SearchArtifactError> {
423 let backend_version = normalize_version("backend_version", backend_version)?;
424 let contract_version = normalize_version("contract_version", contract_version)?;
425 if !canonical_fingerprint(&source.fingerprint) {
426 return Err(invalid(
427 "source_fingerprint",
428 "must be gf-fnv1a256 followed by 64 lowercase hexadecimal digits",
429 ));
430 }
431 match (key.kind, dimension) {
432 (SearchIndexKind::Text, None) => {}
433 (SearchIndexKind::Vector, Some(value)) if value > 0 => {}
434 (SearchIndexKind::Text, Some(_)) => {
435 return Err(invalid("dimension", "text manifests omit dimension"));
436 }
437 (SearchIndexKind::Vector, _) => {
438 return Err(invalid(
439 "dimension",
440 "vector manifests require a non-zero dimension",
441 ));
442 }
443 }
444 Ok(Self {
445 manifest_version: SEARCH_MANIFEST_VERSION,
446 index_kind: key.kind,
447 backend_version,
448 label: key.label.clone(),
449 properties: key.properties.clone(),
450 space: key.space.clone(),
451 dimension,
452 source_generation: source.generation,
453 source_fingerprint: source.fingerprint.clone(),
454 contract_version,
455 completed,
456 })
457 }
458
459 pub fn to_canonical_json(&self) -> Result<Vec<u8>, SearchArtifactError> {
466 let mut value = serde_json::Map::new();
467 value.insert(
468 "manifest_version".to_owned(),
469 serde_json::Value::from(self.manifest_version),
470 );
471 value.insert(
472 "index_kind".to_owned(),
473 serde_json::Value::from(self.index_kind.as_str()),
474 );
475 value.insert(
476 "backend_version".to_owned(),
477 serde_json::Value::from(self.backend_version.clone()),
478 );
479 value.insert(
480 "label".to_owned(),
481 serde_json::Value::from(self.label.clone()),
482 );
483 if let Some(properties) = &self.properties {
484 value.insert(
485 "properties".to_owned(),
486 serde_json::Value::Array(
487 properties
488 .iter()
489 .cloned()
490 .map(serde_json::Value::from)
491 .collect(),
492 ),
493 );
494 }
495 if let Some(space) = &self.space {
496 value.insert("space".to_owned(), serde_json::Value::from(space.clone()));
497 }
498 if let Some(dimension) = self.dimension {
499 value.insert("dimension".to_owned(), serde_json::Value::from(dimension));
500 }
501 value.insert(
502 "source_generation".to_owned(),
503 serde_json::Value::from(self.source_generation),
504 );
505 value.insert(
506 "source_fingerprint".to_owned(),
507 serde_json::Value::from(self.source_fingerprint.clone()),
508 );
509 value.insert(
510 "contract_version".to_owned(),
511 serde_json::Value::from(self.contract_version.clone()),
512 );
513 value.insert(
514 "completed".to_owned(),
515 serde_json::Value::from(self.completed),
516 );
517 serde_json::to_vec(&serde_json::Value::Object(value)).map_err(|error| {
518 SearchArtifactError::CorruptManifest {
519 path: PathBuf::from("<memory>"),
520 reason: error.to_string(),
521 }
522 })
523 }
524
525 pub fn from_json(path: &Path, bytes: &[u8]) -> Result<Self, SearchArtifactError> {
530 if bytes.len() > MAX_SEARCH_MANIFEST_BYTES {
531 return Err(SearchArtifactError::ResourceExhausted {
532 resource: "manifest_bytes",
533 limit: MAX_SEARCH_MANIFEST_BYTES as u64,
534 });
535 }
536 let value: serde_json::Value =
537 serde_json::from_slice(bytes).map_err(|error| corrupt(path, error.to_string()))?;
538 let object = value
539 .as_object()
540 .ok_or_else(|| corrupt(path, "expected a JSON object"))?;
541 let found = required_u64(object, "manifest_version", path)?;
542 if found != u64::from(SEARCH_MANIFEST_VERSION) {
543 return Err(SearchArtifactError::IncompatibleManifest {
544 path: path.to_path_buf(),
545 found,
546 supported: SEARCH_MANIFEST_VERSION,
547 });
548 }
549 let kind = SearchIndexKind::parse(required_str(object, "index_kind", path)?, path)?;
550 let label = required_str(object, "label", path)?;
551 let properties = optional_strings(object, "properties", path)?;
552 let space = optional_str(object, "space", path)?;
553 let key = match kind {
554 SearchIndexKind::Text => SearchArtifactKey::text(
555 label,
556 properties
557 .as_deref()
558 .ok_or_else(|| corrupt(path, "text manifest omits properties"))?,
559 )
560 .map_err(|error| corrupt(path, error.to_string()))?,
561 SearchIndexKind::Vector => SearchArtifactKey::vector(
562 label,
563 space.ok_or_else(|| corrupt(path, "vector manifest omits space"))?,
564 )
565 .map_err(|error| corrupt(path, error.to_string()))?,
566 };
567 if key.label() != label || key.properties() != properties.as_deref() || key.space() != space
568 {
569 return Err(corrupt(
570 path,
571 "artifact selectors are not in canonical normalized order",
572 ));
573 }
574 let backend_version = required_str(object, "backend_version", path)?;
575 let contract_version = required_str(object, "contract_version", path)?;
576 let dimension = optional_u64(object, "dimension", path)?
577 .map(|value| {
578 u32::try_from(value).map_err(|_| corrupt(path, "dimension exceeds u32 range"))
579 })
580 .transpose()?;
581 let source = SearchSourceSnapshot {
582 generation: required_u64(object, "source_generation", path)?,
583 fingerprint: required_str(object, "source_fingerprint", path)?.to_owned(),
584 };
585 let manifest = Self::for_key(
586 &key,
587 backend_version,
588 contract_version,
589 dimension,
590 &source,
591 required_bool(object, "completed", path)?,
592 )
593 .map_err(|error| corrupt(path, error.to_string()))?;
594 if manifest.backend_version != backend_version
595 || manifest.contract_version != contract_version
596 {
597 return Err(corrupt(path, "version fields are not normalized"));
598 }
599 Ok(manifest)
600 }
601
602 pub fn verify_fresh(
610 &self,
611 key: &SearchArtifactKey,
612 backend_version: &str,
613 contract_version: &str,
614 dimension: Option<u32>,
615 source: &SearchSourceSnapshot,
616 ) -> Result<(), SearchArtifactError> {
617 let expected = Self::for_key(
618 key,
619 backend_version,
620 contract_version,
621 dimension,
622 source,
623 true,
624 )?;
625 let reason = if !self.completed {
626 Some("manifest is incomplete".to_owned())
627 } else if self.index_kind != expected.index_kind
628 || self.label != expected.label
629 || self.properties != expected.properties
630 || self.space != expected.space
631 {
632 Some("normalized artifact key changed".to_owned())
633 } else if self.backend_version != expected.backend_version {
634 Some("backend version changed".to_owned())
635 } else if self.contract_version != expected.contract_version {
636 Some("search contract version changed".to_owned())
637 } else if self.dimension != expected.dimension {
638 Some("vector dimension changed".to_owned())
639 } else if self.source_generation != expected.source_generation {
640 Some(format!(
641 "source generation changed from {} to {}",
642 self.source_generation, expected.source_generation
643 ))
644 } else if self.source_fingerprint != expected.source_fingerprint {
645 Some("source fingerprint changed".to_owned())
646 } else {
647 None
648 };
649 match reason {
650 Some(reason) => Err(SearchArtifactError::Stale { reason }),
651 None => Ok(()),
652 }
653 }
654}
655
656fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
657 SearchArtifactError::InvalidSelector {
658 field,
659 reason: reason.into(),
660 }
661}
662
663fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
664 SearchArtifactError::CorruptManifest {
665 path: path.to_path_buf(),
666 reason: reason.into(),
667 }
668}
669
670fn normalize_selector(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
671 let value = value.trim();
672 if value.is_empty() {
673 return Err(invalid(field, "must not be empty"));
674 }
675 if value.chars().any(char::is_control) {
676 return Err(invalid(field, "must not contain control characters"));
677 }
678 if value.len() > MAX_SEARCH_SELECTOR_BYTES {
679 return Err(invalid(
680 field,
681 format!(
682 "{} UTF-8 bytes exceeds {MAX_SEARCH_SELECTOR_BYTES}",
683 value.len()
684 ),
685 ));
686 }
687 Ok(value.to_owned())
688}
689
690fn normalize_source_name(value: &str) -> Result<&str, SearchArtifactError> {
691 if value.is_empty() || value.chars().any(char::is_control) {
692 return Err(invalid(
693 "source fingerprint",
694 "logical part names must be non-empty and control-free",
695 ));
696 }
697 Ok(value)
698}
699
700fn normalize_version(field: &'static str, value: &str) -> Result<String, SearchArtifactError> {
701 normalize_selector(field, value)
702}
703
704fn canonical_fingerprint(value: &str) -> bool {
705 value.strip_prefix("gf-fnv1a256:").is_some_and(|digest| {
706 digest.len() == 64
707 && digest
708 .bytes()
709 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
710 })
711}
712
713fn encode_sequence(values: &[String]) -> Vec<u8> {
714 let mut bytes = Vec::new();
715 let value_count =
716 u32::try_from(values.len()).expect("validated search keys contain a bounded item count");
717 bytes.extend_from_slice(&value_count.to_be_bytes());
718 for value in values {
719 let value_len =
720 u32::try_from(value.len()).expect("validated search selectors have bounded lengths");
721 bytes.extend_from_slice(&value_len.to_be_bytes());
722 bytes.extend_from_slice(value.as_bytes());
723 }
724 bytes
725}
726
727fn push_encoded(path: &mut PathBuf, field: &str, bytes: &[u8]) {
728 path.push(field);
729 let encoded = hex(bytes);
730 for (index, chunk) in encoded.as_bytes().chunks(120).enumerate() {
731 let chunk = std::str::from_utf8(chunk).expect("hex is always UTF-8");
732 path.push(format!("{index:04}-{chunk}"));
733 }
734}
735
736fn hex(bytes: &[u8]) -> String {
737 const DIGITS: &[u8; 16] = b"0123456789abcdef";
738 let mut encoded = String::with_capacity(bytes.len() * 2);
739 for byte in bytes {
740 encoded.push(char::from(DIGITS[usize::from(byte >> 4)]));
741 encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
742 }
743 encoded
744}
745
746fn hash_frame(states: &mut [u64; 4], bytes: &[u8]) {
747 for state in &mut *states {
748 for byte in (bytes.len() as u64).to_be_bytes().iter().chain(bytes) {
749 *state ^= u64::from(*byte);
750 *state = state.wrapping_mul(0x0000_0100_0000_01b3);
751 }
752 }
753 for (index, state) in states.iter_mut().enumerate() {
754 *state ^= (index as u64 + 1) * 0x9e37_79b9;
755 }
756}
757
758fn required_str<'a>(
759 object: &'a serde_json::Map<String, serde_json::Value>,
760 field: &str,
761 path: &Path,
762) -> Result<&'a str, SearchArtifactError> {
763 object
764 .get(field)
765 .and_then(serde_json::Value::as_str)
766 .ok_or_else(|| corrupt(path, format!("missing or non-string {field}")))
767}
768
769fn optional_str<'a>(
770 object: &'a serde_json::Map<String, serde_json::Value>,
771 field: &str,
772 path: &Path,
773) -> Result<Option<&'a str>, SearchArtifactError> {
774 object
775 .get(field)
776 .map(|value| {
777 value
778 .as_str()
779 .ok_or_else(|| corrupt(path, format!("{field} must be a string")))
780 })
781 .transpose()
782}
783
784fn required_u64(
785 object: &serde_json::Map<String, serde_json::Value>,
786 field: &str,
787 path: &Path,
788) -> Result<u64, SearchArtifactError> {
789 object
790 .get(field)
791 .and_then(serde_json::Value::as_u64)
792 .ok_or_else(|| corrupt(path, format!("missing or non-u64 {field}")))
793}
794
795fn optional_u64(
796 object: &serde_json::Map<String, serde_json::Value>,
797 field: &str,
798 path: &Path,
799) -> Result<Option<u64>, SearchArtifactError> {
800 object
801 .get(field)
802 .map(|value| {
803 value
804 .as_u64()
805 .ok_or_else(|| corrupt(path, format!("{field} must be a u64")))
806 })
807 .transpose()
808}
809
810fn required_bool(
811 object: &serde_json::Map<String, serde_json::Value>,
812 field: &str,
813 path: &Path,
814) -> Result<bool, SearchArtifactError> {
815 object
816 .get(field)
817 .and_then(serde_json::Value::as_bool)
818 .ok_or_else(|| corrupt(path, format!("missing or non-boolean {field}")))
819}
820
821fn optional_strings(
822 object: &serde_json::Map<String, serde_json::Value>,
823 field: &str,
824 path: &Path,
825) -> Result<Option<Vec<String>>, SearchArtifactError> {
826 object
827 .get(field)
828 .map(|value| {
829 let values = value
830 .as_array()
831 .ok_or_else(|| corrupt(path, format!("{field} must be an array")))?;
832 values
833 .iter()
834 .map(|value| {
835 value
836 .as_str()
837 .map(str::to_owned)
838 .ok_or_else(|| corrupt(path, format!("{field} must contain strings")))
839 })
840 .collect()
841 })
842 .transpose()
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 fn source() -> SearchSourceSnapshot {
850 SearchSourceSnapshot {
851 generation: 7,
852 fingerprint: format!("gf-fnv1a256:{:064x}", 7),
853 }
854 }
855
856 #[test]
857 fn key_normalization_and_paths_are_stable_and_safe() {
858 let key = SearchArtifactKey::text(" Person ", [" bio ", "name", "bio"]).unwrap();
859 assert_eq!(key.label(), "Person");
860 assert_eq!(
861 key.properties().unwrap(),
862 &["bio".to_owned(), "name".to_owned()]
863 );
864 let path = key.artifact_root(Path::new("/project"));
865 let rendered = path.to_string_lossy();
866 assert!(rendered.starts_with("/project/indexes/search/text/label/"));
867 assert!(!rendered.contains("Person"));
868 assert!(!rendered.contains("bio"));
869
870 let vector = SearchArtifactKey::vector("Person", " model/v1 ").unwrap();
871 let rendered = vector
872 .artifact_root(Path::new("/project"))
873 .to_string_lossy()
874 .into_owned();
875 assert!(rendered.starts_with("/project/embeddings/space/"));
876 assert!(!rendered.contains("model/v1"));
877 }
878
879 #[test]
880 fn key_encoding_is_collision_free_for_framed_properties() {
881 let left = SearchArtifactKey::text("L", ["ab", "c"]).unwrap();
882 let right = SearchArtifactKey::text("L", ["a", "bc"]).unwrap();
883 assert_ne!(
884 left.artifact_root(Path::new("/p")),
885 right.artifact_root(Path::new("/p"))
886 );
887 }
888
889 #[test]
890 fn invalid_selectors_and_key_budget_are_structured() {
891 assert!(matches!(
892 SearchArtifactKey::vector("", "space"),
893 Err(SearchArtifactError::InvalidSelector { field: "label", .. })
894 ));
895 assert!(matches!(
896 SearchArtifactKey::vector("Person", "bad\nspace"),
897 Err(SearchArtifactError::InvalidSelector { field: "space", .. })
898 ));
899 let long = "x".repeat(MAX_SEARCH_SELECTOR_BYTES + 1);
900 assert!(SearchArtifactKey::text("Person", [&long]).is_err());
901 }
902
903 #[test]
904 fn fingerprint_is_order_independent_and_content_sensitive() {
905 let a = SearchSourcePart {
906 name: "properties/Person",
907 bytes: b"alice",
908 };
909 let b = SearchSourcePart {
910 name: "topology/nodes",
911 bytes: b"uuid",
912 };
913 assert_eq!(
914 canonical_source_fingerprint(&[a, b]).unwrap(),
915 canonical_source_fingerprint(&[b, a]).unwrap()
916 );
917 assert_ne!(
918 canonical_source_fingerprint(&[a]).unwrap(),
919 canonical_source_fingerprint(&[SearchSourcePart {
920 name: a.name,
921 bytes: b"bob",
922 }])
923 .unwrap()
924 );
925 assert!(canonical_source_fingerprint(&[a, a]).is_err());
926 }
927
928 #[test]
929 fn source_snapshot_rejects_corrupt_generation_and_noncanonical_fingerprint() {
930 let dir = tempfile::TempDir::new().unwrap();
931 let generation = crate::generation::generation_path(dir.path());
932 std::fs::create_dir_all(generation.parent().unwrap()).unwrap();
933 std::fs::write(&generation, b"corrupt").unwrap();
934 assert!(matches!(
935 SearchSourceSnapshot::capture(dir.path(), &[]),
936 Err(SearchArtifactError::SourceSnapshot { .. })
937 ));
938
939 let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
940 let source = SearchSourceSnapshot {
941 generation: 1,
942 fingerprint: "not-canonical".to_owned(),
943 };
944 assert!(matches!(
945 SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source, true),
946 Err(SearchArtifactError::InvalidSelector {
947 field: "source_fingerprint",
948 ..
949 })
950 ));
951 }
952
953 #[test]
954 fn text_manifest_round_trips_canonical_json() {
955 let key = SearchArtifactKey::text("Person", ["name", "bio"]).unwrap();
956 let manifest = SearchManifest::for_key(
957 &key,
958 "tantivy-0.25",
959 "graphforge_text_v1",
960 None,
961 &source(),
962 true,
963 )
964 .unwrap();
965 let bytes = manifest.to_canonical_json().unwrap();
966 let parsed = SearchManifest::from_json(Path::new("manifest.json"), &bytes).unwrap();
967 assert_eq!(parsed, manifest);
968 let json = String::from_utf8(bytes).unwrap();
969 assert!(json.contains(r#""properties":["bio","name"]"#));
970 assert!(!json.contains(r#""space""#));
971 assert!(!json.contains(r#""dimension""#));
972 let noncanonical = json.replace(r#""label":"Person""#, r#""label":" Person ""#);
973 assert!(matches!(
974 SearchManifest::from_json(Path::new("manifest.json"), noncanonical.as_bytes()),
975 Err(SearchArtifactError::CorruptManifest { .. })
976 ));
977 }
978
979 #[test]
980 fn vector_manifest_requires_dimension_and_omits_properties() {
981 let key = SearchArtifactKey::vector("Person", "semantic").unwrap();
982 assert!(
983 SearchManifest::for_key(&key, "exact-cosine-v1", "vector-v1", None, &source(), true)
984 .is_err()
985 );
986 let manifest = SearchManifest::for_key(
987 &key,
988 "exact-cosine-v1",
989 "vector-v1",
990 Some(3),
991 &source(),
992 true,
993 )
994 .unwrap();
995 let json = String::from_utf8(manifest.to_canonical_json().unwrap()).unwrap();
996 assert!(json.contains(r#""dimension":3"#));
997 assert!(json.contains(r#""space":"semantic""#));
998 assert!(!json.contains(r#""properties""#));
999 assert!(matches!(
1000 manifest.verify_fresh(&key, "exact-cosine-v1", "vector-v1", Some(4), &source(),),
1001 Err(SearchArtifactError::Stale { .. })
1002 ));
1003 }
1004
1005 #[test]
1006 fn parsing_distinguishes_corrupt_incompatible_and_oversized() {
1007 let path = Path::new("manifest.json");
1008 assert!(matches!(
1009 SearchManifest::from_json(path, b"no"),
1010 Err(SearchArtifactError::CorruptManifest { .. })
1011 ));
1012 assert!(matches!(
1013 SearchManifest::from_json(path, br#"{"manifest_version":99}"#),
1014 Err(SearchArtifactError::IncompatibleManifest { found: 99, .. })
1015 ));
1016 assert!(matches!(
1017 SearchManifest::from_json(path, &vec![b' '; MAX_SEARCH_MANIFEST_BYTES + 1]),
1018 Err(SearchArtifactError::ResourceExhausted {
1019 resource: "manifest_bytes",
1020 ..
1021 })
1022 ));
1023 }
1024
1025 #[test]
1026 fn freshness_checks_completion_versions_generation_and_fingerprint() {
1027 let key = SearchArtifactKey::text("Person", ["name"]).unwrap();
1028 let mut manifest =
1029 SearchManifest::for_key(&key, "tantivy-0.25", "text-v1", None, &source(), true)
1030 .unwrap();
1031 manifest
1032 .verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source())
1033 .unwrap();
1034
1035 manifest.completed = false;
1036 assert!(matches!(
1037 manifest.verify_fresh(&key, "tantivy-0.25", "text-v1", None, &source()),
1038 Err(SearchArtifactError::Stale { .. })
1039 ));
1040 manifest.completed = true;
1041 let changed = SearchSourceSnapshot {
1042 generation: 8,
1043 fingerprint: source().fingerprint,
1044 };
1045 assert!(
1046 manifest
1047 .verify_fresh(&key, "tantivy-0.25", "text-v1", None, &changed)
1048 .is_err()
1049 );
1050 }
1051}