1use 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
17pub const DEFAULT_PATH_JSON: &str = "model.json";
19pub const DEFAULT_PATH_WEIGHTS: &str = "model.weights";
21pub const DEFAULT_PATH_SAFETENSORS: &str = "model.safetensors";
23pub const DEFAULT_PATH_MANIFEST: &str = "manifest.json";
25
26pub const PACKED_4BIT_METADATA_KEY: &str = "rustnn.webnn.packed4";
28pub const PACKED_4BIT_METADATA_VERSION: &str = "1";
30
31#[derive(Debug, Error)]
33pub enum WeightResolveError {
34 #[error("failed to read `{path}`: {source}")]
36 ReadFile {
37 path: PathBuf,
38 #[source]
39 source: std::io::Error,
40 },
41 #[error("failed to parse manifest JSON at `{path}`: {source}")]
43 ManifestJson {
44 path: PathBuf,
45 #[source]
46 source: serde_json::Error,
47 },
48 #[error("[safetensors] {0}")]
50 Safetensors(String),
51 #[error("[manifest-weights] {0}")]
53 Manifest(String),
54 #[error("[weights] {0}")]
56 Missing(String),
57}
58
59#[derive(Debug, Error)]
61pub enum WeightWriteError {
62 #[error("[safetensors] {0}")]
64 Validation(String),
65 #[error("failed to write SafeTensors archive `{path}`: {source}")]
67 Serialize {
68 path: PathBuf,
69 #[source]
70 source: safetensors::SafeTensorError,
71 },
72 #[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#[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
167fn 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#[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
460fn 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
487fn 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
504fn 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
511pub 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
614pub 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}