1use std::collections::BTreeMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use unicode_normalization::UnicodeNormalization;
11
12use crate::SearchArtifactError;
13
14pub const EMBEDDING_IDENTITY_VERSION: u32 = 1;
16pub const MAX_EMBEDDING_DISPLAY_NAME_BYTES: usize = 255;
18const MAX_IDENTITY_TEXT_BYTES: usize = 1_024;
19const MAX_IDENTITY_JSON_BYTES: usize = 64 * 1024;
20const MAX_IDENTITY_JSON_DEPTH: usize = 32;
21
22#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct EmbeddingDisplayName(String);
25
26impl EmbeddingDisplayName {
27 pub fn new(value: &str) -> Result<Self, SearchArtifactError> {
32 let normalized: String = value.nfc().collect();
33 if normalized.is_empty() {
34 return Err(invalid("embedding display name", "must not be empty"));
35 }
36 if normalized.trim() != normalized {
37 return Err(invalid(
38 "embedding display name",
39 "must not have leading or trailing whitespace",
40 ));
41 }
42 if normalized.chars().any(char::is_control) {
43 return Err(invalid(
44 "embedding display name",
45 "must not contain control characters",
46 ));
47 }
48 if normalized.contains('/')
49 || normalized.contains('\\')
50 || matches!(normalized.as_str(), "." | "..")
51 {
52 return Err(invalid(
53 "embedding display name",
54 "must not be a path or contain path separators",
55 ));
56 }
57 if normalized.len() > MAX_EMBEDDING_DISPLAY_NAME_BYTES {
58 return Err(invalid(
59 "embedding display name",
60 format!(
61 "{} UTF-8 bytes exceeds {MAX_EMBEDDING_DISPLAY_NAME_BYTES}",
62 normalized.len()
63 ),
64 ));
65 }
66 Ok(Self(normalized))
67 }
68
69 #[must_use]
71 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "kind", rename_all = "snake_case")]
79pub enum EmbeddingProducerIdentity {
80 M18 {
82 algorithm: String,
84 algorithm_version: String,
86 },
87 Local {
89 implementation: String,
91 model: String,
93 revision: String,
95 contract_version: String,
97 },
98 Callback {
100 callback_contract: String,
102 contract_version: String,
104 },
105 Remote {
107 provider: String,
109 model: String,
111 revision: String,
113 response_contract_version: String,
115 },
116 CallerSupplied {
118 contract_version: String,
120 },
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum EmbeddingValueType {
127 Float32,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum EmbeddingNormalization {
135 None,
137 L2,
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum EmbeddingDistance {
145 Cosine,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TokenCountClass {
153 ExactLocal,
155 ProviderReported,
157 Approximate,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub struct TokenizerIdentity {
164 pub identifier: String,
166 pub version: String,
168 pub count_class: TokenCountClass,
170 pub max_input_tokens: u64,
172 pub normalization: String,
174}
175
176#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ChunkingIdentity {
179 pub chunk_size_tokens: u64,
181 pub overlap_tokens: u64,
183 pub aggregation: String,
185 pub truncation_policy: String,
187}
188
189#[derive(Clone, Debug)]
191pub struct EmbeddingCompatibilityInput {
192 pub producer: EmbeddingProducerIdentity,
194 pub dimensions: u32,
196 pub value_type: EmbeddingValueType,
198 pub normalization: EmbeddingNormalization,
200 pub distance: EmbeddingDistance,
202 pub tokenizer: Option<TokenizerIdentity>,
204 pub chunking: Option<ChunkingIdentity>,
206 pub hyperparameters: BTreeMap<String, Value>,
208 pub input_recipe: BTreeMap<String, Value>,
210 pub source_projection_recipe: BTreeMap<String, Value>,
212}
213
214#[derive(Clone, Debug, PartialEq, Serialize)]
216pub struct EmbeddingCompatibilityDescriptor {
217 schema_version: u32,
218 producer: EmbeddingProducerIdentity,
219 dimensions: u32,
220 value_type: EmbeddingValueType,
221 normalization: EmbeddingNormalization,
222 distance: EmbeddingDistance,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 tokenizer: Option<TokenizerIdentity>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 chunking: Option<ChunkingIdentity>,
227 hyperparameters: BTreeMap<String, Value>,
228 input_recipe: BTreeMap<String, Value>,
229 source_projection_recipe: BTreeMap<String, Value>,
230}
231
232#[derive(Deserialize)]
233#[serde(deny_unknown_fields)]
234struct RawEmbeddingCompatibilityDescriptor {
235 schema_version: u32,
236 producer: EmbeddingProducerIdentity,
237 dimensions: u32,
238 value_type: EmbeddingValueType,
239 normalization: EmbeddingNormalization,
240 distance: EmbeddingDistance,
241 tokenizer: Option<TokenizerIdentity>,
242 chunking: Option<ChunkingIdentity>,
243 hyperparameters: BTreeMap<String, Value>,
244 input_recipe: BTreeMap<String, Value>,
245 source_projection_recipe: BTreeMap<String, Value>,
246}
247
248impl EmbeddingCompatibilityDescriptor {
249 pub fn new(input: EmbeddingCompatibilityInput) -> Result<Self, SearchArtifactError> {
255 validate_producer(&input.producer)?;
256 if input.dimensions == 0 {
257 return Err(invalid("embedding dimensions", "must be greater than zero"));
258 }
259 validate_tokenizer(input.tokenizer.as_ref())?;
260 validate_chunking(input.chunking.as_ref(), input.tokenizer.as_ref())?;
261 if matches!(input.producer, EmbeddingProducerIdentity::Remote { .. })
262 && input.tokenizer.is_none()
263 {
264 return Err(invalid(
265 "embedding tokenizer",
266 "remote producers require an explicit tokenizer contract",
267 ));
268 }
269 validate_json_map("embedding hyperparameters", &input.hyperparameters, false)?;
270 validate_json_map("embedding input recipe", &input.input_recipe, true)?;
271 validate_json_map(
272 "embedding source projection recipe",
273 &input.source_projection_recipe,
274 true,
275 )?;
276 let descriptor = Self {
277 schema_version: EMBEDDING_IDENTITY_VERSION,
278 producer: input.producer,
279 dimensions: input.dimensions,
280 value_type: input.value_type,
281 normalization: input.normalization,
282 distance: input.distance,
283 tokenizer: input.tokenizer,
284 chunking: input.chunking,
285 hyperparameters: input.hyperparameters,
286 input_recipe: input.input_recipe,
287 source_projection_recipe: input.source_projection_recipe,
288 };
289 descriptor.to_canonical_json()?;
290 Ok(descriptor)
291 }
292
293 pub fn from_json(path: &Path, bytes: &[u8]) -> Result<Self, SearchArtifactError> {
299 if bytes.len() > MAX_IDENTITY_JSON_BYTES {
300 return Err(SearchArtifactError::ResourceExhausted {
301 resource: "embedding_descriptor_bytes",
302 limit: MAX_IDENTITY_JSON_BYTES as u64,
303 });
304 }
305 let value: Value = serde_json::from_slice(bytes)
306 .map_err(|error| corrupt_descriptor(path, error.to_string()))?;
307 let object = value
308 .as_object()
309 .ok_or_else(|| corrupt_descriptor(path, "expected a JSON object"))?;
310 let version = object
311 .get("schema_version")
312 .and_then(Value::as_u64)
313 .ok_or_else(|| {
314 corrupt_descriptor(path, "schema_version must be an unsigned integer")
315 })?;
316 if version != u64::from(EMBEDDING_IDENTITY_VERSION) {
317 return Err(SearchArtifactError::IncompatibleManifest {
318 path: path.to_path_buf(),
319 found: version,
320 supported: EMBEDDING_IDENTITY_VERSION,
321 });
322 }
323 let raw: RawEmbeddingCompatibilityDescriptor = serde_json::from_value(value)
324 .map_err(|error| corrupt_descriptor(path, error.to_string()))?;
325 let descriptor = Self::new(EmbeddingCompatibilityInput {
326 producer: raw.producer,
327 dimensions: raw.dimensions,
328 value_type: raw.value_type,
329 normalization: raw.normalization,
330 distance: raw.distance,
331 tokenizer: raw.tokenizer,
332 chunking: raw.chunking,
333 hyperparameters: raw.hyperparameters,
334 input_recipe: raw.input_recipe,
335 source_projection_recipe: raw.source_projection_recipe,
336 })
337 .map_err(|error| corrupt_descriptor(path, error.to_string()))?;
338 debug_assert_eq!(raw.schema_version, EMBEDDING_IDENTITY_VERSION);
339 let canonical = descriptor
340 .to_canonical_json()
341 .map_err(|error| corrupt_descriptor(path, error.to_string()))?;
342 if canonical != bytes {
343 return Err(corrupt_descriptor(
344 path,
345 "descriptor bytes are not exact canonical JSON",
346 ));
347 }
348 Ok(descriptor)
349 }
350
351 pub fn to_canonical_json(&self) -> Result<Vec<u8>, SearchArtifactError> {
356 let value = serde_json::to_value(self)
357 .map_err(|error| invalid("embedding identity", error.to_string()))?;
358 let mut output = Vec::new();
359 write_canonical_value(&value, &mut output)?;
360 if output.len() > MAX_IDENTITY_JSON_BYTES {
361 return Err(invalid(
362 "embedding identity",
363 format!(
364 "{} canonical JSON bytes exceeds {MAX_IDENTITY_JSON_BYTES}",
365 output.len()
366 ),
367 ));
368 }
369 Ok(output)
370 }
371
372 pub fn compatibility_id(&self) -> Result<EmbeddingCompatibilityId, SearchArtifactError> {
377 self.to_canonical_json()
378 .map(|bytes| EmbeddingCompatibilityId(hash_bytes(&bytes)))
379 }
380
381 #[must_use]
383 pub const fn dimensions(&self) -> u32 {
384 self.dimensions
385 }
386
387 #[must_use]
389 pub const fn normalization(&self) -> EmbeddingNormalization {
390 self.normalization
391 }
392
393 #[must_use]
395 pub const fn producer(&self) -> &EmbeddingProducerIdentity {
396 &self.producer
397 }
398
399 #[must_use]
401 pub const fn tokenizer(&self) -> Option<&TokenizerIdentity> {
402 self.tokenizer.as_ref()
403 }
404
405 #[must_use]
407 pub const fn chunking(&self) -> Option<&ChunkingIdentity> {
408 self.chunking.as_ref()
409 }
410}
411
412#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
413struct Sha256Value([u8; 32]);
414
415macro_rules! identity_type {
416 ($name:ident, $doc:literal) => {
417 #[doc = $doc]
418 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
419 pub struct $name(Sha256Value);
420
421 impl $name {
422 pub fn from_hex(value: &str) -> Result<Self, SearchArtifactError> {
427 parse_digest(value).map(Self)
428 }
429
430 #[must_use]
432 pub fn to_hex(self) -> String {
433 encode_digest(self.0)
434 }
435 }
436
437 impl fmt::Debug for $name {
438 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
439 formatter
440 .debug_tuple(stringify!($name))
441 .field(&self.to_hex())
442 .finish()
443 }
444 }
445
446 impl fmt::Display for $name {
447 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
448 formatter.write_str(&self.to_hex())
449 }
450 }
451 };
452}
453
454identity_type!(
455 EmbeddingCompatibilityId,
456 "SHA-256 identity of one validated compatibility descriptor."
457);
458identity_type!(
459 EmbeddingSourceFingerprint,
460 "SHA-256 fingerprint of one committed source projection."
461);
462identity_type!(
463 EmbeddingContentDigest,
464 "SHA-256 digest of canonical UUID/vector content."
465);
466identity_type!(
467 EmbeddingGenerationId,
468 "SHA-256 identity of compatibility, source, and canonical content."
469);
470
471impl EmbeddingSourceFingerprint {
472 #[must_use]
474 pub fn digest(bytes: &[u8]) -> Self {
475 Self(hash_bytes(bytes))
476 }
477
478 fn bytes(self) -> [u8; 32] {
479 self.0.0
480 }
481}
482
483impl EmbeddingContentDigest {
484 #[must_use]
486 pub fn digest(bytes: &[u8]) -> Self {
487 Self(hash_bytes(bytes))
488 }
489
490 fn bytes(self) -> [u8; 32] {
491 self.0.0
492 }
493}
494
495impl EmbeddingCompatibilityId {
496 fn bytes(self) -> [u8; 32] {
497 self.0.0
498 }
499}
500
501impl EmbeddingGenerationId {
502 #[must_use]
504 pub fn for_generation(
505 compatibility: EmbeddingCompatibilityId,
506 source: EmbeddingSourceFingerprint,
507 content: EmbeddingContentDigest,
508 ) -> Self {
509 let mut hasher = Sha256::new();
510 hasher.update(compatibility.bytes());
511 hasher.update(source.bytes());
512 hasher.update(content.bytes());
513 Self(Sha256Value(hasher.finalize().into()))
514 }
515}
516
517fn validate_producer(producer: &EmbeddingProducerIdentity) -> Result<(), SearchArtifactError> {
518 let fields: &[(&str, &str)] = match producer {
519 EmbeddingProducerIdentity::M18 {
520 algorithm,
521 algorithm_version,
522 } => &[
523 ("algorithm", algorithm),
524 ("algorithm_version", algorithm_version),
525 ],
526 EmbeddingProducerIdentity::Local {
527 implementation,
528 model,
529 revision,
530 contract_version,
531 } => &[
532 ("implementation", implementation),
533 ("model", model),
534 ("revision", revision),
535 ("contract_version", contract_version),
536 ],
537 EmbeddingProducerIdentity::Callback {
538 callback_contract,
539 contract_version,
540 } => &[
541 ("callback_contract", callback_contract),
542 ("contract_version", contract_version),
543 ],
544 EmbeddingProducerIdentity::Remote {
545 provider,
546 model,
547 revision,
548 response_contract_version,
549 } => &[
550 ("provider", provider),
551 ("model", model),
552 ("revision", revision),
553 ("response_contract_version", response_contract_version),
554 ],
555 EmbeddingProducerIdentity::CallerSupplied { contract_version } => {
556 &[("contract_version", contract_version)]
557 }
558 };
559 for &(field, value) in fields {
560 validate_identity_text(field, value)?;
561 }
562 Ok(())
563}
564
565fn validate_tokenizer(tokenizer: Option<&TokenizerIdentity>) -> Result<(), SearchArtifactError> {
566 let Some(tokenizer) = tokenizer else {
567 return Ok(());
568 };
569 validate_identity_text("tokenizer identifier", &tokenizer.identifier)?;
570 validate_identity_text("tokenizer version", &tokenizer.version)?;
571 validate_identity_text("tokenizer normalization", &tokenizer.normalization)?;
572 if tokenizer.max_input_tokens == 0 {
573 return Err(invalid(
574 "tokenizer max_input_tokens",
575 "must be greater than zero",
576 ));
577 }
578 Ok(())
579}
580
581fn validate_chunking(
582 chunking: Option<&ChunkingIdentity>,
583 tokenizer: Option<&TokenizerIdentity>,
584) -> Result<(), SearchArtifactError> {
585 let Some(chunking) = chunking else {
586 return Ok(());
587 };
588 let tokenizer = tokenizer.ok_or_else(|| {
589 invalid(
590 "embedding chunking",
591 "requires an explicit tokenizer contract",
592 )
593 })?;
594 if chunking.chunk_size_tokens == 0 || chunking.chunk_size_tokens > tokenizer.max_input_tokens {
595 return Err(invalid(
596 "chunk_size_tokens",
597 "must be within the tokenizer input limit",
598 ));
599 }
600 if chunking.overlap_tokens >= chunking.chunk_size_tokens {
601 return Err(invalid(
602 "overlap_tokens",
603 "must be smaller than chunk_size_tokens",
604 ));
605 }
606 validate_identity_text("chunk aggregation", &chunking.aggregation)?;
607 if chunking.truncation_policy != "reject" {
608 return Err(invalid(
609 "truncation_policy",
610 "must be reject; silent truncation is forbidden",
611 ));
612 }
613 Ok(())
614}
615
616fn validate_json_map(
617 field: &'static str,
618 map: &BTreeMap<String, Value>,
619 require_non_empty: bool,
620) -> Result<(), SearchArtifactError> {
621 if require_non_empty && map.is_empty() {
622 return Err(invalid(field, "must not be empty"));
623 }
624 validate_json_value(field, &Value::Object(map.clone().into_iter().collect()), 0)
625}
626
627fn validate_json_value(
628 field: &'static str,
629 value: &Value,
630 depth: usize,
631) -> Result<(), SearchArtifactError> {
632 if depth > MAX_IDENTITY_JSON_DEPTH {
633 return Err(invalid(field, "exceeds maximum JSON nesting depth"));
634 }
635 match value {
636 Value::Null | Value::Bool(_) | Value::Number(_) => Ok(()),
637 Value::String(value) => validate_identity_text(field, value),
638 Value::Array(values) => {
639 for value in values {
640 validate_json_value(field, value, depth + 1)?;
641 }
642 Ok(())
643 }
644 Value::Object(object) => {
645 for (key, value) in object {
646 validate_identity_text(field, key)?;
647 if reserved_compatibility_key(key) {
648 return Err(invalid(
649 field,
650 format!("reserved non-compatibility field {key:?}"),
651 ));
652 }
653 validate_json_value(field, value, depth + 1)?;
654 }
655 Ok(())
656 }
657 }
658}
659
660fn validate_identity_text(field: &'static str, value: &str) -> Result<(), SearchArtifactError> {
661 if value.is_empty() {
662 return Err(invalid(field, "must not be empty"));
663 }
664 if value.trim() != value {
665 return Err(invalid(field, "must not have surrounding whitespace"));
666 }
667 if value.chars().any(char::is_control) {
668 return Err(invalid(field, "must not contain control characters"));
669 }
670 if !value.nfc().eq(value.chars()) {
671 return Err(invalid(field, "must be normalized to Unicode NFC"));
672 }
673 if value.len() > MAX_IDENTITY_TEXT_BYTES {
674 return Err(invalid(
675 field,
676 format!(
677 "{} UTF-8 bytes exceeds {MAX_IDENTITY_TEXT_BYTES}",
678 value.len()
679 ),
680 ));
681 }
682 Ok(())
683}
684
685fn reserved_compatibility_key(key: &str) -> bool {
686 matches!(
687 key.to_ascii_lowercase().as_str(),
688 "alias"
689 | "algorithm_run_uuid"
690 | "api_key"
691 | "apikey"
692 | "authorization"
693 | "committed_at"
694 | "credential"
695 | "credentials"
696 | "generated_at"
697 | "generation_id"
698 | "password"
699 | "refresh_token"
700 | "run_id"
701 | "run_uuid"
702 | "secret"
703 | "source_fingerprint"
704 | "timestamp"
705 | "token"
706 | "access_token"
707 )
708}
709
710fn write_canonical_value(value: &Value, output: &mut Vec<u8>) -> Result<(), SearchArtifactError> {
711 match value {
712 Value::Null => output.extend_from_slice(b"null"),
713 Value::Bool(value) => output.extend_from_slice(if *value { b"true" } else { b"false" }),
714 Value::Number(value) => output.extend_from_slice(value.to_string().as_bytes()),
715 Value::String(value) => serde_json::to_writer(output, value)
716 .map_err(|error| invalid("embedding identity", error.to_string()))?,
717 Value::Array(values) => {
718 output.push(b'[');
719 for (index, value) in values.iter().enumerate() {
720 if index != 0 {
721 output.push(b',');
722 }
723 write_canonical_value(value, output)?;
724 }
725 output.push(b']');
726 }
727 Value::Object(object) => {
728 output.push(b'{');
729 let mut keys = object.keys().collect::<Vec<_>>();
730 keys.sort_unstable_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
731 for (index, key) in keys.into_iter().enumerate() {
732 if index != 0 {
733 output.push(b',');
734 }
735 serde_json::to_writer(&mut *output, key)
736 .map_err(|error| invalid("embedding identity", error.to_string()))?;
737 output.push(b':');
738 write_canonical_value(&object[key], output)?;
739 }
740 output.push(b'}');
741 }
742 }
743 Ok(())
744}
745
746fn hash_bytes(bytes: &[u8]) -> Sha256Value {
747 Sha256Value(Sha256::digest(bytes).into())
748}
749
750fn encode_digest(value: Sha256Value) -> String {
751 const HEX: &[u8; 16] = b"0123456789abcdef";
752 let mut output = String::with_capacity(64);
753 for byte in value.0 {
754 output.push(char::from(HEX[usize::from(byte >> 4)]));
755 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
756 }
757 output
758}
759
760fn parse_digest(value: &str) -> Result<Sha256Value, SearchArtifactError> {
761 if value.len() != 64
762 || !value
763 .bytes()
764 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
765 {
766 return Err(invalid(
767 "embedding digest",
768 "must be exactly 64 lowercase hexadecimal digits",
769 ));
770 }
771 let mut bytes = [0_u8; 32];
772 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
773 bytes[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]);
774 }
775 Ok(Sha256Value(bytes))
776}
777
778fn hex_nibble(byte: u8) -> u8 {
779 match byte {
780 b'0'..=b'9' => byte - b'0',
781 b'a'..=b'f' => byte - b'a' + 10,
782 _ => unreachable!("parse_digest validates lowercase hexadecimal input"),
783 }
784}
785
786fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
787 SearchArtifactError::InvalidSelector {
788 field,
789 reason: reason.into(),
790 }
791}
792
793fn corrupt_descriptor(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
794 SearchArtifactError::CorruptManifest {
795 path: PathBuf::from(path),
796 reason: reason.into(),
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 fn tokenizer() -> TokenizerIdentity {
805 TokenizerIdentity {
806 identifier: "tokenizer-v1".to_owned(),
807 version: "1".to_owned(),
808 count_class: TokenCountClass::ExactLocal,
809 max_input_tokens: 512,
810 normalization: "nfc-v1".to_owned(),
811 }
812 }
813
814 fn input(producer: EmbeddingProducerIdentity) -> EmbeddingCompatibilityInput {
815 let tokenizer =
816 matches!(&producer, EmbeddingProducerIdentity::Remote { .. }).then(tokenizer);
817 EmbeddingCompatibilityInput {
818 producer,
819 dimensions: 4,
820 value_type: EmbeddingValueType::Float32,
821 normalization: EmbeddingNormalization::L2,
822 distance: EmbeddingDistance::Cosine,
823 tokenizer,
824 chunking: None,
825 hyperparameters: BTreeMap::new(),
826 input_recipe: BTreeMap::from([("properties".to_owned(), serde_json::json!(["text"]))]),
827 source_projection_recipe: BTreeMap::from([(
828 "label".to_owned(),
829 Value::String("Paper".to_owned()),
830 )]),
831 }
832 }
833
834 fn caller() -> EmbeddingProducerIdentity {
835 EmbeddingProducerIdentity::CallerSupplied {
836 contract_version: "caller-batch-v1".to_owned(),
837 }
838 }
839
840 fn remote_input() -> EmbeddingCompatibilityInput {
841 let mut input = input(EmbeddingProducerIdentity::Remote {
842 provider: "openrouter".to_owned(),
843 model: "provider/model".to_owned(),
844 revision: "rev-1".to_owned(),
845 response_contract_version: "remote-v1".to_owned(),
846 });
847 input.chunking = Some(ChunkingIdentity {
848 chunk_size_tokens: 256,
849 overlap_tokens: 32,
850 aggregation: "mean-v1".to_owned(),
851 truncation_policy: "reject".to_owned(),
852 });
853 input
854 .hyperparameters
855 .insert("temperature".to_owned(), serde_json::json!(0));
856 input
857 }
858
859 fn compatibility_id(input: EmbeddingCompatibilityInput) -> EmbeddingCompatibilityId {
860 EmbeddingCompatibilityDescriptor::new(input)
861 .unwrap()
862 .compatibility_id()
863 .unwrap()
864 }
865
866 #[test]
867 fn display_names_normalize_without_case_folding() {
868 let composed = EmbeddingDisplayName::new("Café").unwrap();
869 let decomposed = EmbeddingDisplayName::new("Cafe\u{301}").unwrap();
870 assert_eq!(composed, decomposed);
871 assert_ne!(
872 EmbeddingDisplayName::new("Space").unwrap(),
873 EmbeddingDisplayName::new("space").unwrap()
874 );
875 for invalid in ["", " space", "space ", ".", "..", "a/b", "a\\b", "a\n"] {
876 assert!(EmbeddingDisplayName::new(invalid).is_err(), "{invalid:?}");
877 }
878 }
879
880 #[test]
881 fn canonical_identity_ignores_map_insertion_order() {
882 let mut left = input(caller());
883 let mut right = input(caller());
884 left.hyperparameters
885 .insert("z".to_owned(), serde_json::json!({"b": 2, "a": 1}));
886 left.hyperparameters
887 .insert("a".to_owned(), Value::Bool(true));
888 right
889 .hyperparameters
890 .insert("a".to_owned(), Value::Bool(true));
891 right
892 .hyperparameters
893 .insert("z".to_owned(), serde_json::json!({"a": 1, "b": 2}));
894 let left = EmbeddingCompatibilityDescriptor::new(left).unwrap();
895 let right = EmbeddingCompatibilityDescriptor::new(right).unwrap();
896 assert_eq!(
897 left.to_canonical_json().unwrap(),
898 right.to_canonical_json().unwrap()
899 );
900 assert_eq!(
901 left.compatibility_id().unwrap(),
902 right.compatibility_id().unwrap()
903 );
904 }
905
906 #[test]
907 fn every_producer_kind_is_explicit_in_identity() {
908 let producers = [
909 EmbeddingProducerIdentity::M18 {
910 algorithm: "node2vec".to_owned(),
911 algorithm_version: "node2vec-v1".to_owned(),
912 },
913 EmbeddingProducerIdentity::Local {
914 implementation: "local-runtime".to_owned(),
915 model: "model".to_owned(),
916 revision: "rev-1".to_owned(),
917 contract_version: "local-v1".to_owned(),
918 },
919 EmbeddingProducerIdentity::Callback {
920 callback_contract: "callback-a".to_owned(),
921 contract_version: "callback-v1".to_owned(),
922 },
923 EmbeddingProducerIdentity::Remote {
924 provider: "openrouter".to_owned(),
925 model: "provider/model".to_owned(),
926 revision: "unavailable".to_owned(),
927 response_contract_version: "remote-v1".to_owned(),
928 },
929 caller(),
930 ];
931 let mut identities = Vec::new();
932 for producer in producers {
933 identities.push(
934 EmbeddingCompatibilityDescriptor::new(input(producer))
935 .unwrap()
936 .compatibility_id()
937 .unwrap(),
938 );
939 }
940 identities.sort_unstable();
941 identities.dedup();
942 assert_eq!(identities.len(), 5);
943 }
944
945 #[test]
946 fn every_configurable_descriptor_field_participates_in_identity() {
947 let base = remote_input();
948 let base_descriptor = EmbeddingCompatibilityDescriptor::new(base.clone()).unwrap();
949 let canonical = String::from_utf8(base_descriptor.to_canonical_json().unwrap()).unwrap();
950 assert!(canonical.contains(r#""schema_version":1"#));
951 assert!(canonical.contains(r#""value_type":"float32""#));
952 assert!(canonical.contains(r#""distance":"cosine""#));
953 let base_id = base_descriptor.compatibility_id().unwrap();
954
955 let mutations: &[fn(&mut EmbeddingCompatibilityInput)] = &[
956 |input| {
957 let EmbeddingProducerIdentity::Remote { provider, .. } = &mut input.producer else {
958 unreachable!()
959 };
960 *provider = "other-provider".to_owned();
961 },
962 |input| {
963 let EmbeddingProducerIdentity::Remote { model, .. } = &mut input.producer else {
964 unreachable!()
965 };
966 *model = "provider/other-model".to_owned();
967 },
968 |input| {
969 let EmbeddingProducerIdentity::Remote { revision, .. } = &mut input.producer else {
970 unreachable!()
971 };
972 *revision = "rev-2".to_owned();
973 },
974 |input| {
975 let EmbeddingProducerIdentity::Remote {
976 response_contract_version,
977 ..
978 } = &mut input.producer
979 else {
980 unreachable!()
981 };
982 *response_contract_version = "remote-v2".to_owned();
983 },
984 |input| input.dimensions += 1,
985 |input| input.normalization = EmbeddingNormalization::None,
986 |input| {
987 input.tokenizer.as_mut().unwrap().identifier = "tokenizer-v2".to_owned();
988 },
989 |input| input.tokenizer.as_mut().unwrap().version = "2".to_owned(),
990 |input| {
991 input.tokenizer.as_mut().unwrap().count_class = TokenCountClass::ProviderReported;
992 },
993 |input| input.tokenizer.as_mut().unwrap().max_input_tokens += 1,
994 |input| {
995 input.tokenizer.as_mut().unwrap().normalization = "nfc-v2".to_owned();
996 },
997 |input| input.chunking.as_mut().unwrap().chunk_size_tokens -= 1,
998 |input| input.chunking.as_mut().unwrap().overlap_tokens += 1,
999 |input| input.chunking.as_mut().unwrap().aggregation = "max-v1".to_owned(),
1000 |input| {
1001 input
1002 .hyperparameters
1003 .insert("temperature".to_owned(), serde_json::json!(1));
1004 },
1005 |input| {
1006 input
1007 .input_recipe
1008 .insert("separator".to_owned(), serde_json::json!("|"));
1009 },
1010 |input| {
1011 input
1012 .source_projection_recipe
1013 .insert("directed".to_owned(), serde_json::json!(true));
1014 },
1015 ];
1016
1017 for mutate in mutations {
1018 let mut changed = base.clone();
1019 mutate(&mut changed);
1020 assert_ne!(compatibility_id(changed), base_id);
1021 }
1022 }
1023
1024 #[test]
1025 fn generation_identity_uses_all_three_fixed_digests() {
1026 let compatibility = EmbeddingCompatibilityDescriptor::new(input(caller()))
1027 .unwrap()
1028 .compatibility_id()
1029 .unwrap();
1030 let source = EmbeddingSourceFingerprint::digest(b"source");
1031 let content = EmbeddingContentDigest::digest(b"content");
1032 let generation = EmbeddingGenerationId::for_generation(compatibility, source, content);
1033 assert_eq!(
1034 generation,
1035 EmbeddingGenerationId::from_hex(&generation.to_hex()).unwrap()
1036 );
1037 assert_ne!(
1038 generation,
1039 EmbeddingGenerationId::for_generation(
1040 compatibility,
1041 source,
1042 EmbeddingContentDigest::digest(b"changed")
1043 )
1044 );
1045 assert!(EmbeddingGenerationId::from_hex(&"A".repeat(64)).is_err());
1046 assert!(EmbeddingGenerationId::from_hex("abcd").is_err());
1047 }
1048
1049 #[test]
1050 fn invalid_dimensions_recipes_and_chunking_fail_closed() {
1051 let mut zero = input(caller());
1052 zero.dimensions = 0;
1053 assert!(EmbeddingCompatibilityDescriptor::new(zero).is_err());
1054
1055 let mut empty_recipe = input(caller());
1056 empty_recipe.input_recipe.clear();
1057 assert!(EmbeddingCompatibilityDescriptor::new(empty_recipe).is_err());
1058
1059 let mut chunked = input(caller());
1060 chunked.tokenizer = Some(tokenizer());
1061 chunked.chunking = Some(ChunkingIdentity {
1062 chunk_size_tokens: 512,
1063 overlap_tokens: 512,
1064 aggregation: "mean-v1".to_owned(),
1065 truncation_policy: "truncate".to_owned(),
1066 });
1067 assert!(EmbeddingCompatibilityDescriptor::new(chunked).is_err());
1068
1069 let mut remote_without_tokenizer = input(EmbeddingProducerIdentity::Remote {
1070 provider: "openrouter".to_owned(),
1071 model: "provider/model".to_owned(),
1072 revision: "unavailable".to_owned(),
1073 response_contract_version: "remote-v1".to_owned(),
1074 });
1075 remote_without_tokenizer.tokenizer = None;
1076 assert!(EmbeddingCompatibilityDescriptor::new(remote_without_tokenizer).is_err());
1077
1078 let mut secret = input(caller());
1079 secret.hyperparameters.insert(
1080 "api_key".to_owned(),
1081 Value::String("must-not-persist".to_owned()),
1082 );
1083 assert!(EmbeddingCompatibilityDescriptor::new(secret).is_err());
1084 }
1085
1086 #[test]
1087 fn persisted_descriptor_reopens_only_from_exact_canonical_bytes() {
1088 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1089 let canonical = descriptor.to_canonical_json().unwrap();
1090 let reopened =
1091 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &canonical)
1092 .unwrap();
1093 assert_eq!(reopened, descriptor);
1094 assert_eq!(
1095 reopened.compatibility_id().unwrap(),
1096 descriptor.compatibility_id().unwrap()
1097 );
1098
1099 let mut padded = vec![b' '];
1100 padded.extend_from_slice(&canonical);
1101 assert!(matches!(
1102 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &padded),
1103 Err(SearchArtifactError::CorruptManifest { .. })
1104 ));
1105 }
1106
1107 #[test]
1108 fn persisted_descriptor_rejects_unknown_invalid_and_malformed_fields() {
1109 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1110 let mut value: Value =
1111 serde_json::from_slice(&descriptor.to_canonical_json().unwrap()).unwrap();
1112 value
1113 .as_object_mut()
1114 .unwrap()
1115 .insert("alias".to_owned(), Value::String("forbidden".to_owned()));
1116 let unknown = serde_json::to_vec(&value).unwrap();
1117 assert!(matches!(
1118 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &unknown),
1119 Err(SearchArtifactError::CorruptManifest { .. })
1120 ));
1121
1122 value.as_object_mut().unwrap().remove("alias");
1123 value["dimensions"] = Value::from(0);
1124 let invalid = serde_json::to_vec(&value).unwrap();
1125 assert!(matches!(
1126 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &invalid),
1127 Err(SearchArtifactError::CorruptManifest { .. })
1128 ));
1129 assert!(matches!(
1130 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), b"{"),
1131 Err(SearchArtifactError::CorruptManifest { .. })
1132 ));
1133 }
1134
1135 #[test]
1136 fn persisted_descriptor_rejects_duplicate_keys_and_noncanonical_unicode() {
1137 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1138 let canonical = String::from_utf8(descriptor.to_canonical_json().unwrap()).unwrap();
1139 let duplicate =
1140 canonical.replacen(r#""dimensions":4"#, r#""dimensions":4,"dimensions":4"#, 1);
1141 assert_ne!(duplicate, canonical);
1142 assert!(matches!(
1143 EmbeddingCompatibilityDescriptor::from_json(
1144 Path::new("space.json"),
1145 duplicate.as_bytes()
1146 ),
1147 Err(SearchArtifactError::CorruptManifest { .. })
1148 ));
1149
1150 let mut unicode_input = remote_input();
1151 unicode_input
1152 .input_recipe
1153 .insert("unicode".to_owned(), Value::String("Café".to_owned()));
1154 let unicode_descriptor = EmbeddingCompatibilityDescriptor::new(unicode_input).unwrap();
1155 let canonical_unicode =
1156 String::from_utf8(unicode_descriptor.to_canonical_json().unwrap()).unwrap();
1157 let decomposed = canonical_unicode.replace("Café", "Cafe\u{301}");
1158 assert_ne!(decomposed, canonical_unicode);
1159 assert!(matches!(
1160 EmbeddingCompatibilityDescriptor::from_json(
1161 Path::new("space.json"),
1162 decomposed.as_bytes()
1163 ),
1164 Err(SearchArtifactError::CorruptManifest { .. })
1165 ));
1166 }
1167
1168 #[test]
1169 fn persisted_descriptor_distinguishes_version_and_size() {
1170 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1171 let mut value: Value =
1172 serde_json::from_slice(&descriptor.to_canonical_json().unwrap()).unwrap();
1173 value["schema_version"] = Value::from(99);
1174 let incompatible = serde_json::to_vec(&value).unwrap();
1175 assert!(matches!(
1176 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &incompatible),
1177 Err(SearchArtifactError::IncompatibleManifest {
1178 found: 99,
1179 supported: EMBEDDING_IDENTITY_VERSION,
1180 ..
1181 })
1182 ));
1183
1184 let oversized = vec![b' '; MAX_IDENTITY_JSON_BYTES + 1];
1185 assert!(matches!(
1186 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &oversized),
1187 Err(SearchArtifactError::ResourceExhausted {
1188 resource: "embedding_descriptor_bytes",
1189 ..
1190 })
1191 ));
1192 }
1193}