Skip to main content

graphforge_storage/
embedding_identity.rs

1//! Versioned, collision-safe identities for primary embedding generations.
2
3use 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
14/// Compatibility descriptor schema implemented by this release.
15pub const EMBEDDING_IDENTITY_VERSION: u32 = 1;
16/// Maximum normalized bytes in a caller-facing display name.
17pub 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/// A normalized caller-facing alias. It is never used as a filesystem path.
23#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct EmbeddingDisplayName(String);
25
26impl EmbeddingDisplayName {
27    /// Normalize a display name to NFC and enforce the v1 safety contract.
28    ///
29    /// # Errors
30    /// Rejects empty, surrounding-whitespace, control, path-like, or oversized names.
31    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    /// Normalized case-sensitive display text.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76/// Producer category and fields that participate in compatibility.
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "kind", rename_all = "snake_case")]
79pub enum EmbeddingProducerIdentity {
80    /// Read-only M18 algorithm output.
81    M18 {
82        /// Stable algorithm token.
83        algorithm: String,
84        /// Frozen algorithm contract version.
85        algorithm_version: String,
86    },
87    /// Process-local or embedded model implementation.
88    Local {
89        /// Adapter or implementation identity.
90        implementation: String,
91        /// Model identifier.
92        model: String,
93        /// Immutable revision, or the literal `unavailable`.
94        revision: String,
95        /// Adapter response contract.
96        contract_version: String,
97    },
98    /// Caller-registered callback contract.
99    Callback {
100        /// Stable callback contract identity, never a function address.
101        callback_contract: String,
102        /// Callback request/response version.
103        contract_version: String,
104    },
105    /// Explicit remote provider selection.
106    Remote {
107        /// Normalized provider token.
108        provider: String,
109        /// Provider model identifier.
110        model: String,
111        /// Immutable revision, or the literal `unavailable`.
112        revision: String,
113        /// Provider response contract version.
114        response_contract_version: String,
115    },
116    /// Complete caller-supplied UUID/vector batch.
117    CallerSupplied {
118        /// Caller batch contract version.
119        contract_version: String,
120    },
121}
122
123/// Persisted numeric value contract.
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126pub enum EmbeddingValueType {
127    /// IEEE-754 binary32 values.
128    Float32,
129}
130
131/// Persisted vector normalization contract.
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum EmbeddingNormalization {
135    /// Values are stored without normalization.
136    None,
137    /// Every vector is stored with unit L2 norm.
138    L2,
139}
140
141/// Persisted retrieval distance contract.
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum EmbeddingDistance {
145    /// Exact cosine similarity.
146    Cosine,
147}
148
149/// How token counts in a producer contract were obtained.
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TokenCountClass {
153    /// Exact tokenizer implemented locally.
154    ExactLocal,
155    /// Exact count reported by the selected provider.
156    ProviderReported,
157    /// Conservative approximation, never represented as exact.
158    Approximate,
159}
160
161/// Tokenizer and input-limit identity for text-derived embeddings.
162#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
163pub struct TokenizerIdentity {
164    /// Tokenizer identifier, or the literal `unavailable`.
165    pub identifier: String,
166    /// Immutable tokenizer version, or the literal `unavailable`.
167    pub version: String,
168    /// Exact/provider/approximate count classification.
169    pub count_class: TokenCountClass,
170    /// Maximum supported tokens in one model input.
171    pub max_input_tokens: u64,
172    /// Versioned text-normalization token.
173    pub normalization: String,
174}
175
176/// Explicit chunking contract; silent truncation is never represented.
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ChunkingIdentity {
179    /// Maximum tokens in one chunk.
180    pub chunk_size_tokens: u64,
181    /// Tokens repeated between adjacent chunks.
182    pub overlap_tokens: u64,
183    /// Versioned aggregation token.
184    pub aggregation: String,
185    /// Must be the literal `reject`; oversize unchunked input fails.
186    pub truncation_policy: String,
187}
188
189/// Untrusted compatibility fields before validation and canonicalization.
190#[derive(Clone, Debug)]
191pub struct EmbeddingCompatibilityInput {
192    /// Statically distinct producer identity.
193    pub producer: EmbeddingProducerIdentity,
194    /// Fixed vector width.
195    pub dimensions: u32,
196    /// Persisted numeric type.
197    pub value_type: EmbeddingValueType,
198    /// Persisted normalization.
199    pub normalization: EmbeddingNormalization,
200    /// Persisted distance contract.
201    pub distance: EmbeddingDistance,
202    /// Tokenizer contract for text-derived producers.
203    pub tokenizer: Option<TokenizerIdentity>,
204    /// Explicit chunking contract, if any.
205    pub chunking: Option<ChunkingIdentity>,
206    /// Normalized algorithm/provider hyperparameters.
207    pub hyperparameters: BTreeMap<String, Value>,
208    /// Non-empty normalized input/property recipe.
209    pub input_recipe: BTreeMap<String, Value>,
210    /// Non-empty normalized graph projection recipe.
211    pub source_projection_recipe: BTreeMap<String, Value>,
212}
213
214/// Validated compatibility descriptor whose canonical JSON is identity input.
215#[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    /// Validate all compatibility fields and freeze schema version 1.
250    ///
251    /// # Errors
252    /// Rejects incomplete producer/tokenizer/chunking identities, empty recipes,
253    /// unnormalized strings, excessive nesting, and oversized canonical JSON.
254    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    /// Parse one exact canonical persisted descriptor and re-run validation.
294    ///
295    /// # Errors
296    /// Distinguishes oversized, malformed/corrupt, and incompatible descriptor
297    /// bytes. Unknown fields and noncanonical encodings fail closed.
298    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    /// Serialize compact JSON with every object key sorted by UTF-8 bytes.
352    ///
353    /// # Errors
354    /// Returns a structured validation error if serialization exceeds the v1 bound.
355    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    /// SHA-256 compatibility identity over canonical descriptor JSON.
373    ///
374    /// # Errors
375    /// Propagates canonical serialization validation.
376    pub fn compatibility_id(&self) -> Result<EmbeddingCompatibilityId, SearchArtifactError> {
377        self.to_canonical_json()
378            .map(|bytes| EmbeddingCompatibilityId(hash_bytes(&bytes)))
379    }
380
381    /// Fixed vector width.
382    #[must_use]
383    pub const fn dimensions(&self) -> u32 {
384        self.dimensions
385    }
386
387    /// Persisted vector normalization contract.
388    #[must_use]
389    pub const fn normalization(&self) -> EmbeddingNormalization {
390        self.normalization
391    }
392
393    /// Statically distinct producer descriptor.
394    #[must_use]
395    pub const fn producer(&self) -> &EmbeddingProducerIdentity {
396        &self.producer
397    }
398
399    /// Persisted tokenizer identity for text-derived embeddings.
400    #[must_use]
401    pub const fn tokenizer(&self) -> Option<&TokenizerIdentity> {
402        self.tokenizer.as_ref()
403    }
404
405    /// Persisted explicit chunking contract, if any.
406    #[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            /// Parse exactly 64 lowercase hexadecimal digits.
423            ///
424            /// # Errors
425            /// Rejects uppercase, non-hexadecimal, or wrong-width input.
426            pub fn from_hex(value: &str) -> Result<Self, SearchArtifactError> {
427                parse_digest(value).map(Self)
428            }
429
430            /// Lowercase hexadecimal representation.
431            #[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    /// Digest canonical committed-source bytes.
473    #[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    /// Digest canonical UUID/vector content bytes.
485    #[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    /// Hash fixed-width compatibility, source, and content digests in that order.
503    #[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}