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 assert!(
879 EmbeddingDisplayName::new(&"x".repeat(MAX_EMBEDDING_DISPLAY_NAME_BYTES + 1)).is_err()
880 );
881 }
882
883 #[test]
884 fn canonical_identity_ignores_map_insertion_order() {
885 let mut left = input(caller());
886 let mut right = input(caller());
887 left.hyperparameters
888 .insert("z".to_owned(), serde_json::json!({"b": 2, "a": 1}));
889 left.hyperparameters
890 .insert("a".to_owned(), Value::Bool(true));
891 right
892 .hyperparameters
893 .insert("a".to_owned(), Value::Bool(true));
894 right
895 .hyperparameters
896 .insert("z".to_owned(), serde_json::json!({"a": 1, "b": 2}));
897 let left = EmbeddingCompatibilityDescriptor::new(left).unwrap();
898 let right = EmbeddingCompatibilityDescriptor::new(right).unwrap();
899 assert_eq!(
900 left.to_canonical_json().unwrap(),
901 right.to_canonical_json().unwrap()
902 );
903 assert_eq!(
904 left.compatibility_id().unwrap(),
905 right.compatibility_id().unwrap()
906 );
907 }
908
909 #[test]
910 fn every_producer_kind_is_explicit_in_identity() {
911 let producers = [
912 EmbeddingProducerIdentity::M18 {
913 algorithm: "node2vec".to_owned(),
914 algorithm_version: "node2vec-v1".to_owned(),
915 },
916 EmbeddingProducerIdentity::Local {
917 implementation: "local-runtime".to_owned(),
918 model: "model".to_owned(),
919 revision: "rev-1".to_owned(),
920 contract_version: "local-v1".to_owned(),
921 },
922 EmbeddingProducerIdentity::Callback {
923 callback_contract: "callback-a".to_owned(),
924 contract_version: "callback-v1".to_owned(),
925 },
926 EmbeddingProducerIdentity::Remote {
927 provider: "openrouter".to_owned(),
928 model: "provider/model".to_owned(),
929 revision: "unavailable".to_owned(),
930 response_contract_version: "remote-v1".to_owned(),
931 },
932 caller(),
933 ];
934 let mut identities = Vec::new();
935 for producer in producers {
936 identities.push(
937 EmbeddingCompatibilityDescriptor::new(input(producer))
938 .unwrap()
939 .compatibility_id()
940 .unwrap(),
941 );
942 }
943 identities.sort_unstable();
944 identities.dedup();
945 assert_eq!(identities.len(), 5);
946 }
947
948 #[test]
949 fn every_configurable_descriptor_field_participates_in_identity() {
950 let base = remote_input();
951 let base_descriptor = EmbeddingCompatibilityDescriptor::new(base.clone()).unwrap();
952 let canonical = String::from_utf8(base_descriptor.to_canonical_json().unwrap()).unwrap();
953 assert!(canonical.contains(r#""schema_version":1"#));
954 assert!(canonical.contains(r#""value_type":"float32""#));
955 assert!(canonical.contains(r#""distance":"cosine""#));
956 let base_id = base_descriptor.compatibility_id().unwrap();
957
958 let mutations: &[fn(&mut EmbeddingCompatibilityInput)] = &[
959 |input| {
960 let EmbeddingProducerIdentity::Remote { provider, .. } = &mut input.producer else {
961 unreachable!()
962 };
963 *provider = "other-provider".to_owned();
964 },
965 |input| {
966 let EmbeddingProducerIdentity::Remote { model, .. } = &mut input.producer else {
967 unreachable!()
968 };
969 *model = "provider/other-model".to_owned();
970 },
971 |input| {
972 let EmbeddingProducerIdentity::Remote { revision, .. } = &mut input.producer else {
973 unreachable!()
974 };
975 *revision = "rev-2".to_owned();
976 },
977 |input| {
978 let EmbeddingProducerIdentity::Remote {
979 response_contract_version,
980 ..
981 } = &mut input.producer
982 else {
983 unreachable!()
984 };
985 *response_contract_version = "remote-v2".to_owned();
986 },
987 |input| input.dimensions += 1,
988 |input| input.normalization = EmbeddingNormalization::None,
989 |input| {
990 input.tokenizer.as_mut().unwrap().identifier = "tokenizer-v2".to_owned();
991 },
992 |input| input.tokenizer.as_mut().unwrap().version = "2".to_owned(),
993 |input| {
994 input.tokenizer.as_mut().unwrap().count_class = TokenCountClass::ProviderReported;
995 },
996 |input| input.tokenizer.as_mut().unwrap().max_input_tokens += 1,
997 |input| {
998 input.tokenizer.as_mut().unwrap().normalization = "nfc-v2".to_owned();
999 },
1000 |input| input.chunking.as_mut().unwrap().chunk_size_tokens -= 1,
1001 |input| input.chunking.as_mut().unwrap().overlap_tokens += 1,
1002 |input| input.chunking.as_mut().unwrap().aggregation = "max-v1".to_owned(),
1003 |input| {
1004 input
1005 .hyperparameters
1006 .insert("temperature".to_owned(), serde_json::json!(1));
1007 },
1008 |input| {
1009 input
1010 .input_recipe
1011 .insert("separator".to_owned(), serde_json::json!("|"));
1012 },
1013 |input| {
1014 input
1015 .source_projection_recipe
1016 .insert("directed".to_owned(), serde_json::json!(true));
1017 },
1018 ];
1019
1020 for mutate in mutations {
1021 let mut changed = base.clone();
1022 mutate(&mut changed);
1023 assert_ne!(compatibility_id(changed), base_id);
1024 }
1025 }
1026
1027 #[test]
1028 fn generation_identity_uses_all_three_fixed_digests() {
1029 let compatibility = EmbeddingCompatibilityDescriptor::new(input(caller()))
1030 .unwrap()
1031 .compatibility_id()
1032 .unwrap();
1033 let source = EmbeddingSourceFingerprint::digest(b"source");
1034 let content = EmbeddingContentDigest::digest(b"content");
1035 let generation = EmbeddingGenerationId::for_generation(compatibility, source, content);
1036 assert_eq!(
1037 generation,
1038 EmbeddingGenerationId::from_hex(&generation.to_hex()).unwrap()
1039 );
1040 assert_ne!(
1041 generation,
1042 EmbeddingGenerationId::for_generation(
1043 compatibility,
1044 source,
1045 EmbeddingContentDigest::digest(b"changed")
1046 )
1047 );
1048 assert!(EmbeddingGenerationId::from_hex(&"A".repeat(64)).is_err());
1049 assert!(EmbeddingGenerationId::from_hex("abcd").is_err());
1050 }
1051
1052 #[test]
1053 fn invalid_dimensions_recipes_and_chunking_fail_closed() {
1054 let mut zero = input(caller());
1055 zero.dimensions = 0;
1056 assert!(EmbeddingCompatibilityDescriptor::new(zero).is_err());
1057
1058 let mut empty_recipe = input(caller());
1059 empty_recipe.input_recipe.clear();
1060 assert!(EmbeddingCompatibilityDescriptor::new(empty_recipe).is_err());
1061
1062 let mut chunked = input(caller());
1063 chunked.tokenizer = Some(tokenizer());
1064 chunked.chunking = Some(ChunkingIdentity {
1065 chunk_size_tokens: 512,
1066 overlap_tokens: 512,
1067 aggregation: "mean-v1".to_owned(),
1068 truncation_policy: "truncate".to_owned(),
1069 });
1070 assert!(EmbeddingCompatibilityDescriptor::new(chunked).is_err());
1071
1072 let mut remote_without_tokenizer = input(EmbeddingProducerIdentity::Remote {
1073 provider: "openrouter".to_owned(),
1074 model: "provider/model".to_owned(),
1075 revision: "unavailable".to_owned(),
1076 response_contract_version: "remote-v1".to_owned(),
1077 });
1078 remote_without_tokenizer.tokenizer = None;
1079 assert!(EmbeddingCompatibilityDescriptor::new(remote_without_tokenizer).is_err());
1080
1081 let mut secret = input(caller());
1082 secret.hyperparameters.insert(
1083 "api_key".to_owned(),
1084 Value::String("must-not-persist".to_owned()),
1085 );
1086 assert!(EmbeddingCompatibilityDescriptor::new(secret).is_err());
1087
1088 let mut zero_tokenizer = input(caller());
1089 let mut zero_tokenizer_contract = tokenizer();
1090 zero_tokenizer_contract.max_input_tokens = 0;
1091 zero_tokenizer.tokenizer = Some(zero_tokenizer_contract);
1092 assert!(EmbeddingCompatibilityDescriptor::new(zero_tokenizer).is_err());
1093
1094 let mut oversized_chunk = input(caller());
1095 oversized_chunk.tokenizer = Some(tokenizer());
1096 oversized_chunk.chunking = Some(ChunkingIdentity {
1097 chunk_size_tokens: 8_193,
1098 overlap_tokens: 0,
1099 aggregation: "mean-v1".to_owned(),
1100 truncation_policy: "reject".to_owned(),
1101 });
1102 assert!(EmbeddingCompatibilityDescriptor::new(oversized_chunk).is_err());
1103
1104 let mut silent_truncation = input(caller());
1105 silent_truncation.tokenizer = Some(tokenizer());
1106 silent_truncation.chunking = Some(ChunkingIdentity {
1107 chunk_size_tokens: 512,
1108 overlap_tokens: 0,
1109 aggregation: "mean-v1".to_owned(),
1110 truncation_policy: "truncate".to_owned(),
1111 });
1112 assert!(EmbeddingCompatibilityDescriptor::new(silent_truncation).is_err());
1113
1114 for invalid_text in ["", " surrounded ", "line\nbreak"] {
1115 let mut invalid_identity = input(caller());
1116 invalid_identity
1117 .hyperparameters
1118 .insert("value".to_owned(), Value::String(invalid_text.to_owned()));
1119 assert!(EmbeddingCompatibilityDescriptor::new(invalid_identity).is_err());
1120 }
1121
1122 let mut oversized_identity = input(caller());
1123 oversized_identity.hyperparameters.insert(
1124 "value".to_owned(),
1125 Value::String("x".repeat(MAX_IDENTITY_TEXT_BYTES + 1)),
1126 );
1127 assert!(EmbeddingCompatibilityDescriptor::new(oversized_identity).is_err());
1128
1129 let mut nested = Value::Null;
1130 for _ in 0..=MAX_IDENTITY_JSON_DEPTH {
1131 nested = Value::Array(vec![nested]);
1132 }
1133 let mut excessive_depth = input(caller());
1134 excessive_depth
1135 .hyperparameters
1136 .insert("nested".to_owned(), nested);
1137 assert!(EmbeddingCompatibilityDescriptor::new(excessive_depth).is_err());
1138 }
1139
1140 #[test]
1141 fn persisted_descriptor_reopens_only_from_exact_canonical_bytes() {
1142 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1143 let canonical = descriptor.to_canonical_json().unwrap();
1144 let reopened =
1145 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &canonical)
1146 .unwrap();
1147 assert_eq!(reopened, descriptor);
1148 assert_eq!(
1149 reopened.compatibility_id().unwrap(),
1150 descriptor.compatibility_id().unwrap()
1151 );
1152
1153 let mut padded = vec![b' '];
1154 padded.extend_from_slice(&canonical);
1155 assert!(matches!(
1156 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &padded),
1157 Err(SearchArtifactError::CorruptManifest { .. })
1158 ));
1159 }
1160
1161 #[test]
1162 fn persisted_descriptor_rejects_unknown_invalid_and_malformed_fields() {
1163 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1164 let mut value: Value =
1165 serde_json::from_slice(&descriptor.to_canonical_json().unwrap()).unwrap();
1166 value
1167 .as_object_mut()
1168 .unwrap()
1169 .insert("alias".to_owned(), Value::String("forbidden".to_owned()));
1170 let unknown = serde_json::to_vec(&value).unwrap();
1171 assert!(matches!(
1172 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &unknown),
1173 Err(SearchArtifactError::CorruptManifest { .. })
1174 ));
1175
1176 value.as_object_mut().unwrap().remove("alias");
1177 value["dimensions"] = Value::from(0);
1178 let invalid = serde_json::to_vec(&value).unwrap();
1179 assert!(matches!(
1180 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &invalid),
1181 Err(SearchArtifactError::CorruptManifest { .. })
1182 ));
1183 assert!(matches!(
1184 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), b"{"),
1185 Err(SearchArtifactError::CorruptManifest { .. })
1186 ));
1187 }
1188
1189 #[test]
1190 fn persisted_descriptor_rejects_duplicate_keys_and_noncanonical_unicode() {
1191 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1192 let canonical = String::from_utf8(descriptor.to_canonical_json().unwrap()).unwrap();
1193 let duplicate =
1194 canonical.replacen(r#""dimensions":4"#, r#""dimensions":4,"dimensions":4"#, 1);
1195 assert_ne!(duplicate, canonical);
1196 assert!(matches!(
1197 EmbeddingCompatibilityDescriptor::from_json(
1198 Path::new("space.json"),
1199 duplicate.as_bytes()
1200 ),
1201 Err(SearchArtifactError::CorruptManifest { .. })
1202 ));
1203
1204 let mut unicode_input = remote_input();
1205 unicode_input
1206 .input_recipe
1207 .insert("unicode".to_owned(), Value::String("Café".to_owned()));
1208 let unicode_descriptor = EmbeddingCompatibilityDescriptor::new(unicode_input).unwrap();
1209 let canonical_unicode =
1210 String::from_utf8(unicode_descriptor.to_canonical_json().unwrap()).unwrap();
1211 let decomposed = canonical_unicode.replace("Café", "Cafe\u{301}");
1212 assert_ne!(decomposed, canonical_unicode);
1213 assert!(matches!(
1214 EmbeddingCompatibilityDescriptor::from_json(
1215 Path::new("space.json"),
1216 decomposed.as_bytes()
1217 ),
1218 Err(SearchArtifactError::CorruptManifest { .. })
1219 ));
1220 }
1221
1222 #[test]
1223 fn persisted_descriptor_distinguishes_version_and_size() {
1224 let descriptor = EmbeddingCompatibilityDescriptor::new(remote_input()).unwrap();
1225 let mut value: Value =
1226 serde_json::from_slice(&descriptor.to_canonical_json().unwrap()).unwrap();
1227 value["schema_version"] = Value::from(99);
1228 let incompatible = serde_json::to_vec(&value).unwrap();
1229 assert!(matches!(
1230 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &incompatible),
1231 Err(SearchArtifactError::IncompatibleManifest {
1232 found: 99,
1233 supported: EMBEDDING_IDENTITY_VERSION,
1234 ..
1235 })
1236 ));
1237
1238 let oversized = vec![b' '; MAX_IDENTITY_JSON_BYTES + 1];
1239 assert!(matches!(
1240 EmbeddingCompatibilityDescriptor::from_json(Path::new("space.json"), &oversized),
1241 Err(SearchArtifactError::ResourceExhausted {
1242 resource: "embedding_descriptor_bytes",
1243 ..
1244 })
1245 ));
1246 }
1247}