Skip to main content

ferrum_models/vnext/
source.rs

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/// Exact physical weight artifact selected by product source resolution.
25#[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/// Non-serialized product source lease shared by tokenizer, model preparation,
58/// plan resolution, and the executor. Source roles remain distinct even when
59/// all three happen to resolve to one Hugging Face snapshot.
60#[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    /// Opens one immutable role-specific bundle after validating the exact
131    /// semantic bytes retained by the bundle. The preflight runs before the
132    /// weight path is normalized, listed, fingerprinted, or opened.
133    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    /// Compatibility constructor for existing co-located safetensors callers.
196    /// Product entrypoints should construct explicit role-specific sources.
197    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    /// Physical checkpoint metadata retained independently from the semantic
250    /// model config. Quantized safetensors commonly repeat semantic fields in
251    /// this file, but only physical format metadata may be selected from it.
252    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                    // A GGUF artifact contributes exactly its one selected file.
292                    || 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            // Repository evidence must distinguish subdirectories containing
416            // the same basename. Preserve the snapshot path before LFS resolution.
417            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    // Cache object names may be Xet IDs rather than SHA256 content digests.
483    // Fingerprint the bytes consumed by every source, including HF snapshots.
484    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
580/// Recover a stable repository/revision identity only from an exact Hugging
581/// Face cache snapshot root or an artifact below it. Nested artifact directories
582/// retain the same snapshot identity; ambiguous nested cache roots are rejected.
583/// The path is
584/// inspected structurally without canonicalizing an LFS symlink into `blobs/`.
585pub 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        // Standard SHA256 known answer, independent of the cache locator.
907        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}