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    }
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}