1use std::fmt;
4use std::path::{Path, PathBuf};
5
6use serde::Deserialize;
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9
10use crate::{
11 EmbeddingCompatibilityId, EmbeddingContentDigest, EmbeddingGenerationId,
12 EmbeddingSourceFingerprint, SearchArtifactError,
13};
14
15pub const EMBEDDING_GENERATION_MANIFEST_VERSION: u32 = 1;
17pub const MAX_EMBEDDING_GENERATION_MANIFEST_BYTES: usize = 64 * 1024;
19const SOURCE_FINGERPRINT_DOMAIN: &[u8] = b"graphforge_embedding_source_v1";
20
21#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct EmbeddingPublicationFingerprint([u8; 32]);
24
25impl EmbeddingPublicationFingerprint {
26 #[must_use]
28 pub fn digest(bytes: &[u8]) -> Self {
29 Self(Sha256::digest(bytes).into())
30 }
31
32 pub fn from_hex(value: &str) -> Result<Self, SearchArtifactError> {
37 parse_digest("publication_fingerprint", value).map(Self)
38 }
39
40 #[must_use]
42 pub fn to_hex(self) -> String {
43 encode_digest(self.0)
44 }
45}
46
47impl fmt::Debug for EmbeddingPublicationFingerprint {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter
50 .debug_tuple("EmbeddingPublicationFingerprint")
51 .field(&self.to_hex())
52 .finish()
53 }
54}
55
56impl fmt::Display for EmbeddingPublicationFingerprint {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 formatter.write_str(&self.to_hex())
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub struct EmbeddingSourceState {
65 graph_generation: u64,
66 label_membership_digest: [u8; 32],
67 dependency_input_digest: [u8; 32],
68 eligible_uuid_count: u64,
69 fingerprint: EmbeddingSourceFingerprint,
70}
71
72impl EmbeddingSourceState {
73 #[must_use]
75 pub fn new(
76 graph_generation: u64,
77 label_membership_digest: [u8; 32],
78 dependency_input_digest: [u8; 32],
79 eligible_uuid_count: u64,
80 ) -> Self {
81 let mut hasher = Sha256::new();
82 hasher.update(SOURCE_FINGERPRINT_DOMAIN);
83 hasher.update(graph_generation.to_le_bytes());
84 hasher.update(label_membership_digest);
85 hasher.update(dependency_input_digest);
86 hasher.update(eligible_uuid_count.to_le_bytes());
87 let fingerprint = EmbeddingSourceFingerprint::from_hex(&format!("{:x}", hasher.finalize()))
88 .expect("SHA-256 output is always a valid lowercase source fingerprint");
89 Self {
90 graph_generation,
91 label_membership_digest,
92 dependency_input_digest,
93 eligible_uuid_count,
94 fingerprint,
95 }
96 }
97
98 #[must_use]
100 pub const fn graph_generation(self) -> u64 {
101 self.graph_generation
102 }
103
104 #[must_use]
106 pub const fn label_membership_digest(self) -> [u8; 32] {
107 self.label_membership_digest
108 }
109
110 #[must_use]
112 pub const fn dependency_input_digest(self) -> [u8; 32] {
113 self.dependency_input_digest
114 }
115
116 #[must_use]
118 pub const fn eligible_uuid_count(self) -> u64 {
119 self.eligible_uuid_count
120 }
121
122 #[must_use]
124 pub const fn fingerprint(self) -> EmbeddingSourceFingerprint {
125 self.fingerprint
126 }
127}
128
129#[derive(Clone, Copy, Debug)]
131pub struct EmbeddingGenerationManifestInput {
132 pub compatibility_id: EmbeddingCompatibilityId,
134 pub source: EmbeddingSourceState,
136 pub content_digest: EmbeddingContentDigest,
138 pub vector_count: u64,
140 pub dimension: u32,
142 pub generated_at_micros: i64,
144 pub committed_at_micros: i64,
146 pub publication_fingerprint: EmbeddingPublicationFingerprint,
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct EmbeddingGenerationManifest {
153 compatibility_id: EmbeddingCompatibilityId,
154 generation_id: EmbeddingGenerationId,
155 source: EmbeddingSourceState,
156 content_digest: EmbeddingContentDigest,
157 vector_count: u64,
158 dimension: u32,
159 generated_at_micros: i64,
160 committed_at_micros: i64,
161 publication_fingerprint: EmbeddingPublicationFingerprint,
162}
163
164impl EmbeddingGenerationManifest {
165 pub fn new(input: EmbeddingGenerationManifestInput) -> Result<Self, SearchArtifactError> {
170 if input.vector_count != input.source.eligible_uuid_count {
171 return Err(invalid(
172 "embedding generation",
173 "vector_count must equal eligible_uuid_count",
174 ));
175 }
176 if input.dimension == 0 {
177 return Err(invalid(
178 "embedding generation dimension",
179 "must be greater than zero",
180 ));
181 }
182 if input.generated_at_micros < 0 || input.committed_at_micros < 0 {
183 return Err(invalid(
184 "embedding generation timestamp",
185 "must be non-negative UTC microseconds",
186 ));
187 }
188 if input.committed_at_micros < input.generated_at_micros {
189 return Err(invalid(
190 "embedding generation timestamp",
191 "committed_at_micros must not precede generated_at_micros",
192 ));
193 }
194 let generation_id = EmbeddingGenerationId::for_generation(
195 input.compatibility_id,
196 input.source.fingerprint,
197 input.content_digest,
198 );
199 Ok(Self {
200 compatibility_id: input.compatibility_id,
201 generation_id,
202 source: input.source,
203 content_digest: input.content_digest,
204 vector_count: input.vector_count,
205 dimension: input.dimension,
206 generated_at_micros: input.generated_at_micros,
207 committed_at_micros: input.committed_at_micros,
208 publication_fingerprint: input.publication_fingerprint,
209 })
210 }
211
212 pub fn to_canonical_json(&self) -> Result<Vec<u8>, SearchArtifactError> {
217 serde_json::to_vec(&serde_json::json!({
218 "committed_at_micros": self.committed_at_micros,
219 "compatibility_id": self.compatibility_id.to_hex(),
220 "content_digest": self.content_digest.to_hex(),
221 "dependency_input_digest": encode_digest(self.source.dependency_input_digest),
222 "dimension": self.dimension,
223 "eligible_uuid_count": self.source.eligible_uuid_count,
224 "generated_at_micros": self.generated_at_micros,
225 "generation_id": self.generation_id.to_hex(),
226 "graph_generation": self.source.graph_generation,
227 "label_membership_digest": encode_digest(self.source.label_membership_digest),
228 "manifest_version": EMBEDDING_GENERATION_MANIFEST_VERSION,
229 "publication_fingerprint": self.publication_fingerprint.to_hex(),
230 "source_fingerprint": self.source.fingerprint.to_hex(),
231 "vector_count": self.vector_count,
232 }))
233 .map_err(|error| corrupt(Path::new("<memory>"), error.to_string()))
234 }
235
236 pub fn from_json(path: &Path, bytes: &[u8]) -> Result<Self, SearchArtifactError> {
241 if bytes.len() > MAX_EMBEDDING_GENERATION_MANIFEST_BYTES {
242 return Err(SearchArtifactError::ResourceExhausted {
243 resource: "embedding_generation_manifest_bytes",
244 limit: MAX_EMBEDDING_GENERATION_MANIFEST_BYTES as u64,
245 });
246 }
247 let value: Value =
248 serde_json::from_slice(bytes).map_err(|error| corrupt(path, error.to_string()))?;
249 let object = value
250 .as_object()
251 .ok_or_else(|| corrupt(path, "expected a JSON object"))?;
252 let version = object
253 .get("manifest_version")
254 .and_then(Value::as_u64)
255 .ok_or_else(|| corrupt(path, "manifest_version must be an unsigned integer"))?;
256 if version != u64::from(EMBEDDING_GENERATION_MANIFEST_VERSION) {
257 return Err(SearchArtifactError::IncompatibleManifest {
258 path: path.to_path_buf(),
259 found: version,
260 supported: EMBEDDING_GENERATION_MANIFEST_VERSION,
261 });
262 }
263 let raw: RawEmbeddingGenerationManifest =
264 serde_json::from_value(value).map_err(|error| corrupt(path, error.to_string()))?;
265 debug_assert_eq!(raw.manifest_version, EMBEDDING_GENERATION_MANIFEST_VERSION);
266 let compatibility_id = EmbeddingCompatibilityId::from_hex(&raw.compatibility_id)
267 .map_err(|error| corrupt(path, error.to_string()))?;
268 let content_digest = EmbeddingContentDigest::from_hex(&raw.content_digest)
269 .map_err(|error| corrupt(path, error.to_string()))?;
270 let expected_generation = EmbeddingGenerationId::from_hex(&raw.generation_id)
271 .map_err(|error| corrupt(path, error.to_string()))?;
272 let expected_source = EmbeddingSourceFingerprint::from_hex(&raw.source_fingerprint)
273 .map_err(|error| corrupt(path, error.to_string()))?;
274 let source = EmbeddingSourceState::new(
275 raw.graph_generation,
276 parse_digest("label_membership_digest", &raw.label_membership_digest)
277 .map_err(|error| corrupt(path, error.to_string()))?,
278 parse_digest("dependency_input_digest", &raw.dependency_input_digest)
279 .map_err(|error| corrupt(path, error.to_string()))?,
280 raw.eligible_uuid_count,
281 );
282 if source.fingerprint != expected_source {
283 return Err(corrupt(
284 path,
285 "source_fingerprint does not match source state",
286 ));
287 }
288 let manifest = Self::new(EmbeddingGenerationManifestInput {
289 compatibility_id,
290 source,
291 content_digest,
292 vector_count: raw.vector_count,
293 dimension: raw.dimension,
294 generated_at_micros: raw.generated_at_micros,
295 committed_at_micros: raw.committed_at_micros,
296 publication_fingerprint: EmbeddingPublicationFingerprint::from_hex(
297 &raw.publication_fingerprint,
298 )
299 .map_err(|error| corrupt(path, error.to_string()))?,
300 })
301 .map_err(|error| corrupt(path, error.to_string()))?;
302 if manifest.generation_id != expected_generation {
303 return Err(corrupt(
304 path,
305 "generation_id does not match compatibility, source, and content",
306 ));
307 }
308 if manifest
309 .to_canonical_json()
310 .map_err(|error| corrupt(path, error.to_string()))?
311 != bytes
312 {
313 return Err(corrupt(path, "manifest bytes are not exact canonical JSON"));
314 }
315 Ok(manifest)
316 }
317
318 #[must_use]
320 pub const fn generation_id(&self) -> EmbeddingGenerationId {
321 self.generation_id
322 }
323
324 #[must_use]
326 pub const fn compatibility_id(&self) -> EmbeddingCompatibilityId {
327 self.compatibility_id
328 }
329
330 #[must_use]
332 pub const fn source(&self) -> EmbeddingSourceState {
333 self.source
334 }
335
336 #[must_use]
338 pub const fn content_digest(&self) -> EmbeddingContentDigest {
339 self.content_digest
340 }
341
342 #[must_use]
344 pub const fn vector_count(&self) -> u64 {
345 self.vector_count
346 }
347
348 #[must_use]
350 pub const fn dimension(&self) -> u32 {
351 self.dimension
352 }
353
354 #[must_use]
356 pub const fn generated_at_micros(&self) -> i64 {
357 self.generated_at_micros
358 }
359
360 #[must_use]
362 pub const fn committed_at_micros(&self) -> i64 {
363 self.committed_at_micros
364 }
365
366 #[must_use]
368 pub const fn publication_fingerprint(&self) -> EmbeddingPublicationFingerprint {
369 self.publication_fingerprint
370 }
371}
372
373#[derive(Deserialize)]
374#[serde(deny_unknown_fields)]
375struct RawEmbeddingGenerationManifest {
376 manifest_version: u32,
377 compatibility_id: String,
378 generation_id: String,
379 source_fingerprint: String,
380 graph_generation: u64,
381 label_membership_digest: String,
382 dependency_input_digest: String,
383 eligible_uuid_count: u64,
384 content_digest: String,
385 vector_count: u64,
386 dimension: u32,
387 generated_at_micros: i64,
388 committed_at_micros: i64,
389 publication_fingerprint: String,
390}
391
392fn parse_digest(field: &'static str, value: &str) -> Result<[u8; 32], SearchArtifactError> {
393 if value.len() != 64
394 || !value
395 .bytes()
396 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
397 {
398 return Err(invalid(
399 field,
400 "must be exactly 64 lowercase hexadecimal digits",
401 ));
402 }
403 let mut output = [0_u8; 32];
404 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
405 output[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]);
406 }
407 Ok(output)
408}
409
410fn encode_digest(value: [u8; 32]) -> String {
411 const HEX: &[u8; 16] = b"0123456789abcdef";
412 let mut output = String::with_capacity(64);
413 for byte in value {
414 output.push(char::from(HEX[usize::from(byte >> 4)]));
415 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
416 }
417 output
418}
419
420fn hex_nibble(byte: u8) -> u8 {
421 match byte {
422 b'0'..=b'9' => byte - b'0',
423 b'a'..=b'f' => byte - b'a' + 10,
424 _ => unreachable!("parse_digest validates lowercase hexadecimal input"),
425 }
426}
427
428fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
429 SearchArtifactError::InvalidSelector {
430 field,
431 reason: reason.into(),
432 }
433}
434
435fn corrupt(path: &Path, reason: impl Into<String>) -> SearchArtifactError {
436 SearchArtifactError::CorruptManifest {
437 path: PathBuf::from(path),
438 reason: reason.into(),
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 fn manifest_input() -> EmbeddingGenerationManifestInput {
447 EmbeddingGenerationManifestInput {
448 compatibility_id: EmbeddingCompatibilityId::from_hex(&"11".repeat(32)).unwrap(),
449 source: EmbeddingSourceState::new(7, [2; 32], [3; 32], 2),
450 content_digest: EmbeddingContentDigest::digest(b"canonical rows"),
451 vector_count: 2,
452 dimension: 4,
453 generated_at_micros: 100,
454 committed_at_micros: 101,
455 publication_fingerprint: EmbeddingPublicationFingerprint::digest(b"parquet"),
456 }
457 }
458
459 #[test]
460 fn every_source_component_changes_fingerprint() {
461 let base = EmbeddingSourceState::new(1, [2; 32], [3; 32], 4).fingerprint();
462 let changed = [
463 EmbeddingSourceState::new(2, [2; 32], [3; 32], 4).fingerprint(),
464 EmbeddingSourceState::new(1, [9; 32], [3; 32], 4).fingerprint(),
465 EmbeddingSourceState::new(1, [2; 32], [9; 32], 4).fingerprint(),
466 EmbeddingSourceState::new(1, [2; 32], [3; 32], 5).fingerprint(),
467 ];
468 assert!(changed.into_iter().all(|value| value != base));
469 }
470
471 #[test]
472 fn complete_manifest_round_trips_exactly() {
473 let manifest = EmbeddingGenerationManifest::new(manifest_input()).unwrap();
474 let bytes = manifest.to_canonical_json().unwrap();
475 let reopened =
476 EmbeddingGenerationManifest::from_json(Path::new("manifest.json"), &bytes).unwrap();
477 assert_eq!(reopened, manifest);
478 assert_eq!(reopened.to_canonical_json().unwrap(), bytes);
479 assert_eq!(
480 reopened.generation_id(),
481 EmbeddingGenerationId::for_generation(
482 reopened.compatibility_id(),
483 reopened.source().fingerprint(),
484 reopened.content_digest()
485 )
486 );
487 }
488
489 #[test]
490 fn constructor_rejects_incomplete_dimensions_and_timestamps() {
491 let mut incomplete = manifest_input();
492 incomplete.vector_count = 1;
493 assert!(EmbeddingGenerationManifest::new(incomplete).is_err());
494
495 let mut zero_dimension = manifest_input();
496 zero_dimension.dimension = 0;
497 assert!(EmbeddingGenerationManifest::new(zero_dimension).is_err());
498
499 let mut negative = manifest_input();
500 negative.generated_at_micros = -1;
501 assert!(EmbeddingGenerationManifest::new(negative).is_err());
502
503 let mut reversed = manifest_input();
504 reversed.committed_at_micros = 99;
505 assert!(EmbeddingGenerationManifest::new(reversed).is_err());
506 }
507
508 #[test]
509 fn reopen_rejects_identity_digest_and_shape_corruption() {
510 let manifest = EmbeddingGenerationManifest::new(manifest_input()).unwrap();
511 let mut value: Value =
512 serde_json::from_slice(&manifest.to_canonical_json().unwrap()).unwrap();
513 value["generation_id"] = Value::String("22".repeat(32));
514 assert!(matches!(
515 EmbeddingGenerationManifest::from_json(
516 Path::new("manifest.json"),
517 &serde_json::to_vec(&value).unwrap()
518 ),
519 Err(SearchArtifactError::CorruptManifest { .. })
520 ));
521
522 value["generation_id"] = Value::String(manifest.generation_id().to_hex());
523 value["content_digest"] = Value::String("not-a-digest".to_owned());
524 assert!(matches!(
525 EmbeddingGenerationManifest::from_json(
526 Path::new("manifest.json"),
527 &serde_json::to_vec(&value).unwrap()
528 ),
529 Err(SearchArtifactError::CorruptManifest { .. })
530 ));
531
532 value["content_digest"] = Value::String(manifest.content_digest().to_hex());
533 value["unknown"] = Value::Bool(true);
534 assert!(matches!(
535 EmbeddingGenerationManifest::from_json(
536 Path::new("manifest.json"),
537 &serde_json::to_vec(&value).unwrap()
538 ),
539 Err(SearchArtifactError::CorruptManifest { .. })
540 ));
541 }
542
543 #[test]
544 fn reopen_rejects_duplicate_noncanonical_version_and_size() {
545 let manifest = EmbeddingGenerationManifest::new(manifest_input()).unwrap();
546 let canonical = String::from_utf8(manifest.to_canonical_json().unwrap()).unwrap();
547 let duplicate = canonical.replacen(r#""dimension":4"#, r#""dimension":4,"dimension":4"#, 1);
548 assert_ne!(duplicate, canonical);
549 let padded = format!(" {canonical}");
550 for bytes in [duplicate.as_bytes(), padded.as_bytes()] {
551 assert!(matches!(
552 EmbeddingGenerationManifest::from_json(Path::new("manifest.json"), bytes),
553 Err(SearchArtifactError::CorruptManifest { .. })
554 ));
555 }
556
557 let mut value: Value = serde_json::from_str(&canonical).unwrap();
558 value["manifest_version"] = Value::from(99);
559 assert!(matches!(
560 EmbeddingGenerationManifest::from_json(
561 Path::new("manifest.json"),
562 &serde_json::to_vec(&value).unwrap()
563 ),
564 Err(SearchArtifactError::IncompatibleManifest {
565 found: 99,
566 supported: EMBEDDING_GENERATION_MANIFEST_VERSION,
567 ..
568 })
569 ));
570
571 let oversized = vec![b' '; MAX_EMBEDDING_GENERATION_MANIFEST_BYTES + 1];
572 assert!(matches!(
573 EmbeddingGenerationManifest::from_json(Path::new("manifest.json"), &oversized),
574 Err(SearchArtifactError::ResourceExhausted {
575 resource: "embedding_generation_manifest_bytes",
576 ..
577 })
578 ));
579 }
580}