Skip to main content

webnn_graph/
external_weights.rs

1//! Resolve `@weights` / [`ConstInit::Weights`] using sidecar files
2//! next to a graph path (SafeTensors or manifest + raw weights blob).
3
4use std::collections::{BTreeMap, HashMap};
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use half::bf16;
10use safetensors::tensor::{Dtype as StDtype, TensorView};
11use safetensors::SafeTensors;
12use serde::Deserialize;
13use thiserror::Error;
14
15use crate::ast::{ConstInit, DataType as AstDataType, GraphJson};
16
17/// Default graph JSON basename (typical sidecar layout next to weights / manifest).
18pub const DEFAULT_PATH_JSON: &str = "model.json";
19/// Default raw weights blob basename when not using a stem-prefixed `*.weights` file.
20pub const DEFAULT_PATH_WEIGHTS: &str = "model.weights";
21/// Default SafeTensors archive basename when not using a stem-prefixed `*.safetensors` file.
22pub const DEFAULT_PATH_SAFETENSORS: &str = "model.safetensors";
23/// Default weights manifest basename when not using a stem-prefixed `*.manifest.json` file.
24pub const DEFAULT_PATH_MANIFEST: &str = "manifest.json";
25
26/// SafeTensors archive metadata key for packed logical Int4/Uint4 tensors.
27pub const PACKED_4BIT_METADATA_KEY: &str = "rustnn.webnn.packed4";
28/// Current version of the packed logical Int4/Uint4 storage extension.
29pub const PACKED_4BIT_METADATA_VERSION: &str = "1";
30
31/// Failure while resolving external weights for a [`GraphJson`].
32#[derive(Debug, Error)]
33pub enum WeightResolveError {
34    /// Could not read a required file from disk.
35    #[error("failed to read `{path}`: {source}")]
36    ReadFile {
37        path: PathBuf,
38        #[source]
39        source: std::io::Error,
40    },
41    /// Manifest JSON is invalid.
42    #[error("failed to parse manifest JSON at `{path}`: {source}")]
43    ManifestJson {
44        path: PathBuf,
45        #[source]
46        source: serde_json::Error,
47    },
48    /// SafeTensors–specific validation or parse error.
49    #[error("[safetensors] {0}")]
50    Safetensors(String),
51    /// Manifest + weights blob resolution error.
52    #[error("[manifest-weights] {0}")]
53    Manifest(String),
54    /// No usable weight source was found next to the graph.
55    #[error("[weights] {0}")]
56    Missing(String),
57}
58
59/// Failure while writing a SafeTensors archive for a [`GraphJson`].
60#[derive(Debug, Error)]
61pub enum WeightWriteError {
62    /// The graph declarations or supplied tensor bytes are inconsistent.
63    #[error("[safetensors] {0}")]
64    Validation(String),
65    /// SafeTensors could not serialize the archive.
66    #[error("failed to write SafeTensors archive `{path}`: {source}")]
67    Serialize {
68        path: PathBuf,
69        #[source]
70        source: safetensors::SafeTensorError,
71    },
72    /// The completed temporary archive could not be installed at its destination.
73    #[error("failed to install SafeTensors archive `{path}`: {source}")]
74    Install {
75        path: PathBuf,
76        #[source]
77        source: std::io::Error,
78    },
79}
80
81static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
82
83fn graph_has_external_weight_refs(graph_json: &GraphJson) -> bool {
84    graph_json
85        .consts
86        .values()
87        .any(|c| matches!(c.init, ConstInit::Weights { .. }))
88}
89
90/// Normalizes tensor / manifest key strings for lookup when graphs use sanitized weight refs.
91#[inline]
92fn sanitize_weight_key(name: &str) -> String {
93    name.replace("::", "__").replace('.', "_")
94}
95
96fn safetensors_st_dtype_matches_ast(st: StDtype, ast: &AstDataType) -> bool {
97    matches!(
98        (ast, st),
99        (AstDataType::Float32, StDtype::F32)
100            | (AstDataType::Float16, StDtype::F16)
101            | (AstDataType::Int32, StDtype::I32)
102            | (AstDataType::Uint32, StDtype::U32)
103            | (AstDataType::Int64, StDtype::I64)
104            | (AstDataType::Uint64, StDtype::U64)
105            | (AstDataType::Int8, StDtype::I8)
106            | (AstDataType::Uint8, StDtype::U8)
107    )
108}
109
110fn ast_dtype_to_safetensors(ast: &AstDataType) -> Option<StDtype> {
111    Some(match ast {
112        AstDataType::Float32 => StDtype::F32,
113        AstDataType::Float16 => StDtype::F16,
114        AstDataType::Int32 => StDtype::I32,
115        AstDataType::Uint32 => StDtype::U32,
116        AstDataType::Int64 => StDtype::I64,
117        AstDataType::Uint64 => StDtype::U64,
118        AstDataType::Int8 => StDtype::I8,
119        AstDataType::Uint8 => StDtype::U8,
120        AstDataType::Int4 | AstDataType::Uint4 => return None,
121    })
122}
123
124fn logical_element_count(name: &str, shape: &[u32]) -> Result<usize, String> {
125    shape.iter().try_fold(1usize, |count, &dimension| {
126        count
127            .checked_mul(dimension as usize)
128            .ok_or_else(|| format!("constant `{name}` element count overflows usize"))
129    })
130}
131
132fn packed_byte_length(name: &str, shape: &[u32]) -> Result<usize, String> {
133    Ok(logical_element_count(name, shape)?.div_ceil(2))
134}
135
136fn validate_packed4_marker(bytes: &[u8], path: &Path) -> Result<(), WeightResolveError> {
137    let (_, metadata) = SafeTensors::read_metadata(bytes).map_err(|error| {
138        WeightResolveError::Safetensors(format!("failed to read `{}`: {error}", path.display()))
139    })?;
140    let marker = metadata
141        .metadata()
142        .as_ref()
143        .and_then(|values| values.get(PACKED_4BIT_METADATA_KEY));
144    match marker.map(String::as_str) {
145        Some(PACKED_4BIT_METADATA_VERSION) => Ok(()),
146        Some(version) => Err(WeightResolveError::Safetensors(format!(
147            "`{}` uses unsupported packed 4-bit format version `{version}`",
148            path.display()
149        ))),
150        None => Err(WeightResolveError::Safetensors(format!(
151            "`{}` contains external int4/uint4 weights but lacks the `{PACKED_4BIT_METADATA_KEY}` metadata marker",
152            path.display()
153        ))),
154    }
155}
156
157fn st_shape_matches_const(st_shape: &[usize], const_shape: &[u32]) -> bool {
158    if st_shape.len() != const_shape.len() {
159        return false;
160    }
161    st_shape
162        .iter()
163        .zip(const_shape.iter())
164        .all(|(&s, &c)| s as u32 == c)
165}
166
167/// Convert little-endian BF16 payload to little-endian F32 (WebNN float32 constants).
168fn bf16_bytes_to_f32_le_bytes(data: &[u8]) -> Result<Vec<u8>, WeightResolveError> {
169    if !data.len().is_multiple_of(2) {
170        return Err(WeightResolveError::Safetensors(format!(
171            "BF16 data length {} is not a multiple of 2",
172            data.len()
173        )));
174    }
175    let mut out = Vec::with_capacity(data.len() * 2);
176    for chunk in data.as_chunks::<2>().0 {
177        let bits = u16::from_le_bytes([chunk[0], chunk[1]]);
178        let v = bf16::from_bits(bits).to_f32();
179        out.extend_from_slice(&v.to_le_bytes());
180    }
181    Ok(out)
182}
183
184fn safetensors_sanitized_name_map(
185    st: &SafeTensors<'_>,
186) -> Result<HashMap<String, String>, WeightResolveError> {
187    let mut out: HashMap<String, String> = HashMap::new();
188    for name in st.names() {
189        let sanitized = sanitize_weight_key(name);
190        if let Some(prev) = out.insert(sanitized.clone(), name.to_string()) {
191            if prev.as_str() != name {
192                return Err(WeightResolveError::Safetensors(format!(
193                    "ambiguous sanitized tensor name `{sanitized}` (both `{prev}` and `{name}`)"
194                )));
195            }
196        }
197    }
198    Ok(out)
199}
200
201fn resolve_tensor_view<'a>(
202    st: &'a SafeTensors<'a>,
203    sanitized_map: &HashMap<String, String>,
204    r#ref: &str,
205) -> Result<safetensors::tensor::TensorView<'a>, WeightResolveError> {
206    if let Ok(v) = st.tensor(r#ref) {
207        return Ok(v);
208    }
209    let orig = sanitized_map.get(r#ref).ok_or_else(|| {
210        WeightResolveError::Safetensors(format!("tensor `{ref}` not found in safetensors archive"))
211    })?;
212    st.tensor(orig.as_str())
213        .map_err(|e| WeightResolveError::Safetensors(format!("tensor `{ref}` (via `{orig}`): {e}")))
214}
215
216fn read_file(path: &Path) -> Result<Vec<u8>, WeightResolveError> {
217    fs::read(path).map_err(|source| WeightResolveError::ReadFile {
218        path: path.to_path_buf(),
219        source,
220    })
221}
222
223fn inline_weights_from_safetensors(
224    graph_json: &mut GraphJson,
225    safetensors_path: &Path,
226) -> Result<(), WeightResolveError> {
227    let weight_ref_count = graph_json
228        .consts
229        .values()
230        .filter(|c| matches!(c.init, ConstInit::Weights { .. }))
231        .count();
232    eprintln!(
233        "[webnn-graph] resolve safetensors: path=`{}` weight_ref_count={}",
234        safetensors_path.display(),
235        weight_ref_count
236    );
237
238    let archive_bytes = read_file(safetensors_path)?;
239    let st = SafeTensors::deserialize(&archive_bytes).map_err(|e| {
240        WeightResolveError::Safetensors(format!("`{}`: {e}", safetensors_path.display()))
241    })?;
242    let sanitized_map = safetensors_sanitized_name_map(&st)?;
243    let has_packed_refs = graph_json.consts.values().any(|decl| {
244        matches!(decl.data_type, AstDataType::Int4 | AstDataType::Uint4)
245            && matches!(decl.init, ConstInit::Weights { .. })
246    });
247    if has_packed_refs {
248        validate_packed4_marker(&archive_bytes, safetensors_path)?;
249    }
250
251    for (const_name, const_decl) in graph_json.consts.iter_mut() {
252        let ConstInit::Weights { r#ref } = &const_decl.init else {
253            continue;
254        };
255        let view = match resolve_tensor_view(&st, &sanitized_map, r#ref) {
256            Ok(v) => v,
257            Err(e) => {
258                eprintln!(
259                    "[webnn-graph] warning: safetensors could not resolve weight ref `{ref}` \
260                     (constant `{const_name}`) from `{}`: {e}",
261                    safetensors_path.display()
262                );
263                return Err(e);
264            }
265        };
266
267        if matches!(const_decl.data_type, AstDataType::Int4 | AstDataType::Uint4) {
268            let expected = packed_byte_length(const_name, &const_decl.shape)
269                .map_err(WeightResolveError::Safetensors)?;
270            if view.dtype() != StDtype::U8 {
271                return Err(WeightResolveError::Safetensors(format!(
272                    "packed 4-bit weight `{ref}` (constant `{const_name}`) must use SafeTensors U8 storage, found {:?}",
273                    view.dtype()
274                )));
275            }
276            if view.shape() != [expected] {
277                return Err(WeightResolveError::Safetensors(format!(
278                    "packed 4-bit weight `{ref}` (constant `{const_name}`) storage shape {:?} does not match expected [{expected}] for logical shape {:?}",
279                    view.shape(), const_decl.shape
280                )));
281            }
282            if view.data().len() != expected {
283                return Err(WeightResolveError::Safetensors(format!(
284                    "packed 4-bit weight `{ref}` (constant `{const_name}`) has {} bytes, expected {expected}",
285                    view.data().len()
286                )));
287            }
288            const_decl.init = ConstInit::InlineBytes {
289                bytes: view.data().to_vec(),
290            };
291            continue;
292        }
293        if !st_shape_matches_const(view.shape(), &const_decl.shape) {
294            let msg = format!(
295                "shape mismatch for weight `{ref}` (constant `{const_name}`): graph {:?} vs safetensors {:?}",
296                const_decl.shape,
297                view.shape()
298            );
299            eprintln!(
300                "[webnn-graph] warning: safetensors could not resolve weight `{ref}` \
301                 (constant `{const_name}`) from `{}`: {msg}",
302                safetensors_path.display()
303            );
304            return Err(WeightResolveError::Safetensors(msg));
305        }
306
307        let st_dtype = view.dtype();
308        let raw = view.data();
309        let bytes = if safetensors_st_dtype_matches_ast(st_dtype, &const_decl.data_type) {
310            raw.to_vec()
311        } else if matches!(
312            (&const_decl.data_type, st_dtype),
313            (AstDataType::Float32, StDtype::BF16)
314        ) {
315            let elem_count: usize = const_decl.shape.iter().map(|&x| x as usize).product();
316            let expected = elem_count.checked_mul(2).ok_or_else(|| {
317                WeightResolveError::Safetensors(format!(
318                    "element count overflow for weight `{ref}` (constant `{const_name}`)"
319                ))
320            })?;
321            if raw.len() != expected {
322                return Err(WeightResolveError::Safetensors(format!(
323                    "BF16 tensor `{ref}` (constant `{const_name}`): byte length {} != expected {} ({} BF16 elements)",
324                    raw.len(),
325                    expected,
326                    elem_count
327                )));
328            }
329            eprintln!(
330                "[webnn-graph] safetensors: converting BF16 → float32 for weight `{ref}` (constant `{const_name}`)"
331            );
332            bf16_bytes_to_f32_le_bytes(raw)?
333        } else {
334            let msg = format!(
335                "dtype mismatch for weight `{ref}` (constant `{const_name}`): graph declares {:?} but safetensors has {:?}",
336                const_decl.data_type,
337                st_dtype
338            );
339            eprintln!(
340                "[webnn-graph] warning: safetensors could not resolve weight `{ref}` \
341                 (constant `{const_name}`) from `{}`: {msg}",
342                safetensors_path.display()
343            );
344            return Err(WeightResolveError::Safetensors(msg));
345        };
346
347        const_decl.init = ConstInit::InlineBytes { bytes };
348    }
349
350    let still_count = graph_json
351        .consts
352        .values()
353        .filter(|c| matches!(c.init, ConstInit::Weights { .. }))
354        .count();
355    if still_count > 0 {
356        return Err(WeightResolveError::Safetensors(format!(
357            "safetensors `{}` did not provide all tensors referenced by the graph ({still_count} still missing)",
358            safetensors_path.display()
359        )));
360    }
361
362    Ok(())
363}
364
365/// Weight manifest JSON next to a graph (supports `webnn-weights-manifest` and related layouts).
366#[derive(Debug, Deserialize)]
367struct FlexibleManifest {
368    #[serde(default)]
369    tensors: HashMap<String, FlexibleTensorEntry>,
370}
371
372#[derive(Debug, Deserialize, Clone)]
373struct FlexibleTensorEntry {
374    #[serde(rename = "byteOffset")]
375    byte_offset: u64,
376    #[serde(rename = "byteLength")]
377    byte_length: u64,
378}
379
380fn inline_weights_from_manifest(
381    graph_json: &mut GraphJson,
382    manifest_path: &Path,
383    weights_path: &Path,
384) -> Result<(), WeightResolveError> {
385    let manifest_text =
386        fs::read_to_string(manifest_path).map_err(|source| WeightResolveError::ReadFile {
387            path: manifest_path.to_path_buf(),
388            source,
389        })?;
390    let weights_bytes = read_file(weights_path)?;
391
392    let manifest: FlexibleManifest = serde_json::from_str(&manifest_text).map_err(|source| {
393        WeightResolveError::ManifestJson {
394            path: manifest_path.to_path_buf(),
395            source,
396        }
397    })?;
398
399    let mut manifest_by_sanitized: HashMap<String, Vec<FlexibleTensorEntry>> = HashMap::new();
400    for (name, entry) in &manifest.tensors {
401        let sanitized = sanitize_weight_key(name);
402        manifest_by_sanitized
403            .entry(sanitized)
404            .or_default()
405            .push(entry.clone());
406    }
407
408    for (const_name, const_decl) in graph_json.consts.iter_mut() {
409        let ConstInit::Weights { r#ref } = &const_decl.init else {
410            continue;
411        };
412        let entry = manifest
413            .tensors
414            .get(r#ref)
415            .cloned()
416            .or_else(|| {
417                manifest_by_sanitized.get(r#ref).and_then(|entries| {
418                    if entries.len() == 1 {
419                        Some(entries[0].clone())
420                    } else {
421                        None
422                    }
423                })
424            })
425            .ok_or_else(|| {
426                WeightResolveError::Manifest(format!(
427                    "no manifest tensor entry for weight ref `{ref}` (constant `{const_name}`)"
428                ))
429            })?;
430
431        let start = usize::try_from(entry.byte_offset).map_err(|_| {
432            WeightResolveError::Manifest(format!(
433                "byteOffset {} for `{ref}` does not fit in usize",
434                entry.byte_offset
435            ))
436        })?;
437        let len = usize::try_from(entry.byte_length).map_err(|_| {
438            WeightResolveError::Manifest(format!(
439                "byteLength {} for `{ref}` does not fit in usize",
440                entry.byte_length
441            ))
442        })?;
443        let end = start.checked_add(len).ok_or_else(|| {
444            WeightResolveError::Manifest(format!("byte range overflow for `{ref}`"))
445        })?;
446        if end > weights_bytes.len() {
447            return Err(WeightResolveError::Manifest(format!(
448                "byte range [{start}, {end}) for `{ref}` exceeds weights file length {} (`{}`)",
449                weights_bytes.len(),
450                weights_path.display()
451            )));
452        }
453        const_decl.init = ConstInit::InlineBytes {
454            bytes: weights_bytes[start..end].to_vec(),
455        };
456    }
457    Ok(())
458}
459
460/// Resolves `path_str` relative to the parent directory of `graph_path`, or as an absolute path
461/// when `path_str` is absolute.
462fn resolve_path_relative_to_graph(graph_path: &Path, path_str: &str) -> PathBuf {
463    let p = Path::new(path_str);
464    if p.is_absolute() {
465        p.to_path_buf()
466    } else {
467        graph_path
468            .parent()
469            .unwrap_or_else(|| Path::new("."))
470            .join(path_str)
471    }
472}
473
474fn discover_sidecar_manifest(graph_path: &Path) -> Option<PathBuf> {
475    let stem = graph_path
476        .file_stem()
477        .and_then(|s| s.to_str())
478        .unwrap_or_default();
479    [
480        graph_path.with_file_name(format!("{stem}.manifest.json")),
481        graph_path.with_file_name(DEFAULT_PATH_MANIFEST),
482    ]
483    .into_iter()
484    .find(|p| p.exists())
485}
486
487/// Discovers a single weights file next to `graph_path`, in order: `{stem}.safetensors`,
488/// `{stem}.weights`, [`DEFAULT_PATH_SAFETENSORS`], [`DEFAULT_PATH_WEIGHTS`].
489fn discover_weights_file(graph_path: &Path) -> Option<PathBuf> {
490    let stem = graph_path
491        .file_stem()
492        .and_then(|s| s.to_str())
493        .unwrap_or_default();
494    [
495        graph_path.with_file_name(format!("{stem}.safetensors")),
496        graph_path.with_file_name(format!("{stem}.weights")),
497        graph_path.with_file_name(DEFAULT_PATH_SAFETENSORS),
498        graph_path.with_file_name(DEFAULT_PATH_WEIGHTS),
499    ]
500    .into_iter()
501    .find(|p| p.exists())
502}
503
504/// Whether `path` refers to a SafeTensors archive (by extension).
505fn path_looks_like_safetensors(path: &Path) -> bool {
506    path.extension().and_then(|s| s.to_str()).is_some_and(|e| {
507        e.eq_ignore_ascii_case("safetensors") || e.eq_ignore_ascii_case("safetensor")
508    })
509}
510
511/// If `graph_json` contains any `ConstInit::Weights` references, load tensors from disk next to
512/// `graph_path` and replace them with [`ConstInit::InlineBytes`].
513///
514/// ## Resolution
515///
516/// 1. **No-op.** If the graph has no [`ConstInit::Weights`] initializers, return `Ok(())` without
517///    reading the filesystem.
518///
519/// 2. **Resolve weights path** (discovery is separate from loading):
520///    - If `weights_path` is set: resolve relative to the graph’s directory (or absolute as-is); the file
521///      must exist or return [`WeightResolveError::Missing`].
522///    - Else: [`discover_weights_file`] searches next to the graph in order: `{stem}.safetensors`,
523///      `{stem}.weights`, [`DEFAULT_PATH_SAFETENSORS`], [`DEFAULT_PATH_WEIGHTS`]. If none exist, return
524///      [`WeightResolveError::Missing`].
525///
526/// 3. **Load by kind:**
527///    - If the weights path is SafeTensors → [`inline_weights_from_safetensors`] and return (any
528///      `manifest_path` is ignored).
529///    - Otherwise it is a binary blob → resolve manifest: explicit `manifest_path` must exist, or
530///      [`discover_sidecar_manifest`] must find `{stem}.manifest.json` / [`DEFAULT_PATH_MANIFEST`], else
531///      [`WeightResolveError::Missing`]. Then [`inline_weights_from_manifest`].
532///
533/// Incomplete SafeTensors resolution returns [`WeightResolveError::Safetensors`]; manifest errors use
534/// [`WeightResolveError::Manifest`] / [`WeightResolveError::ManifestJson`].
535pub fn resolve_external_weights(
536    graph_json: &mut GraphJson,
537    graph_path: &Path,
538    weights_path: Option<&str>,
539    manifest_path: Option<&str>,
540) -> Result<(), WeightResolveError> {
541    eprintln!(
542        "[webnn graph] resolve external weights: graph={}, weights_path={}, manifest_path={}",
543        graph_path.display(),
544        weights_path.unwrap_or("<discover next to graph>"),
545        manifest_path.unwrap_or("<discover next to graph>"),
546    );
547
548    if !graph_has_external_weight_refs(graph_json) {
549        return Ok(());
550    }
551
552    let stem = graph_path
553        .file_stem()
554        .and_then(|s| s.to_str())
555        .unwrap_or_default();
556
557    let wp = if let Some(s) = weights_path {
558        let p = resolve_path_relative_to_graph(graph_path, s);
559        if !p.exists() {
560            return Err(WeightResolveError::Missing(format!(
561                "weights path `{}` does not exist",
562                p.display()
563            )));
564        }
565        p
566    } else {
567        discover_weights_file(graph_path).ok_or_else(|| {
568            WeightResolveError::Missing(format!(
569                "no weights file found next to `{0}`; expected `{1}.safetensors`, `{1}.weights`, \
570                 `{DEFAULT_PATH_SAFETENSORS}`, or `{DEFAULT_PATH_WEIGHTS}`, or pass `weights_path`",
571                graph_path.display(),
572                stem,
573            ))
574        })?
575    };
576
577    if path_looks_like_safetensors(&wp) {
578        return inline_weights_from_safetensors(graph_json, &wp);
579    }
580
581    let mp = if let Some(s) = manifest_path {
582        let p = resolve_path_relative_to_graph(graph_path, s);
583        if !p.exists() {
584            return Err(WeightResolveError::Missing(format!(
585                "manifest path `{}` does not exist",
586                p.display()
587            )));
588        }
589        p
590    } else {
591        discover_sidecar_manifest(graph_path).ok_or_else(|| {
592            WeightResolveError::Missing(format!(
593                "weights blob `{0}` requires a manifest; pass `manifest_path` or place `{1}.manifest.json` / \
594                 `{DEFAULT_PATH_MANIFEST}` next to `{2}`",
595                wp.display(),
596                stem,
597                graph_path.display()
598            ))
599        })?
600    };
601
602    inline_weights_from_manifest(graph_json, &mp, &wp)
603}
604
605fn temporary_archive_path(path: &Path) -> PathBuf {
606    let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
607    let name = path
608        .file_name()
609        .and_then(|name| name.to_str())
610        .unwrap_or("weights.safetensors");
611    path.with_file_name(format!(".{name}.{}.{}.tmp", std::process::id(), sequence))
612}
613
614/// Writes all external constants declared by `graph_json` to one SafeTensors archive.
615///
616/// `external_weights` is keyed by each [`ConstInit::Weights`] reference. Logical Int4 and Uint4
617/// payloads remain packed low-nibble-first and are stored as one-dimensional U8 tensors.
618pub fn write_external_weights_safetensors(
619    graph_json: &GraphJson,
620    external_weights: &HashMap<String, &[u8]>,
621    destination: &Path,
622) -> Result<(), WeightWriteError> {
623    let mut declarations: BTreeMap<&str, (&AstDataType, &[u32])> = BTreeMap::new();
624    for (constant_name, declaration) in &graph_json.consts {
625        let ConstInit::Weights { r#ref } = &declaration.init else {
626            continue;
627        };
628        if let Some((data_type, shape)) = declarations.get(r#ref.as_str()) {
629            if *data_type != &declaration.data_type || *shape != declaration.shape.as_slice() {
630                return Err(WeightWriteError::Validation(format!(
631                    "weight reference `{ref}` has conflicting declarations (constant `{constant_name}`)"
632                )));
633            }
634        } else {
635            declarations.insert(r#ref, (&declaration.data_type, &declaration.shape));
636        }
637    }
638
639    let mut views = Vec::with_capacity(declarations.len());
640    let mut has_packed4 = false;
641    for (weight_ref, (data_type, logical_shape)) in declarations {
642        let bytes = external_weights.get(weight_ref).copied().ok_or_else(|| {
643            WeightWriteError::Validation(format!(
644                "weight reference `{weight_ref}` has no supplied tensor bytes"
645            ))
646        })?;
647        let (storage_dtype, storage_shape) = if matches!(
648            data_type,
649            AstDataType::Int4 | AstDataType::Uint4
650        ) {
651            let packed_len = packed_byte_length(weight_ref, logical_shape)
652                .map_err(WeightWriteError::Validation)?;
653            if bytes.len() != packed_len {
654                return Err(WeightWriteError::Validation(format!(
655                        "packed 4-bit weight `{weight_ref}` has {} bytes, expected {packed_len} for logical shape {logical_shape:?}",
656                        bytes.len()
657                    )));
658            }
659            has_packed4 = true;
660            (StDtype::U8, vec![packed_len])
661        } else {
662            let dtype = ast_dtype_to_safetensors(data_type).ok_or_else(|| {
663                WeightWriteError::Validation(format!(
664                    "weight reference `{weight_ref}` has unsupported data type {data_type:?}"
665                ))
666            })?;
667            (
668                dtype,
669                logical_shape
670                    .iter()
671                    .map(|&dimension| dimension as usize)
672                    .collect(),
673            )
674        };
675        let view = TensorView::new(storage_dtype, storage_shape, bytes).map_err(|error| {
676            WeightWriteError::Validation(format!("weight reference `{weight_ref}`: {error}"))
677        })?;
678        views.push((weight_ref.to_string(), view));
679    }
680
681    let metadata = has_packed4.then(|| {
682        HashMap::from([(
683            PACKED_4BIT_METADATA_KEY.to_string(),
684            PACKED_4BIT_METADATA_VERSION.to_string(),
685        )])
686    });
687    let temporary = temporary_archive_path(destination);
688    if let Err(source) = safetensors::serialize_to_file(views, metadata, &temporary) {
689        let _ = fs::remove_file(&temporary);
690        return Err(WeightWriteError::Serialize {
691            path: destination.to_path_buf(),
692            source,
693        });
694    }
695
696    let install_result = match fs::rename(&temporary, destination) {
697        Ok(()) => Ok(()),
698        Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
699            fs::remove_file(destination).and_then(|()| fs::rename(&temporary, destination))
700        }
701        Err(source) => Err(source),
702    };
703    if let Err(source) = install_result {
704        let _ = fs::remove_file(&temporary);
705        return Err(WeightWriteError::Install {
706            path: destination.to_path_buf(),
707            source,
708        });
709    }
710    Ok(())
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use safetensors::tensor::TensorView;
717    use safetensors::{serialize, Dtype};
718    use tempfile::TempDir;
719
720    fn write_safetensors_f32(path: &Path, tensor_name: &str, shape: Vec<usize>, data: &[u8]) {
721        let view = TensorView::new(Dtype::F32, shape, data).unwrap();
722        let bytes = serialize(vec![(tensor_name.to_string(), view)], None).unwrap();
723        std::fs::write(path, bytes).unwrap();
724    }
725
726    fn write_safetensors_bf16(path: &Path, tensor_name: &str, shape: Vec<usize>, data: &[u8]) {
727        let view = TensorView::new(Dtype::BF16, shape, data).unwrap();
728        let bytes = serialize(vec![(tensor_name.to_string(), view)], None).unwrap();
729        std::fs::write(path, bytes).unwrap();
730    }
731
732    #[test]
733    fn manifest_and_weights_inline() {
734        let temp_dir = TempDir::new().unwrap();
735        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
736        let manifest_path = temp_dir.path().join("model.manifest.json");
737        let weights_path = temp_dir.path().join(DEFAULT_PATH_WEIGHTS);
738
739        let graph_content = r#"{
740            "format": "webnn-graph-json",
741            "version": 1,
742            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
743            "consts": {
744                "weight": {
745                    "dataType": "float32",
746                    "shape": [2],
747                    "init": { "kind": "weights", "ref": "weight" }
748                }
749            },
750            "nodes": [],
751            "outputs": { "y": "x" }
752        }"#;
753
754        let manifest_content = r#"{
755            "format": "webnn-weights-manifest",
756            "version": 1,
757            "endianness": "little",
758            "tensors": {
759                "weight": {
760                    "dataType": "float32",
761                    "shape": [2],
762                    "byteOffset": 0,
763                    "byteLength": 8
764                }
765            }
766        }"#;
767
768        let weights_data: Vec<u8> = vec![0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
769        std::fs::write(&graph_path, graph_content).unwrap();
770        std::fs::write(&manifest_path, manifest_content).unwrap();
771        std::fs::write(&weights_path, &weights_data).unwrap();
772
773        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
774        resolve_external_weights(&mut graph, &graph_path, None, None).unwrap();
775        match &graph.consts["weight"].init {
776            ConstInit::InlineBytes { bytes } => assert_eq!(bytes.len(), 8),
777            other => panic!("expected inline bytes, got {:?}", other),
778        }
779    }
780
781    #[test]
782    fn explicit_manifest_and_weights_paths() {
783        let temp_dir = TempDir::new().unwrap();
784        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
785        let manifest_path = temp_dir.path().join("custom.manifest.json");
786        let weights_path = temp_dir.path().join("blob.weights");
787
788        let graph_content = r#"{
789            "format": "webnn-graph-json",
790            "version": 1,
791            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
792            "consts": {
793                "weight": {
794                    "dataType": "float32",
795                    "shape": [2],
796                    "init": { "kind": "weights", "ref": "weight" }
797                }
798            },
799            "nodes": [],
800            "outputs": { "y": "x" }
801        }"#;
802
803        let manifest_content = r#"{
804            "format": "webnn-weights-manifest",
805            "version": 1,
806            "endianness": "little",
807            "tensors": {
808                "weight": {
809                    "dataType": "float32",
810                    "shape": [2],
811                    "byteOffset": 0,
812                    "byteLength": 8
813                }
814            }
815        }"#;
816
817        let weights_data: Vec<u8> = vec![0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
818        std::fs::write(&graph_path, graph_content).unwrap();
819        std::fs::write(&manifest_path, manifest_content).unwrap();
820        std::fs::write(&weights_path, &weights_data).unwrap();
821
822        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
823        resolve_external_weights(
824            &mut graph,
825            &graph_path,
826            Some("blob.weights"),
827            Some("custom.manifest.json"),
828        )
829        .unwrap();
830        match &graph.consts["weight"].init {
831            ConstInit::InlineBytes { bytes } => assert_eq!(bytes.len(), 8),
832            other => panic!("expected inline bytes, got {:?}", other),
833        }
834    }
835
836    #[test]
837    fn explicit_safetensors_weights_path() {
838        let temp_dir = TempDir::new().unwrap();
839        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
840        let st_path = temp_dir.path().join("custom.safetensors");
841
842        let graph_content = r#"{
843            "format": "webnn-graph-json",
844            "version": 1,
845            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
846            "consts": {
847                "weight": {
848                    "dataType": "float32",
849                    "shape": [2],
850                    "init": { "kind": "weights", "ref": "weight" }
851                }
852            },
853            "nodes": [],
854            "outputs": { "y": "x" }
855        }"#;
856
857        let tensor_bytes: Vec<u8> = vec![0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
858        std::fs::write(&graph_path, graph_content).unwrap();
859        write_safetensors_f32(&st_path, "weight", vec![2], &tensor_bytes);
860
861        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
862        resolve_external_weights(&mut graph, &graph_path, Some("custom.safetensors"), None)
863            .unwrap();
864        match &graph.consts["weight"].init {
865            ConstInit::InlineBytes { bytes } => assert_eq!(bytes, &tensor_bytes),
866            other => panic!("expected inline bytes, got {:?}", other),
867        }
868    }
869
870    #[test]
871    fn manifest_arg_ignored_when_weights_path_is_safetensors() {
872        let temp_dir = TempDir::new().unwrap();
873        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
874        let st_path = temp_dir.path().join("weights.safetensors");
875
876        let graph_content = r#"{
877            "format": "webnn-graph-json",
878            "version": 1,
879            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
880            "consts": {
881                "weight": {
882                    "dataType": "float32",
883                    "shape": [2],
884                    "init": { "kind": "weights", "ref": "weight" }
885                }
886            },
887            "nodes": [],
888            "outputs": { "y": "x" }
889        }"#;
890
891        let tensor_bytes: Vec<u8> = vec![0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
892        std::fs::write(&graph_path, graph_content).unwrap();
893        write_safetensors_f32(&st_path, "weight", vec![2], &tensor_bytes);
894
895        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
896        resolve_external_weights(
897            &mut graph,
898            &graph_path,
899            Some("weights.safetensors"),
900            Some("this_manifest_is_not_read.json"),
901        )
902        .unwrap();
903        match &graph.consts["weight"].init {
904            ConstInit::InlineBytes { bytes } => assert_eq!(bytes, &tensor_bytes),
905            other => panic!("expected inline bytes, got {:?}", other),
906        }
907    }
908
909    #[test]
910    fn safetensors_inline() {
911        let temp_dir = TempDir::new().unwrap();
912        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
913        let st_path = temp_dir.path().join(DEFAULT_PATH_SAFETENSORS);
914
915        let graph_content = r#"{
916            "format": "webnn-graph-json",
917            "version": 1,
918            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
919            "consts": {
920                "weight": {
921                    "dataType": "float32",
922                    "shape": [2],
923                    "init": { "kind": "weights", "ref": "weight" }
924                }
925            },
926            "nodes": [],
927            "outputs": { "y": "x" }
928        }"#;
929
930        let tensor_bytes: Vec<u8> = vec![0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40];
931        std::fs::write(&graph_path, graph_content).unwrap();
932        write_safetensors_f32(&st_path, "weight", vec![2], &tensor_bytes);
933
934        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
935        resolve_external_weights(&mut graph, &graph_path, None, None).unwrap();
936        match &graph.consts["weight"].init {
937            ConstInit::InlineBytes { bytes } => assert_eq!(bytes, &tensor_bytes),
938            other => panic!("expected inline bytes, got {:?}", other),
939        }
940    }
941
942    #[test]
943    fn out_of_bounds_manifest_errors() {
944        let temp_dir = TempDir::new().unwrap();
945        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
946        let manifest_path = temp_dir.path().join(DEFAULT_PATH_MANIFEST);
947        let weights_path = temp_dir.path().join(DEFAULT_PATH_WEIGHTS);
948
949        let graph_content = r#"{
950            "format": "webnn-graph-json",
951            "version": 1,
952            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
953            "consts": {
954                "weight": {
955                    "dataType": "float32",
956                    "shape": [2],
957                    "init": { "kind": "weights", "ref": "weight" }
958                }
959            },
960            "nodes": [],
961            "outputs": { "y": "x" }
962        }"#;
963
964        let manifest_content = r#"{
965            "format": "webnn-weights-manifest",
966            "version": 1,
967            "tensors": {
968                "weight": {
969                    "dataType": "float32",
970                    "shape": [2],
971                    "byteOffset": 0,
972                    "byteLength": 100
973                }
974            }
975        }"#;
976
977        std::fs::write(&graph_path, graph_content).unwrap();
978        std::fs::write(&manifest_path, manifest_content).unwrap();
979        std::fs::write(&weights_path, vec![0u8; 8]).unwrap();
980
981        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
982        let err = resolve_external_weights(&mut graph, &graph_path, None, None).unwrap_err();
983        assert!(matches!(err, WeightResolveError::Manifest(_)));
984    }
985
986    #[test]
987    fn safetensors_preferred_over_invalid_manifest() {
988        let temp_dir = TempDir::new().unwrap();
989        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
990        let manifest_path = temp_dir.path().join(DEFAULT_PATH_MANIFEST);
991        let weights_path = temp_dir.path().join(DEFAULT_PATH_WEIGHTS);
992        let st_path = temp_dir.path().join(DEFAULT_PATH_SAFETENSORS);
993
994        let graph_content = r#"{
995            "format": "webnn-graph-json",
996            "version": 1,
997            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
998            "consts": {
999                "weight": {
1000                    "dataType": "float32",
1001                    "shape": [2],
1002                    "init": { "kind": "weights", "ref": "weight" }
1003                }
1004            },
1005            "nodes": [],
1006            "outputs": { "y": "x" }
1007        }"#;
1008
1009        std::fs::write(&graph_path, graph_content).unwrap();
1010        std::fs::write(&manifest_path, "{ not valid manifest json").unwrap();
1011        std::fs::write(&weights_path, [0u8; 8]).unwrap();
1012        write_safetensors_f32(
1013            &st_path,
1014            "weight",
1015            vec![2],
1016            &[0x00, 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x40],
1017        );
1018
1019        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
1020        resolve_external_weights(&mut graph, &graph_path, None, None).unwrap();
1021    }
1022
1023    #[test]
1024    fn safetensors_bf16_converts_to_float32_for_graph_constants() {
1025        use half::bf16;
1026
1027        let temp_dir = TempDir::new().unwrap();
1028        let graph_path = temp_dir.path().join(DEFAULT_PATH_JSON);
1029        let st_path = temp_dir.path().join(DEFAULT_PATH_SAFETENSORS);
1030
1031        let graph_content = r#"{
1032            "format": "webnn-graph-json",
1033            "version": 1,
1034            "inputs": { "x": { "dataType": "float32", "shape": [2] } },
1035            "consts": {
1036                "weight": {
1037                    "dataType": "float32",
1038                    "shape": [2],
1039                    "init": { "kind": "weights", "ref": "weight" }
1040                }
1041            },
1042            "nodes": [],
1043            "outputs": { "y": "x" }
1044        }"#;
1045
1046        let mut bf16_bytes = Vec::new();
1047        bf16_bytes.extend_from_slice(&bf16::from_f32(1.0f32).to_bits().to_le_bytes());
1048        bf16_bytes.extend_from_slice(&bf16::from_f32(2.0f32).to_bits().to_le_bytes());
1049
1050        std::fs::write(&graph_path, graph_content).unwrap();
1051        write_safetensors_bf16(&st_path, "weight", vec![2], &bf16_bytes);
1052
1053        let mut graph: GraphJson = serde_json::from_str(graph_content).unwrap();
1054        resolve_external_weights(&mut graph, &graph_path, None, None).unwrap();
1055
1056        let expected: Vec<u8> = [1.0f32, 2.0f32]
1057            .iter()
1058            .flat_map(|f| f.to_le_bytes())
1059            .collect();
1060        match &graph.consts["weight"].init {
1061            ConstInit::InlineBytes { bytes } => assert_eq!(bytes, &expected),
1062            other => panic!("expected inline bytes, got {:?}", other),
1063        }
1064    }
1065
1066    fn external_graph(constants: Vec<(&str, AstDataType, Vec<u32>, &str)>) -> GraphJson {
1067        let mut graph = crate::ast::new_graph_json();
1068        for (name, data_type, shape, weight_ref) in constants {
1069            graph.consts.insert(
1070                name.to_string(),
1071                crate::ast::ConstDecl {
1072                    data_type,
1073                    shape,
1074                    init: ConstInit::Weights {
1075                        r#ref: weight_ref.to_string(),
1076                    },
1077                },
1078            );
1079        }
1080        graph
1081    }
1082
1083    fn write_test_archive(
1084        path: &Path,
1085        tensors: Vec<(String, Dtype, Vec<usize>, Vec<u8>)>,
1086        marker: Option<&str>,
1087    ) {
1088        let views = tensors
1089            .iter()
1090            .map(|(name, dtype, shape, bytes)| {
1091                (
1092                    name.clone(),
1093                    TensorView::new(*dtype, shape.clone(), bytes).unwrap(),
1094                )
1095            })
1096            .collect::<Vec<_>>();
1097        let metadata = marker.map(|version| {
1098            HashMap::from([(PACKED_4BIT_METADATA_KEY.to_string(), version.to_string())])
1099        });
1100        safetensors::serialize_to_file(views, metadata, path).unwrap();
1101    }
1102
1103    #[test]
1104    fn mixed_ordinary_and_packed_weights_roundtrip_in_one_archive() {
1105        let temp_dir = TempDir::new().unwrap();
1106        let graph_path = temp_dir.path().join("mixed.webnn");
1107        let archive_path = temp_dir.path().join("mixed.safetensors");
1108        let mut graph = external_graph(vec![
1109            ("even", AstDataType::Int4, vec![4], "even"),
1110            ("odd", AstDataType::Uint4, vec![3], "odd"),
1111            ("float", AstDataType::Float32, vec![2], "float"),
1112        ]);
1113        let even = [0x21, 0x43];
1114        let odd = [0x65, 0x07];
1115        let float = [0_u8, 0, 128, 63, 0, 0, 0, 64];
1116        let bytes = HashMap::from([
1117            ("even".to_string(), even.as_slice()),
1118            ("odd".to_string(), odd.as_slice()),
1119            ("float".to_string(), float.as_slice()),
1120        ]);
1121
1122        write_external_weights_safetensors(&graph, &bytes, &archive_path).unwrap();
1123        let archive_bytes = read_file(&archive_path).unwrap();
1124        let (_, metadata) = SafeTensors::read_metadata(&archive_bytes).unwrap();
1125        assert_eq!(
1126            metadata.metadata().as_ref().unwrap()[PACKED_4BIT_METADATA_KEY],
1127            PACKED_4BIT_METADATA_VERSION
1128        );
1129        let archive = SafeTensors::deserialize(&archive_bytes).unwrap();
1130        assert_eq!(archive.tensor("even").unwrap().shape(), [2]);
1131        assert_eq!(archive.tensor("odd").unwrap().shape(), [2]);
1132        assert_eq!(archive.tensor("float").unwrap().shape(), [2]);
1133
1134        resolve_external_weights(&mut graph, &graph_path, None, None).unwrap();
1135        assert_eq!(
1136            graph.consts["even"].init,
1137            ConstInit::InlineBytes {
1138                bytes: even.to_vec()
1139            }
1140        );
1141        assert_eq!(
1142            graph.consts["odd"].init,
1143            ConstInit::InlineBytes {
1144                bytes: odd.to_vec()
1145            }
1146        );
1147        assert_eq!(
1148            graph.consts["float"].init,
1149            ConstInit::InlineBytes {
1150                bytes: float.to_vec()
1151            }
1152        );
1153    }
1154
1155    #[test]
1156    fn packed_weights_require_supported_marker() {
1157        let temp_dir = TempDir::new().unwrap();
1158        let graph_path = temp_dir.path().join("packed.webnn");
1159        let archive_path = temp_dir.path().join("packed.safetensors");
1160        let graph = external_graph(vec![("q", AstDataType::Uint4, vec![3], "q")]);
1161
1162        for marker in [None, Some("999")] {
1163            write_test_archive(
1164                &archive_path,
1165                vec![("q".to_string(), Dtype::U8, vec![2], vec![0x21, 0x03])],
1166                marker,
1167            );
1168            let mut candidate = graph.clone();
1169            let error = resolve_external_weights(&mut candidate, &graph_path, None, None)
1170                .unwrap_err()
1171                .to_string();
1172            assert!(error.contains(if marker.is_some() {
1173                "unsupported"
1174            } else {
1175                "lacks"
1176            }));
1177        }
1178    }
1179
1180    #[test]
1181    fn packed_weights_validate_storage_dtype_and_shape() {
1182        let temp_dir = TempDir::new().unwrap();
1183        let graph_path = temp_dir.path().join("packed.webnn");
1184        let archive_path = temp_dir.path().join("packed.safetensors");
1185        let graph = external_graph(vec![("q", AstDataType::Int4, vec![3], "q")]);
1186
1187        write_test_archive(
1188            &archive_path,
1189            vec![("q".to_string(), Dtype::I8, vec![2], vec![0x21, 0x03])],
1190            Some(PACKED_4BIT_METADATA_VERSION),
1191        );
1192        let error = resolve_external_weights(&mut graph.clone(), &graph_path, None, None)
1193            .unwrap_err()
1194            .to_string();
1195        assert!(error.contains("must use SafeTensors U8"));
1196
1197        write_test_archive(
1198            &archive_path,
1199            vec![("q".to_string(), Dtype::U8, vec![1, 2], vec![0x21, 0x03])],
1200            Some(PACKED_4BIT_METADATA_VERSION),
1201        );
1202        let error = resolve_external_weights(&mut graph.clone(), &graph_path, None, None)
1203            .unwrap_err()
1204            .to_string();
1205        assert!(error.contains("storage shape"));
1206    }
1207
1208    #[test]
1209    fn writer_rejects_missing_malformed_and_overflowing_weights_without_partials() {
1210        let temp_dir = TempDir::new().unwrap();
1211        let archive_path = temp_dir.path().join("packed.safetensors");
1212        let graph = external_graph(vec![("q", AstDataType::Uint4, vec![3], "q")]);
1213
1214        let error = write_external_weights_safetensors(&graph, &HashMap::new(), &archive_path)
1215            .unwrap_err()
1216            .to_string();
1217        assert!(error.contains("no supplied tensor bytes"));
1218
1219        let malformed = [0x21];
1220        let error = write_external_weights_safetensors(
1221            &graph,
1222            &HashMap::from([("q".to_string(), malformed.as_slice())]),
1223            &archive_path,
1224        )
1225        .unwrap_err()
1226        .to_string();
1227        assert!(error.contains("expected 2"));
1228
1229        let overflow = external_graph(vec![(
1230            "q",
1231            AstDataType::Int4,
1232            vec![u32::MAX, u32::MAX, u32::MAX],
1233            "q",
1234        )]);
1235        let empty = [];
1236        let error = write_external_weights_safetensors(
1237            &overflow,
1238            &HashMap::from([("q".to_string(), empty.as_slice())]),
1239            &archive_path,
1240        )
1241        .unwrap_err()
1242        .to_string();
1243        assert!(error.contains("element count overflows"));
1244        assert!(!archive_path.exists());
1245        assert!(temp_dir.path().read_dir().unwrap().all(|entry| !entry
1246            .unwrap()
1247            .file_name()
1248            .to_string_lossy()
1249            .ends_with(".tmp")));
1250    }
1251
1252    #[test]
1253    fn packed_weights_share_sanitized_lookup_and_ambiguity_rules() {
1254        let temp_dir = TempDir::new().unwrap();
1255        let graph_path = temp_dir.path().join("packed.webnn");
1256        let archive_path = temp_dir.path().join("packed.safetensors");
1257        let graph = external_graph(vec![("q", AstDataType::Uint4, vec![3], "a__b")]);
1258
1259        write_test_archive(
1260            &archive_path,
1261            vec![("a::b".to_string(), Dtype::U8, vec![2], vec![0x21, 0x03])],
1262            Some(PACKED_4BIT_METADATA_VERSION),
1263        );
1264        let mut resolved = graph.clone();
1265        resolve_external_weights(&mut resolved, &graph_path, None, None).unwrap();
1266        assert_eq!(
1267            resolved.consts["q"].init,
1268            ConstInit::InlineBytes {
1269                bytes: vec![0x21, 0x03]
1270            }
1271        );
1272
1273        write_test_archive(
1274            &archive_path,
1275            vec![
1276                ("a::b".to_string(), Dtype::U8, vec![2], vec![0x21, 0x03]),
1277                ("a..b".to_string(), Dtype::U8, vec![2], vec![0x21, 0x03]),
1278            ],
1279            Some(PACKED_4BIT_METADATA_VERSION),
1280        );
1281        let error = resolve_external_weights(&mut graph.clone(), &graph_path, None, None)
1282            .unwrap_err()
1283            .to_string();
1284        assert!(error.contains("ambiguous sanitized tensor name"));
1285
1286        write_test_archive(
1287            &archive_path,
1288            vec![("other".to_string(), Dtype::U8, vec![2], vec![0x21, 0x03])],
1289            Some(PACKED_4BIT_METADATA_VERSION),
1290        );
1291        let missing = external_graph(vec![("q", AstDataType::Uint4, vec![3], "missing")]);
1292        let error = resolve_external_weights(&mut missing.clone(), &graph_path, None, None)
1293            .unwrap_err()
1294            .to_string();
1295        assert!(error.contains("not found"));
1296    }
1297}