1use std::fmt;
2use std::fs::File;
3use std::io::{BufReader, Read};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use ferrum_interfaces::vnext::{
8 FileFingerprint, ModelArtifactSourceRole, ModelSourceKind, OriginalModelSource,
9 OriginalModelSources, ProductModelArtifactBinding, ProductModelSourceIdentity,
10 ResolvedModelSource, ResolvedModelSources,
11};
12use ferrum_types::{FerrumError, Result};
13use sha2::{Digest, Sha256};
14
15const TOKENIZER_REQUIRED_FILES: &[&str] = &["tokenizer.json"];
16const TOKENIZER_OPTIONAL_FILES: &[&str] = &[
17 "tokenizer_config.json",
18 "generation_config.json",
19 "special_tokens_map.json",
20 "chat_template.json",
21 "chat_template.jinja",
22];
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum ProductionWeightArtifact {
27 SafetensorsDirectory(PathBuf),
28 GgufFile(PathBuf),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct HuggingFaceSnapshotIdentity {
33 pub repository_id: String,
34 pub revision: String,
35}
36
37impl ProductionWeightArtifact {
38 pub fn safetensors_directory(path: impl Into<PathBuf>) -> Self {
39 Self::SafetensorsDirectory(path.into())
40 }
41
42 pub fn gguf_file(path: impl Into<PathBuf>) -> Self {
43 Self::GgufFile(path.into())
44 }
45
46 pub fn path(&self) -> &Path {
47 match self {
48 Self::SafetensorsDirectory(path) | Self::GgufFile(path) => path,
49 }
50 }
51
52 pub fn is_gguf(&self) -> bool {
53 matches!(self, Self::GgufFile(_))
54 }
55}
56
57#[derive(Clone)]
61pub struct ProductionModelSourceBundle {
62 semantic_root: PathBuf,
63 tokenizer_root: PathBuf,
64 weights: ProductionWeightArtifact,
65 original_sources: OriginalModelSources,
66 resolved_sources: ResolvedModelSources,
67 config_json: Arc<[u8]>,
68 weight_config_json: Option<Arc<[u8]>>,
69 tokenizer_json: Arc<[u8]>,
70 tokenizer_config_json: Option<Arc<[u8]>>,
71 generation_config_json: Option<Arc<[u8]>>,
72 chat_template_json: Option<Arc<[u8]>>,
73 chat_template_jinja: Option<Arc<[u8]>>,
74}
75
76impl fmt::Debug for ProductionModelSourceBundle {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter
79 .debug_struct("ProductionModelSourceBundle")
80 .field("semantic_root", &self.semantic_root)
81 .field("tokenizer_root", &self.tokenizer_root)
82 .field("weights", &self.weights)
83 .field("original_sources", &self.original_sources)
84 .field("resolved_sources", &self.resolved_sources)
85 .field("config_json_bytes", &self.config_json.len())
86 .field(
87 "weight_config_json_bytes",
88 &self.weight_config_json.as_ref().map(|bytes| bytes.len()),
89 )
90 .field("tokenizer_json_bytes", &self.tokenizer_json.len())
91 .field(
92 "tokenizer_config_json_bytes",
93 &self.tokenizer_config_json.as_ref().map(|bytes| bytes.len()),
94 )
95 .field(
96 "generation_config_json_bytes",
97 &self
98 .generation_config_json
99 .as_ref()
100 .map(|bytes| bytes.len()),
101 )
102 .field(
103 "chat_template_json_bytes",
104 &self.chat_template_json.as_ref().map(|bytes| bytes.len()),
105 )
106 .field(
107 "chat_template_jinja_bytes",
108 &self.chat_template_jinja.as_ref().map(|bytes| bytes.len()),
109 )
110 .finish()
111 }
112}
113
114impl ProductionModelSourceBundle {
115 pub fn open(
116 semantic_root: impl AsRef<Path>,
117 tokenizer_root: impl AsRef<Path>,
118 weights: ProductionWeightArtifact,
119 original_sources: OriginalModelSources,
120 ) -> Result<Self> {
121 Self::open_with_semantic_preflight(
122 semantic_root,
123 tokenizer_root,
124 weights,
125 original_sources,
126 |_| Ok(()),
127 )
128 }
129
130 pub(super) fn open_with_semantic_preflight(
134 semantic_root: impl AsRef<Path>,
135 tokenizer_root: impl AsRef<Path>,
136 weights: ProductionWeightArtifact,
137 original_sources: OriginalModelSources,
138 semantic_preflight: impl FnOnce(&[u8]) -> Result<()>,
139 ) -> Result<Self> {
140 let semantic_root = canonical_directory(semantic_root.as_ref(), "semantic source")?;
141 let config_json = read_required_file(&semantic_root.join("config.json"))?;
142 semantic_preflight(&config_json)?;
143
144 let tokenizer_root = canonical_directory(tokenizer_root.as_ref(), "tokenizer source")?;
145 let weights = normalize_weight_artifact(weights)?;
146
147 let semantic_files = vec![fingerprint_loaded_file("config.json", &config_json)?];
148 let tokenizer_files = fingerprint_named_files(
149 &tokenizer_root,
150 TOKENIZER_REQUIRED_FILES,
151 TOKENIZER_OPTIONAL_FILES,
152 )?;
153 let weight_files = fingerprint_weight_artifact(&weights)?;
154
155 let weight_config_json = match &weights {
156 ProductionWeightArtifact::SafetensorsDirectory(root) => {
157 read_optional_file(&root.join("config.json"))?
158 }
159 ProductionWeightArtifact::GgufFile(_) => None,
160 };
161 let tokenizer_json = read_required_file(&tokenizer_root.join("tokenizer.json"))?;
162 let tokenizer_config_json =
163 read_optional_file(&tokenizer_root.join("tokenizer_config.json"))?;
164 let generation_config_json =
165 read_optional_file(&tokenizer_root.join("generation_config.json"))?;
166 let chat_template_json = read_optional_file(&tokenizer_root.join("chat_template.json"))?;
167 let chat_template_jinja = read_optional_file(&tokenizer_root.join("chat_template.jinja"))?;
168
169 let resolved_sources = ResolvedModelSources {
170 semantic: resolved_source(&original_sources.semantic, &semantic_root, semantic_files)?,
171 tokenizer: resolved_source(
172 &original_sources.tokenizer,
173 &tokenizer_root,
174 tokenizer_files,
175 )?,
176 weights: resolved_source(&original_sources.weights, weights.path(), weight_files)?,
177 };
178
179 Ok(Self {
180 semantic_root,
181 tokenizer_root,
182 weights,
183 original_sources,
184 resolved_sources,
185 config_json,
186 weight_config_json,
187 tokenizer_json,
188 tokenizer_config_json,
189 generation_config_json,
190 chat_template_json,
191 chat_template_jinja,
192 })
193 }
194
195 pub fn open_colocated_safetensors(model_dir: impl AsRef<Path>) -> Result<Self> {
198 let model_dir = model_dir.as_ref();
199 let location = model_dir.display().to_string();
200 let original = OriginalModelSource {
201 kind: ModelSourceKind::LocalDirectory,
202 location,
203 requested_revision: None,
204 };
205 Self::open(
206 model_dir,
207 model_dir,
208 ProductionWeightArtifact::safetensors_directory(model_dir),
209 OriginalModelSources {
210 semantic: original.clone(),
211 tokenizer: original.clone(),
212 weights: original,
213 },
214 )
215 }
216
217 pub fn semantic_root(&self) -> &Path {
218 &self.semantic_root
219 }
220
221 pub fn tokenizer_root(&self) -> &Path {
222 &self.tokenizer_root
223 }
224
225 pub fn tokenizer_file(&self) -> PathBuf {
226 self.tokenizer_root.join("tokenizer.json")
227 }
228
229 pub fn weights(&self) -> &ProductionWeightArtifact {
230 &self.weights
231 }
232
233 pub fn original_sources(&self) -> &OriginalModelSources {
234 &self.original_sources
235 }
236
237 pub fn resolved_sources(&self) -> &ResolvedModelSources {
238 &self.resolved_sources
239 }
240
241 pub fn config_json(&self) -> &[u8] {
242 &self.config_json
243 }
244
245 pub fn tokenizer_json(&self) -> &[u8] {
246 &self.tokenizer_json
247 }
248
249 pub fn weight_config_json(&self) -> Option<&[u8]> {
253 self.weight_config_json.as_deref()
254 }
255
256 pub fn tokenizer_config_json(&self) -> Option<&[u8]> {
257 self.tokenizer_config_json.as_deref()
258 }
259
260 pub fn generation_config_json(&self) -> Option<&[u8]> {
261 self.generation_config_json.as_deref()
262 }
263
264 pub fn chat_template_json(&self) -> Option<&[u8]> {
265 self.chat_template_json.as_deref()
266 }
267
268 pub fn chat_template_jinja(&self) -> Option<&[u8]> {
269 self.chat_template_jinja.as_deref()
270 }
271
272 pub fn fingerprint(
273 &self,
274 role: ModelArtifactSourceRole,
275 relative_path: &str,
276 ) -> Option<&FileFingerprint> {
277 self.resolved_sources
278 .for_role(role)
279 .files
280 .iter()
281 .find(|file| file.relative_path == relative_path)
282 }
283
284 pub fn weight_payload_bytes(&self) -> Result<u64> {
285 self.resolved_sources
286 .weights
287 .files
288 .iter()
289 .filter(|file| {
290 file.relative_path.ends_with(".safetensors")
291 || self.weights.is_gguf()
293 })
294 .try_fold(0_u64, |total, file| {
295 total.checked_add(file.size_bytes).ok_or_else(|| {
296 FerrumError::model("resolved weight payload byte size overflows u64")
297 })
298 })
299 }
300
301 pub fn product_source_identity(
302 &self,
303 requested_model: impl Into<String>,
304 resolved_model: impl Into<String>,
305 template_source_file: &str,
306 template_content: &str,
307 ) -> Result<ProductModelSourceIdentity> {
308 let binding = |role, source_file: &str, content_sha256: Option<String>| {
309 let fingerprint = self.fingerprint(role, source_file).ok_or_else(|| {
310 FerrumError::model(format!(
311 "selected {role:?} source file is absent: {source_file}"
312 ))
313 })?;
314 ProductModelArtifactBinding::new(
315 role,
316 source_file,
317 fingerprint.sha256.clone(),
318 content_sha256,
319 )
320 .map_err(|error| FerrumError::model(error.to_string()))
321 };
322 let semantic_config = binding(ModelArtifactSourceRole::Semantic, "config.json", None)?;
323 let tokenizer = binding(ModelArtifactSourceRole::Tokenizer, "tokenizer.json", None)?;
324 let template = binding(
325 ModelArtifactSourceRole::Tokenizer,
326 template_source_file,
327 Some(format!("{:x}", Sha256::digest(template_content.as_bytes()))),
328 )?;
329 let weight_config = self
330 .weight_config_json
331 .as_ref()
332 .map(|_| binding(ModelArtifactSourceRole::Weights, "config.json", None))
333 .transpose()?;
334 ProductModelSourceIdentity::new(
335 requested_model,
336 resolved_model,
337 self.original_sources.clone(),
338 self.resolved_sources.clone(),
339 semantic_config,
340 tokenizer,
341 template,
342 weight_config,
343 )
344 .map_err(|error| FerrumError::model(error.to_string()))
345 }
346}
347
348fn canonical_directory(path: &Path, kind: &str) -> Result<PathBuf> {
349 if !path.is_dir() {
350 return Err(FerrumError::model(format!(
351 "{kind} is not a directory: {}",
352 path.display()
353 )));
354 }
355 path.canonicalize()
356 .map_err(|error| FerrumError::model(format!("canonicalize {}: {error}", path.display())))
357}
358
359fn normalize_weight_artifact(
360 artifact: ProductionWeightArtifact,
361) -> Result<ProductionWeightArtifact> {
362 match artifact {
363 ProductionWeightArtifact::SafetensorsDirectory(path) => {
364 Ok(ProductionWeightArtifact::SafetensorsDirectory(
365 canonical_directory(&path, "safetensors weight source")?,
366 ))
367 }
368 ProductionWeightArtifact::GgufFile(path) => {
369 if !path.is_file() {
370 return Err(FerrumError::model(format!(
371 "GGUF weight source is not a file: {}",
372 path.display()
373 )));
374 }
375 let file_name = path.file_name().ok_or_else(|| {
376 FerrumError::model(format!("GGUF source has no file name: {}", path.display()))
377 })?;
378 let parent = path.parent().ok_or_else(|| {
379 FerrumError::model(format!("GGUF source has no parent: {}", path.display()))
380 })?;
381 let parent = parent.canonicalize().map_err(|error| {
382 FerrumError::model(format!("canonicalize {}: {error}", parent.display()))
383 })?;
384 Ok(ProductionWeightArtifact::GgufFile(parent.join(file_name)))
385 }
386 }
387}
388
389fn fingerprint_named_files(
390 root: &Path,
391 required: &[&str],
392 optional: &[&str],
393) -> Result<Vec<FileFingerprint>> {
394 let mut files = Vec::with_capacity(required.len() + optional.len());
395 for relative_path in required {
396 files.push(fingerprint_file(root, relative_path)?);
397 }
398 for relative_path in optional {
399 if root.join(relative_path).is_file() {
400 files.push(fingerprint_file(root, relative_path)?);
401 }
402 }
403 files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
404 Ok(files)
405}
406
407fn fingerprint_weight_artifact(
408 artifact: &ProductionWeightArtifact,
409) -> Result<Vec<FileFingerprint>> {
410 match artifact {
411 ProductionWeightArtifact::GgufFile(path) => {
412 let parent = path
413 .parent()
414 .ok_or_else(|| FerrumError::model("GGUF source has no parent"))?;
415 let root = if huggingface_snapshot_identity(path).is_some() {
418 path.ancestors()
419 .find(|root| huggingface_snapshot_root_identity(root).is_some())
420 .unwrap_or(parent)
421 } else {
422 parent
423 };
424 let relative = path.strip_prefix(root).map_err(|error| {
425 FerrumError::model(format!("GGUF artifact is outside its source root: {error}"))
426 })?;
427 let name = relative
428 .to_str()
429 .ok_or_else(|| FerrumError::model("GGUF source file name is not UTF-8"))?
430 .replace(std::path::MAIN_SEPARATOR, "/");
431 fingerprint_file(root, &name).map(|file| vec![file])
432 }
433 ProductionWeightArtifact::SafetensorsDirectory(root) => {
434 let mut relative_paths = std::fs::read_dir(root)
435 .map_err(|error| {
436 FerrumError::model(format!("read weight directory {}: {error}", root.display()))
437 })?
438 .filter_map(|entry| entry.ok())
439 .filter_map(|entry| {
440 let path = entry.path();
441 let name = path.file_name()?.to_str()?.to_owned();
442 (path.is_file()
443 && (name.ends_with(".safetensors")
444 || name == "model.safetensors.index.json"
445 || name == "config.json"))
446 .then_some(name)
447 })
448 .collect::<Vec<_>>();
449 relative_paths.sort();
450 if !relative_paths
451 .iter()
452 .any(|path| path.ends_with(".safetensors"))
453 {
454 return Err(FerrumError::model(format!(
455 "safetensors source contains no shard: {}",
456 root.display()
457 )));
458 }
459 relative_paths
460 .iter()
461 .map(|path| fingerprint_file(root, path))
462 .collect()
463 }
464 }
465}
466
467fn fingerprint_file(root: &Path, relative_path: &str) -> Result<FileFingerprint> {
468 if !portable_relative_path(relative_path) {
469 return Err(FerrumError::model(format!(
470 "source manifest path is not portable: {relative_path:?}"
471 )));
472 }
473 let path = root.join(relative_path);
474 let metadata = std::fs::metadata(&path)
475 .map_err(|error| FerrumError::model(format!("stat {}: {error}", path.display())))?;
476 if !metadata.is_file() || metadata.len() == 0 {
477 return Err(FerrumError::model(format!(
478 "source manifest file is missing or empty: {}",
479 path.display()
480 )));
481 }
482 let sha256 = hash_file(&path)?;
485 Ok(FileFingerprint {
486 relative_path: relative_path.to_owned(),
487 size_bytes: metadata.len(),
488 sha256,
489 })
490}
491
492fn fingerprint_loaded_file(relative_path: &str, bytes: &[u8]) -> Result<FileFingerprint> {
493 if !portable_relative_path(relative_path) {
494 return Err(FerrumError::model(format!(
495 "source manifest path is not portable: {relative_path:?}"
496 )));
497 }
498 if bytes.is_empty() {
499 return Err(FerrumError::model(format!(
500 "source manifest file is empty: {relative_path}"
501 )));
502 }
503 let size_bytes = u64::try_from(bytes.len())
504 .map_err(|_| FerrumError::internal("source file size exceeds u64"))?;
505 Ok(FileFingerprint {
506 relative_path: relative_path.to_owned(),
507 size_bytes,
508 sha256: format!("{:x}", Sha256::digest(bytes)),
509 })
510}
511
512fn hash_file(path: &Path) -> Result<String> {
513 let started = std::time::Instant::now();
514 let file = File::open(path)
515 .map_err(|error| FerrumError::model(format!("open {}: {error}", path.display())))?;
516 let mut reader = BufReader::with_capacity(4 * 1024 * 1024, file);
517 let mut hasher = Sha256::new();
518 let mut buffer = vec![0_u8; 4 * 1024 * 1024];
519 let mut bytes_hashed = 0_u64;
520 loop {
521 let count = reader
522 .read(&mut buffer)
523 .map_err(|error| FerrumError::model(format!("read {}: {error}", path.display())))?;
524 if count == 0 {
525 break;
526 }
527 hasher.update(&buffer[..count]);
528 bytes_hashed = bytes_hashed.saturating_add(count as u64);
529 }
530 let fingerprint = format!("{:x}", hasher.finalize());
531 tracing::debug!(
532 target: "ferrum.startup",
533 phase = "source_fingerprint",
534 source_path = %path.display(),
535 bytes_hashed,
536 duration_us = started.elapsed().as_micros() as u64,
537 "Product source content fingerprint completed"
538 );
539 Ok(fingerprint)
540}
541
542fn resolved_source(
543 original: &OriginalModelSource,
544 path: &Path,
545 files: Vec<FileFingerprint>,
546) -> Result<ResolvedModelSource> {
547 let manifest_revision = manifest_revision(&files)?;
548 let snapshot_identity = huggingface_snapshot_identity(path);
549 let (canonical_location, resolved_revision) = if let Some(identity) = snapshot_identity {
550 (identity.repository_id, identity.revision)
551 } else {
552 match original.kind {
553 ModelSourceKind::Repository => (
554 original.location.clone(),
555 huggingface_snapshot_revision(path).ok_or_else(|| {
556 FerrumError::model(format!(
557 "repository source {} did not resolve below snapshots/<revision>: {}",
558 original.location,
559 path.display()
560 ))
561 })?,
562 ),
563 ModelSourceKind::LocalDirectory | ModelSourceKind::LocalFile => {
564 (path.display().to_string(), manifest_revision)
565 }
566 ModelSourceKind::ReleaseArtifact => {
567 return Err(FerrumError::unsupported(
568 "release artifact source bundles are not implemented",
569 ))
570 }
571 }
572 };
573 Ok(ResolvedModelSource {
574 canonical_location,
575 resolved_revision,
576 files,
577 })
578}
579
580pub fn huggingface_snapshot_identity(path: &Path) -> Option<HuggingFaceSnapshotIdentity> {
586 if path
587 .components()
588 .any(|part| matches!(part, std::path::Component::ParentDir))
589 {
590 return None;
591 }
592 let mut identities = path
593 .ancestors()
594 .filter_map(huggingface_snapshot_root_identity);
595 let identity = identities.next()?;
596 identities.next().is_none().then_some(identity)
597}
598
599fn huggingface_snapshot_root_identity(root: &Path) -> Option<HuggingFaceSnapshotIdentity> {
600 let revision = root.file_name()?.to_str()?;
601 if revision.len() != 40
602 || !revision
603 .bytes()
604 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
605 {
606 return None;
607 }
608 let snapshots = root.parent()?;
609 if snapshots.file_name()?.to_str()? != "snapshots" {
610 return None;
611 }
612 let repository_dir = snapshots.parent()?.file_name()?.to_str()?;
613 let encoded = repository_dir.strip_prefix("models--")?;
614 let components = encoded.split("--").collect::<Vec<_>>();
615 if components.len() != 2 || components.iter().any(|component| component.is_empty()) {
616 return None;
617 }
618 let repository_id = components.join("/");
619 if format!("models--{}", repository_id.replace('/', "--")) != repository_dir {
620 return None;
621 }
622 Some(HuggingFaceSnapshotIdentity {
623 repository_id,
624 revision: revision.to_owned(),
625 })
626}
627
628fn huggingface_snapshot_revision(path: &Path) -> Option<String> {
629 let root = if path.is_file() { path.parent()? } else { path };
630 if root.parent()?.file_name()?.to_str()? != "snapshots" {
631 return None;
632 }
633 root.file_name()?.to_str().map(str::to_owned)
634}
635
636fn manifest_revision(files: &[FileFingerprint]) -> Result<String> {
637 let bytes = serde_json::to_vec(files)
638 .map_err(|error| FerrumError::internal(format!("serialize source manifest: {error}")))?;
639 Ok(format!("{:x}", Sha256::digest(bytes)))
640}
641
642fn read_required_file(path: &Path) -> Result<Arc<[u8]>> {
643 let bytes = std::fs::read(path)
644 .map_err(|error| FerrumError::model(format!("read {}: {error}", path.display())))?;
645 if bytes.is_empty() {
646 return Err(FerrumError::model(format!(
647 "required source file is empty: {}",
648 path.display()
649 )));
650 }
651 Ok(bytes.into())
652}
653
654fn read_optional_file(path: &Path) -> Result<Option<Arc<[u8]>>> {
655 if !path.is_file() {
656 return Ok(None);
657 }
658 read_required_file(path).map(Some)
659}
660
661fn portable_relative_path(path: &str) -> bool {
662 !path.is_empty()
663 && !path.starts_with('/')
664 && !path.ends_with('/')
665 && !path.contains('\\')
666 && path
667 .split('/')
668 .all(|component| !matches!(component, "" | "." | ".."))
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674
675 fn original(kind: ModelSourceKind, location: &str) -> OriginalModelSource {
676 OriginalModelSource {
677 kind,
678 location: location.to_owned(),
679 requested_revision: None,
680 }
681 }
682
683 #[test]
684 fn preserves_three_distinct_roots_and_same_named_files() {
685 let root = tempfile::tempdir().unwrap();
686 let semantic = root.path().join("semantic");
687 let tokenizer = root.path().join("tokenizer");
688 let weights = root.path().join("weights");
689 std::fs::create_dir_all(&semantic).unwrap();
690 std::fs::create_dir_all(&tokenizer).unwrap();
691 std::fs::create_dir_all(&weights).unwrap();
692 std::fs::write(
693 semantic.join("config.json"),
694 br#"{"architectures":["Fixture"]}"#,
695 )
696 .unwrap();
697 std::fs::write(tokenizer.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
698 std::fs::write(
699 tokenizer.join("tokenizer_config.json"),
700 br#"{"chat_template":"fixture"}"#,
701 )
702 .unwrap();
703 std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
704
705 let bundle = ProductionModelSourceBundle::open(
706 &semantic,
707 &tokenizer,
708 ProductionWeightArtifact::safetensors_directory(&weights),
709 OriginalModelSources {
710 semantic: original(ModelSourceKind::LocalDirectory, "semantic-input"),
711 tokenizer: original(ModelSourceKind::LocalDirectory, "tokenizer-input"),
712 weights: original(ModelSourceKind::LocalDirectory, "weights-input"),
713 },
714 )
715 .unwrap();
716
717 assert_ne!(bundle.semantic_root(), bundle.tokenizer_root());
718 assert_ne!(bundle.tokenizer_root(), bundle.weights().path());
719 assert_eq!(
720 bundle
721 .fingerprint(ModelArtifactSourceRole::Semantic, "config.json")
722 .unwrap()
723 .size_bytes,
724 29
725 );
726 assert!(bundle
727 .fingerprint(ModelArtifactSourceRole::Tokenizer, "tokenizer.json")
728 .is_some());
729 assert!(bundle
730 .fingerprint(ModelArtifactSourceRole::Weights, "model.safetensors")
731 .is_some());
732 assert_eq!(bundle.weight_payload_bytes().unwrap(), 15);
733 }
734
735 #[test]
736 fn semantic_preflight_fingerprint_and_retained_bytes_are_atomic() {
737 let root = tempfile::tempdir().unwrap();
738 let semantic = root.path().join("semantic");
739 let tokenizer = root.path().join("tokenizer");
740 let weights = root.path().join("weights");
741 std::fs::create_dir_all(&semantic).unwrap();
742 std::fs::create_dir_all(&tokenizer).unwrap();
743 std::fs::create_dir_all(&weights).unwrap();
744 let original_config = br#"{"architectures":["Original"]}"#;
745 std::fs::write(semantic.join("config.json"), original_config).unwrap();
746 std::fs::write(tokenizer.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
747 std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
748
749 let bundle = ProductionModelSourceBundle::open_with_semantic_preflight(
750 &semantic,
751 &tokenizer,
752 ProductionWeightArtifact::safetensors_directory(&weights),
753 OriginalModelSources {
754 semantic: original(ModelSourceKind::LocalDirectory, "semantic-input"),
755 tokenizer: original(ModelSourceKind::LocalDirectory, "tokenizer-input"),
756 weights: original(ModelSourceKind::LocalDirectory, "weights-input"),
757 },
758 |raw| {
759 assert_eq!(raw, original_config);
760 std::fs::write(
761 semantic.join("config.json"),
762 br#"{"architectures":["Mutated"]}"#,
763 )
764 .unwrap();
765 Ok(())
766 },
767 )
768 .unwrap();
769
770 assert_eq!(bundle.config_json(), original_config);
771 assert_eq!(
772 bundle
773 .fingerprint(ModelArtifactSourceRole::Semantic, "config.json")
774 .unwrap()
775 .sha256,
776 format!("{:x}", Sha256::digest(original_config))
777 );
778 }
779
780 #[test]
781 fn local_huggingface_snapshot_paths_recover_stable_role_identities() {
782 let root = tempfile::tempdir().unwrap();
783 let semantic_revision = "a".repeat(40);
784 let weight_revision = "b".repeat(40);
785 let semantic = root
786 .path()
787 .join("models--Qwen--Qwen3.5-35B-A3B")
788 .join("snapshots")
789 .join(&semantic_revision);
790 let weights = root
791 .path()
792 .join("models--Qwen--Qwen3.5-35B-A3B-GPTQ-Int4")
793 .join("snapshots")
794 .join(&weight_revision);
795 std::fs::create_dir_all(&semantic).unwrap();
796 std::fs::create_dir_all(&weights).unwrap();
797 std::fs::write(
798 semantic.join("config.json"),
799 br#"{"architectures":["Fixture"]}"#,
800 )
801 .unwrap();
802 std::fs::write(semantic.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
803 std::fs::write(
804 semantic.join("tokenizer_config.json"),
805 br#"{"chat_template":"fixture"}"#,
806 )
807 .unwrap();
808 std::fs::write(
809 weights.join("config.json"),
810 br#"{"quantization_config":{"quant_method":"gptq"}}"#,
811 )
812 .unwrap();
813 std::fs::write(weights.join("model.safetensors"), b"fixture-weights").unwrap();
814 let local = |path: &Path| OriginalModelSource {
815 kind: ModelSourceKind::LocalDirectory,
816 location: path.display().to_string(),
817 requested_revision: None,
818 };
819 let bundle = ProductionModelSourceBundle::open(
820 &semantic,
821 &semantic,
822 ProductionWeightArtifact::safetensors_directory(&weights),
823 OriginalModelSources {
824 semantic: local(&semantic),
825 tokenizer: local(&semantic),
826 weights: local(&weights),
827 },
828 )
829 .unwrap();
830
831 assert_eq!(
832 bundle.resolved_sources().semantic.canonical_location,
833 "Qwen/Qwen3.5-35B-A3B"
834 );
835 assert_eq!(
836 bundle.resolved_sources().semantic.resolved_revision,
837 semantic_revision
838 );
839 assert_eq!(
840 bundle.resolved_sources().weights.canonical_location,
841 "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4"
842 );
843 assert_eq!(
844 bundle.resolved_sources().weights.resolved_revision,
845 weight_revision
846 );
847 let identity = bundle
848 .product_source_identity(
849 weights.display().to_string(),
850 "Qwen/Qwen3.5-35B-A3B-GPTQ-Int4",
851 "tokenizer_config.json",
852 "fixture",
853 )
854 .unwrap();
855 let template_sha256 = format!("{:x}", Sha256::digest(b"fixture"));
856 assert_eq!(identity.template.content_sha256, Some(template_sha256));
857 assert!(identity.weight_config.is_some());
858 }
859
860 #[test]
861 fn huggingface_snapshot_identity_rejects_ambiguous_cache_paths() {
862 let revision = "a".repeat(40);
863 assert!(huggingface_snapshot_identity(Path::new(&format!(
864 "/cache/models--Qwen--Model/snapshots/{revision}"
865 )))
866 .is_some());
867 assert!(huggingface_snapshot_identity(Path::new(
868 "/cache/models--Qwen--Model/snapshots/main"
869 ))
870 .is_none());
871 assert!(huggingface_snapshot_identity(Path::new(&format!(
872 "/cache/models--Qwen--Nested--Model/snapshots/{revision}"
873 )))
874 .is_none());
875 let root = PathBuf::from(format!("/cache/models--Owner--Model/snapshots/{revision}"));
876 let nested = huggingface_snapshot_identity(&root.join("weights/quant/model.gguf")).unwrap();
877 assert_eq!(nested.repository_id, "Owner/Model");
878 assert_eq!(nested.revision, revision);
879 assert!(huggingface_snapshot_identity(&root.join("../other/model.gguf")).is_none());
880 assert!(huggingface_snapshot_identity(&root.join(format!(
881 "models--Other--Model/snapshots/{revision}/model.gguf"
882 )))
883 .is_none());
884 }
885
886 #[cfg(unix)]
887 #[test]
888 fn huggingface_cache_ids_do_not_substitute_for_content_fingerprints() {
889 use std::os::unix::fs::symlink;
890
891 let root = tempfile::tempdir().unwrap();
892 let repository = root.path().join("models--fixture--weights");
893 let blobs = repository.join("blobs");
894 let snapshot = repository.join("snapshots").join("revision");
895 std::fs::create_dir_all(&blobs).unwrap();
896 std::fs::create_dir_all(&snapshot).unwrap();
897 let object_id = "a".repeat(64);
898 std::fs::write(blobs.join(&object_id), b"abc").unwrap();
899 symlink(
900 Path::new("../../blobs").join(&object_id),
901 snapshot.join("model.safetensors"),
902 )
903 .unwrap();
904
905 let fingerprint = fingerprint_file(&snapshot, "model.safetensors").unwrap();
906 assert_eq!(
908 fingerprint.sha256,
909 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
910 );
911 assert_eq!(fingerprint.size_bytes, 3);
912 std::fs::write(blobs.join(&object_id), b"abd").unwrap();
913 let changed = fingerprint_file(&snapshot, "model.safetensors").unwrap();
914 assert_eq!(changed.size_bytes, fingerprint.size_bytes);
915 assert_ne!(changed.sha256, fingerprint.sha256);
916 }
917
918 #[cfg(unix)]
919 #[test]
920 fn product_package_and_direct_blob_links_have_the_same_content_identity() {
921 use std::os::unix::fs::symlink;
922
923 let root = tempfile::tempdir().unwrap();
924 let repository = root.path().join("models--fixture--Qwen3.5-4B-GGUF");
925 let blobs = repository.join("blobs");
926 let snapshot = repository.join("snapshots").join("revision");
927 let package = root.path().join("product-package");
928 std::fs::create_dir_all(&blobs).unwrap();
929 std::fs::create_dir_all(&snapshot).unwrap();
930 std::fs::create_dir_all(&package).unwrap();
931 let digest = "b".repeat(64);
932 std::fs::write(blobs.join(&digest), b"weight-bytes").unwrap();
933 let snapshot_weight = snapshot.join("model.gguf");
934 symlink(Path::new("../../blobs").join(&digest), &snapshot_weight).unwrap();
935 symlink(&snapshot_weight, package.join("model.gguf")).unwrap();
936
937 let fingerprint = fingerprint_file(&package, "model.gguf").unwrap();
938 assert_eq!(
939 fingerprint.sha256,
940 format!("{:x}", Sha256::digest(b"weight-bytes"))
941 );
942 assert_eq!(fingerprint.size_bytes, 12);
943
944 let direct_blob_link = package.join("direct-blob.gguf");
945 symlink(blobs.join(&digest), &direct_blob_link).unwrap();
946 let direct_blob_fingerprint = fingerprint_file(&package, "direct-blob.gguf").unwrap();
947 assert_eq!(
948 direct_blob_fingerprint.sha256,
949 format!("{:x}", Sha256::digest(b"weight-bytes"))
950 );
951 assert_eq!(direct_blob_fingerprint.sha256, fingerprint.sha256);
952 }
953}