1use std::io::{Seek, SeekFrom, Write};
3
4use runmat_builtins::{CharArray, Value};
5use runmat_macros::runtime_builtin;
6
7use crate::builtins::common::spec::{
8 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
9 ReductionNaN, ResidencyPolicy, ShapeRequirements,
10};
11use crate::builtins::io::filetext::registry;
12use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
13
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16
17#[cfg(feature = "doc_export")]
18pub const DOC_MD: &str = r#"---
19title: "fwrite"
20category: "io/filetext"
21keywords: ["fwrite", "binary write", "io", "precision", "machine format", "skip"]
22summary: "Write binary data to a file identifier with MATLAB-compatible precision, skip, and machine-format semantics."
23references:
24 - https://www.mathworks.com/help/matlab/ref/fwrite.html
25gpu_support:
26 elementwise: false
27 reduction: false
28 precisions: []
29 broadcasting: "none"
30 notes: "Runs entirely on the host CPU. When data or arguments live on the GPU, RunMat gathers them first; providers do not expose file-I/O hooks."
31fusion:
32 elementwise: false
33 reduction: false
34 max_inputs: 4
35 constants: "inline"
36requires_feature: null
37tested:
38 unit: "builtins::io::filetext::fwrite::tests"
39 integration:
40 - "builtins::io::filetext::fwrite::tests::fwrite_double_precision_writes_native_endian"
41 - "builtins::io::filetext::fwrite::tests::fwrite_gpu_tensor_gathers_before_write"
42 - "builtins::io::filetext::fwrite::tests::fwrite_wgpu_tensor_roundtrip"
43 - "builtins::io::filetext::fwrite::tests::fwrite_invalid_precision_errors"
44 - "builtins::io::filetext::fwrite::tests::fwrite_negative_skip_errors"
45---
46
47# What does the `fwrite` function do in MATLAB / RunMat?
48`fwrite` writes binary data to a file identifier obtained from `fopen`. It mirrors MATLAB's handling
49of precision strings, skip values, and machine-format overrides so existing MATLAB scripts can save data
50without modification. The builtin accepts numeric tensors, logical data, and character arrays; the bytes are
51emitted in column-major order to match MATLAB storage.
52
53## How does the `fwrite` function behave in MATLAB / RunMat?
54- `count = fwrite(fid, A)` converts `A` to unsigned 8-bit integers and writes one byte per element.
55- `count = fwrite(fid, A, precision)` converts `A` to the requested precision before writing. Supported
56 precisions are `double`, `single`, `uint8`, `int8`, `uint16`, `int16`, `uint32`, `int32`, `uint64`,
57 `int64`, and `char`. Shorthand aliases such as `uchar`, `byte`, and `real*8` are also recognised.
58- `count = fwrite(fid, A, precision, skip)` skips `skip` bytes after writing each element. RunMat applies the
59 skip with a file seek, which produces sparse regions when the target position moves beyond the current end.
60- `count = fwrite(fid, A, precision, skip, machinefmt)` overrides the byte ordering used for the conversion.
61 Supported machine formats are `'native'`, `'ieee-le'`, and `'ieee-be'`. When omitted, the builtin honours the
62 format recorded by `fopen`.
63- Column-major ordering matches MATLAB semantics: tensors and character arrays write their first column
64 completely before advancing to the next column. Scalars and vectors behave as 1-by-N matrices.
65- The return value `count` is the number of elements written, not the number of bytes. A zero-length input
66 produces `count == 0`.
67- RunMat executes `fwrite` entirely on the host. When the data resides on a GPU (`gpuArray`), RunMat gathers the
68 tensor to host memory before writing; providers do not currently implement device-side file I/O.
69
70## `fwrite` Function GPU Execution Behaviour
71`fwrite` never launches GPU kernels. If any input value (file identifier, data, or optional arguments) is backed
72by a GPU tensor, RunMat gathers the value to host memory before performing the write. This mirrors MATLAB's own
73behaviour when working with `gpuArray` objects: data is moved to the CPU for I/O. When a provider is available,
74the gather occurs via the provider's `download` path; otherwise the builtin emits an informative error.
75
76## GPU residency in RunMat (Do I need `gpuArray`?)
77You rarely need to move data with `gpuArray` purely for `fwrite`. RunMat keeps tensors on the GPU while compute
78stays in fused expressions, but explicit file I/O always happens on the host. If your data already lives on the
79device, `fwrite` performs an automatic gather, writes the bytes, and leaves residency unchanged for the rest of
80the program. You can still call `gpuArray` manually when porting MATLAB code verbatim—the builtin will gather it
81for you automatically.
82
83## Examples of using the `fwrite` function in MATLAB / RunMat
84
85### Write unsigned bytes with the default precision
86```matlab
87fid = fopen('bytes.bin', 'w+b');
88count = fwrite(fid, [1 2 3 255]);
89fclose(fid);
90```
91Expected output:
92```matlab
93count = 4
94```
95
96### Write double-precision values
97```matlab
98fid = fopen('values.bin', 'w+b');
99data = [1.5 -2.25 42.0];
100count = fwrite(fid, data, 'double');
101fclose(fid);
102```
103Expected output:
104```matlab
105count = 3
106```
107The file contains three IEEE 754 doubles in the machine format recorded by `fopen`.
108
109### Write 16-bit integers using big-endian byte ordering
110```matlab
111fid = fopen('sensor.be', 'w+b', 'ieee-be');
112fwrite(fid, [258 772], 'uint16');
113fclose(fid);
114```
115Expected output:
116```matlab
117count = 2
118```
119The bytes on disk follow big-endian ordering (`01 02 03 04` for the values above).
120
121### Insert padding bytes between samples
122```matlab
123fid = fopen('spaced.bin', 'w+b');
124fwrite(fid, [10 20 30], 'uint8', 1); % skip one byte between elements
125fclose(fid);
126```
127Expected output:
128```matlab
129count = 3
130```
131The file layout is `0A 00 14 00 1E`, leaving a zero byte between each stored value.
132
133### Write character data without manual conversions
134```matlab
135fid = fopen('greeting.txt', 'w+b');
136fwrite(fid, 'RunMat!', 'char');
137fclose(fid);
138```
139Expected output:
140```matlab
141count = 7
142```
143Passing text uses the character codes (UTF-16 code units truncated to 8 bits) and writes them sequentially.
144
145### Gather GPU data before writing
146```matlab
147fid = fopen('gpu.bin', 'w+b');
148G = gpuArray([1 2 3 4]);
149count = fwrite(fid, G, 'uint16');
150fclose(fid);
151```
152Expected output when a GPU provider is active:
153```matlab
154count = 4
155```
156RunMat gathers `G` to the host before conversion. When no provider is registered, `fwrite` raises an error
157stating that GPU values cannot be gathered.
158
159## FAQ
160
161### What precisions does `fwrite` support?
162RunMat recognises the commonly used MATLAB precisions: `double`, `single`, `uint8`, `int8`, `uint16`, `int16`,
163`uint32`, `int32`, `uint64`, `int64`, and `char`, along with their documented aliases (`real*8`, `uchar`, etc.).
164The `precision => output` forms are accepted when both sides match; differing output classes are not implemented yet.
165
166### How are values converted before writing?
167Numeric inputs are converted to the requested precision using MATLAB-style rounding (to the nearest integer) with
168saturation to the target range. Logical inputs map `true` to 1 and `false` to 0. Character inputs use their Unicode
169scalar values.
170
171### What does the return value represent?
172`fwrite` returns the number of elements successfully written, not the total number of bytes. Multiply by the element
173size when you need to know the byte count.
174
175### Does `skip` insert bytes into the file?
176`skip` seeks forward after each element is written. When the seek lands beyond the current end of file, the OS
177creates a sparse region (holes are zero-filled on most platforms). Use `skip = 0` (the default) to write densely.
178
179### How do machine formats affect the output?
180The machine format controls byte ordering for multi-byte precisions. `'native'` uses the host endianness, `'ieee-le'`
181forces little-endian ordering, and `'ieee-be'` forces big-endian ordering regardless of the host.
182
183### Can I write directly to standard output?
184Not yet. File identifiers 0, 1, and 2 (stdin, stdout, stderr) are reserved and raise a descriptive error. Use
185`fopen` to create a file handle before calling `fwrite`.
186
187### Are GPU tensors supported?
188Yes. RunMat gathers GPU tensors to host memory before writing. The gather relies on the active provider; if no
189provider is registered, an informative error is raised.
190
191### Do string arrays insert newline characters?
192RunMat joins string-array elements using newline (`'\n'`) separators before writing. This mirrors how MATLAB flattens
193string arrays to character data for binary I/O.
194
195### What happens with `NaN` or infinite values?
196`NaN` values map to zero for integer precisions and remain `NaN` for floating-point precisions. Infinite values
197saturate to the min/max integer representable by the target precision.
198
199## See Also
200[fopen](./fopen), [fclose](./fclose), [fread](./fread), [fileread](./fileread), [filewrite](./filewrite)
201
202## Source & Feedback
203- Implementation: [`crates/runmat-runtime/src/builtins/io/filetext/fwrite.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/filetext/fwrite.rs)
204- Found a behavioural mismatch? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
205"#;
206
207pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
208 name: "fwrite",
209 op_kind: GpuOpKind::Custom("file-io-write"),
210 supported_precisions: &[],
211 broadcast: BroadcastSemantics::None,
212 provider_hooks: &[],
213 constant_strategy: ConstantStrategy::InlineLiteral,
214 residency: ResidencyPolicy::GatherImmediately,
215 nan_mode: ReductionNaN::Include,
216 two_pass_threshold: None,
217 workgroup_size: None,
218 accepts_nan_mode: false,
219 notes: "Host-only binary file I/O; GPU arguments are gathered to the CPU prior to writing.",
220};
221
222register_builtin_gpu_spec!(GPU_SPEC);
223
224pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
225 name: "fwrite",
226 shape: ShapeRequirements::Any,
227 constant_strategy: ConstantStrategy::InlineLiteral,
228 elementwise: None,
229 reduction: None,
230 emits_nan: false,
231 notes: "File I/O is never fused; metadata recorded for completeness.",
232};
233
234register_builtin_fusion_spec!(FUSION_SPEC);
235
236#[cfg(feature = "doc_export")]
237register_builtin_doc_text!("fwrite", DOC_MD);
238
239#[runtime_builtin(
240 name = "fwrite",
241 category = "io/filetext",
242 summary = "Write binary data to a file identifier.",
243 keywords = "fwrite,file,io,binary,precision",
244 accel = "cpu"
245)]
246fn fwrite_builtin(fid: Value, data: Value, rest: Vec<Value>) -> Result<Value, String> {
247 let eval = evaluate(&fid, &data, &rest)?;
248 Ok(Value::Num(eval.count as f64))
249}
250
251#[derive(Debug, Clone)]
253pub struct FwriteEval {
254 count: usize,
255}
256
257impl FwriteEval {
258 fn new(count: usize) -> Self {
259 Self { count }
260 }
261
262 pub fn count(&self) -> usize {
264 self.count
265 }
266}
267
268pub fn evaluate(
270 fid_value: &Value,
271 data_value: &Value,
272 rest: &[Value],
273) -> Result<FwriteEval, String> {
274 let fid_host = gather_value(fid_value)?;
275 let fid = parse_fid(&fid_host)?;
276 if fid < 0 {
277 return Err("fwrite: file identifier must be non-negative".to_string());
278 }
279 if fid < 3 {
280 return Err("fwrite: standard input/output identifiers are not supported yet".to_string());
281 }
282
283 let info = registry::info_for(fid).ok_or_else(|| {
284 "fwrite: Invalid file identifier. Use fopen to generate a valid file ID.".to_string()
285 })?;
286 let handle = registry::take_handle(fid).ok_or_else(|| {
287 "fwrite: Invalid file identifier. Use fopen to generate a valid file ID.".to_string()
288 })?;
289
290 let mut file = handle
291 .lock()
292 .map_err(|_| "fwrite: failed to lock file handle (poisoned mutex)".to_string())?;
293
294 let data_host = gather_value(data_value)?;
295 let rest_host = gather_args(rest)?;
296 let (precision_arg, skip_arg, machine_arg) = classify_arguments(&rest_host)?;
297
298 let precision_spec = parse_precision(precision_arg)?;
299 let skip_bytes = parse_skip(skip_arg)?;
300 let machine_format = parse_machine_format(machine_arg, &info.machinefmt)?;
301
302 let elements = flatten_elements(&data_host)?;
303 let count = write_elements(
304 &mut file,
305 &elements,
306 precision_spec,
307 skip_bytes,
308 machine_format,
309 )?;
310 Ok(FwriteEval::new(count))
311}
312
313fn gather_value(value: &Value) -> Result<Value, String> {
314 gather_if_needed(value).map_err(|e| format!("fwrite: {e}"))
315}
316
317fn gather_args(args: &[Value]) -> Result<Vec<Value>, String> {
318 let mut gathered = Vec::with_capacity(args.len());
319 for value in args {
320 gathered.push(gather_if_needed(value).map_err(|e| format!("fwrite: {e}"))?);
321 }
322 Ok(gathered)
323}
324
325fn parse_fid(value: &Value) -> Result<i32, String> {
326 let scalar = match value {
327 Value::Num(n) => *n,
328 Value::Int(int) => int.to_f64(),
329 _ => return Err("fwrite: file identifier must be numeric".to_string()),
330 };
331 if !scalar.is_finite() {
332 return Err("fwrite: file identifier must be finite".to_string());
333 }
334 if scalar.fract().abs() > f64::EPSILON {
335 return Err("fwrite: file identifier must be an integer".to_string());
336 }
337 Ok(scalar as i32)
338}
339
340type FwriteArgs<'a> = (Option<&'a Value>, Option<&'a Value>, Option<&'a Value>);
341
342fn classify_arguments(args: &[Value]) -> Result<FwriteArgs<'_>, String> {
343 match args.len() {
344 0 => Ok((None, None, None)),
345 1 => {
346 if is_string_like(&args[0]) {
347 Ok((Some(&args[0]), None, None))
348 } else {
349 Err(
350 "fwrite: precision argument must be a string scalar or character vector"
351 .to_string(),
352 )
353 }
354 }
355 2 => {
356 if !is_string_like(&args[0]) {
357 return Err(
358 "fwrite: precision argument must be a string scalar or character vector"
359 .to_string(),
360 );
361 }
362 if is_numeric_like(&args[1]) {
363 Ok((Some(&args[0]), Some(&args[1]), None))
364 } else if is_string_like(&args[1]) {
365 Ok((Some(&args[0]), None, Some(&args[1])))
366 } else {
367 Err("fwrite: invalid argument combination (expected numeric skip or machine format string)".to_string())
368 }
369 }
370 3 => {
371 if !is_string_like(&args[0]) || !is_numeric_like(&args[1]) || !is_string_like(&args[2])
372 {
373 return Err("fwrite: expected arguments (precision, skip, machinefmt)".to_string());
374 }
375 Ok((Some(&args[0]), Some(&args[1]), Some(&args[2])))
376 }
377 _ => Err("fwrite: too many input arguments".to_string()),
378 }
379}
380
381fn is_string_like(value: &Value) -> bool {
382 match value {
383 Value::String(_) => true,
384 Value::CharArray(ca) => ca.rows == 1,
385 Value::StringArray(sa) => sa.data.len() == 1,
386 _ => false,
387 }
388}
389
390fn is_numeric_like(value: &Value) -> bool {
391 match value {
392 Value::Num(_) | Value::Int(_) | Value::Bool(_) => true,
393 Value::Tensor(t) => t.data.len() == 1,
394 Value::LogicalArray(la) => la.data.len() == 1,
395 _ => false,
396 }
397}
398
399#[derive(Clone, Copy, Debug)]
400struct WriteSpec {
401 input: InputType,
402}
403
404impl WriteSpec {
405 fn default() -> Self {
406 Self {
407 input: InputType::UInt8,
408 }
409 }
410}
411
412fn parse_precision(arg: Option<&Value>) -> Result<WriteSpec, String> {
413 match arg {
414 None => Ok(WriteSpec::default()),
415 Some(value) => {
416 let text = scalar_string(
417 value,
418 "fwrite: precision argument must be a string scalar or character vector",
419 )?;
420 parse_precision_string(&text)
421 }
422 }
423}
424
425fn parse_precision_string(raw: &str) -> Result<WriteSpec, String> {
426 let trimmed = raw.trim();
427 if trimmed.is_empty() {
428 return Err("fwrite: precision argument must not be empty".to_string());
429 }
430 let lower = trimmed.to_ascii_lowercase();
431 if let Some((lhs, rhs)) = lower.split_once("=>") {
432 let lhs = lhs.trim();
433 let rhs = rhs.trim();
434 let input = parse_input_label(lhs)?;
435 let output = parse_input_label(rhs)?;
436 if input != output {
437 return Err(
438 "fwrite: differing input/output precisions are not implemented yet".to_string(),
439 );
440 }
441 Ok(WriteSpec { input })
442 } else {
443 parse_input_label(lower.trim()).map(|input| WriteSpec { input })
444 }
445}
446
447fn parse_skip(arg: Option<&Value>) -> Result<usize, String> {
448 match arg {
449 None => Ok(0),
450 Some(value) => {
451 let scalar = numeric_scalar(value, "fwrite: skip must be numeric")?;
452 if !scalar.is_finite() {
453 return Err("fwrite: skip value must be finite".to_string());
454 }
455 if scalar < 0.0 {
456 return Err("fwrite: skip value must be non-negative".to_string());
457 }
458 let rounded = scalar.round();
459 if (rounded - scalar).abs() > f64::EPSILON {
460 return Err("fwrite: skip value must be an integer".to_string());
461 }
462 if rounded > i64::MAX as f64 {
463 return Err("fwrite: skip value is too large".to_string());
464 }
465 Ok(rounded as usize)
466 }
467 }
468}
469
470#[derive(Clone, Copy, Debug)]
471enum MachineFormat {
472 Native,
473 LittleEndian,
474 BigEndian,
475}
476
477impl MachineFormat {
478 fn to_endianness(self) -> Endianness {
479 match self {
480 MachineFormat::Native => {
481 if cfg!(target_endian = "little") {
482 Endianness::Little
483 } else {
484 Endianness::Big
485 }
486 }
487 MachineFormat::LittleEndian => Endianness::Little,
488 MachineFormat::BigEndian => Endianness::Big,
489 }
490 }
491}
492
493#[derive(Clone, Copy, Debug)]
494enum Endianness {
495 Little,
496 Big,
497}
498
499fn parse_machine_format(arg: Option<&Value>, default_label: &str) -> Result<MachineFormat, String> {
500 match arg {
501 Some(value) => {
502 let text = scalar_string(
503 value,
504 "fwrite: machine format must be a string scalar or character vector",
505 )?;
506 machine_format_from_label(&text)
507 }
508 None => machine_format_from_label(default_label),
509 }
510}
511
512fn machine_format_from_label(label: &str) -> Result<MachineFormat, String> {
513 let trimmed = label.trim();
514 if trimmed.is_empty() {
515 return Err("fwrite: machine format must not be empty".to_string());
516 }
517 let lower = trimmed.to_ascii_lowercase();
518 let collapsed: String = lower
519 .chars()
520 .filter(|c| !matches!(c, '-' | '_' | ' '))
521 .collect();
522 if matches!(collapsed.as_str(), "native" | "n" | "system" | "default") {
523 return Ok(MachineFormat::Native);
524 }
525 if matches!(
526 collapsed.as_str(),
527 "l" | "le" | "littleendian" | "pc" | "intel"
528 ) {
529 return Ok(MachineFormat::LittleEndian);
530 }
531 if matches!(
532 collapsed.as_str(),
533 "b" | "be" | "bigendian" | "mac" | "motorola"
534 ) {
535 return Ok(MachineFormat::BigEndian);
536 }
537 if lower.starts_with("ieee-le") {
538 return Ok(MachineFormat::LittleEndian);
539 }
540 if lower.starts_with("ieee-be") {
541 return Ok(MachineFormat::BigEndian);
542 }
543 Err(format!("fwrite: unsupported machine format '{trimmed}'"))
544}
545
546fn scalar_string(value: &Value, err: &str) -> Result<String, String> {
547 match value {
548 Value::String(s) => Ok(s.clone()),
549 Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
550 Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
551 _ => Err(err.to_string()),
552 }
553}
554
555fn numeric_scalar(value: &Value, err: &str) -> Result<f64, String> {
556 match value {
557 Value::Num(n) => Ok(*n),
558 Value::Int(int) => Ok(int.to_f64()),
559 Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
560 Value::Tensor(t) if t.data.len() == 1 => Ok(t.data[0]),
561 Value::LogicalArray(la) if la.data.len() == 1 => {
562 Ok(if la.data[0] != 0 { 1.0 } else { 0.0 })
563 }
564 _ => Err(err.to_string()),
565 }
566}
567
568fn flatten_elements(value: &Value) -> Result<Vec<f64>, String> {
569 match value {
570 Value::Tensor(tensor) => Ok(tensor.data.clone()),
571 Value::Num(n) => Ok(vec![*n]),
572 Value::Int(int) => Ok(vec![int.to_f64()]),
573 Value::Bool(b) => Ok(vec![if *b { 1.0 } else { 0.0 }]),
574 Value::LogicalArray(array) => Ok(array
575 .data
576 .iter()
577 .map(|bit| if *bit != 0 { 1.0 } else { 0.0 })
578 .collect()),
579 Value::CharArray(ca) => Ok(flatten_char_array(ca)),
580 Value::String(text) => Ok(text.chars().map(|ch| ch as u32 as f64).collect()),
581 Value::StringArray(sa) => Ok(flatten_string_array(sa)),
582 Value::GpuTensor(_) => Err("fwrite: expected host tensor data after gathering".to_string()),
583 Value::Complex(_, _) | Value::ComplexTensor(_) => {
584 Err("fwrite: complex values are not supported yet".to_string())
585 }
586 _ => Err(format!("fwrite: unsupported data type {:?}", value)),
587 }
588}
589
590fn flatten_char_array(ca: &CharArray) -> Vec<f64> {
591 let mut values = Vec::with_capacity(ca.rows.saturating_mul(ca.cols));
592 for c in 0..ca.cols {
593 for r in 0..ca.rows {
594 let idx = r * ca.cols + c;
595 values.push(ca.data[idx] as u32 as f64);
596 }
597 }
598 values
599}
600
601fn flatten_string_array(sa: &runmat_builtins::StringArray) -> Vec<f64> {
602 if sa.data.is_empty() {
603 return Vec::new();
604 }
605 let mut values = Vec::new();
606 for (idx, text) in sa.data.iter().enumerate() {
607 if idx > 0 {
608 values.push('\n' as u32 as f64);
609 }
610 values.extend(text.chars().map(|ch| ch as u32 as f64));
611 }
612 values
613}
614
615fn write_elements(
616 file: &mut std::sync::MutexGuard<'_, std::fs::File>,
617 values: &[f64],
618 spec: WriteSpec,
619 skip: usize,
620 machine: MachineFormat,
621) -> Result<usize, String> {
622 let endianness = machine.to_endianness();
623 let skip_offset = skip as i64;
624 for &value in values {
625 match spec.input {
626 InputType::UInt8 => {
627 let byte = to_u8(value);
628 write_bytes(file, &[byte])?;
629 }
630 InputType::Int8 => {
631 let byte = to_i8(value) as u8;
632 write_bytes(file, &[byte])?;
633 }
634 InputType::UInt16 => {
635 let bytes = encode_u16(value, endianness);
636 write_bytes(file, &bytes)?;
637 }
638 InputType::Int16 => {
639 let bytes = encode_i16(value, endianness);
640 write_bytes(file, &bytes)?;
641 }
642 InputType::UInt32 => {
643 let bytes = encode_u32(value, endianness);
644 write_bytes(file, &bytes)?;
645 }
646 InputType::Int32 => {
647 let bytes = encode_i32(value, endianness);
648 write_bytes(file, &bytes)?;
649 }
650 InputType::UInt64 => {
651 let bytes = encode_u64(value, endianness);
652 write_bytes(file, &bytes)?;
653 }
654 InputType::Int64 => {
655 let bytes = encode_i64(value, endianness);
656 write_bytes(file, &bytes)?;
657 }
658 InputType::Float32 => {
659 let bytes = encode_f32(value, endianness);
660 write_bytes(file, &bytes)?;
661 }
662 InputType::Float64 => {
663 let bytes = encode_f64(value, endianness);
664 write_bytes(file, &bytes)?;
665 }
666 }
667
668 if skip > 0 {
669 file.seek(SeekFrom::Current(skip_offset))
670 .map_err(|err| format!("fwrite: failed to seek while applying skip ({err})"))?;
671 }
672 }
673 Ok(values.len())
674}
675
676fn write_bytes(file: &mut std::fs::File, bytes: &[u8]) -> Result<(), String> {
677 file.write_all(bytes)
678 .map_err(|err| format!("fwrite: failed to write to file ({err})"))
679}
680
681fn to_u8(value: f64) -> u8 {
682 if !value.is_finite() {
683 return if value.is_sign_negative() { 0 } else { u8::MAX };
684 }
685 let mut rounded = value.round();
686 if rounded.is_nan() {
687 return 0;
688 }
689 if rounded < 0.0 {
690 rounded = 0.0;
691 }
692 if rounded > u8::MAX as f64 {
693 rounded = u8::MAX as f64;
694 }
695 rounded as u8
696}
697
698fn to_i8(value: f64) -> i8 {
699 saturating_round(value, i8::MIN as f64, i8::MAX as f64) as i8
700}
701
702fn encode_u16(value: f64, endianness: Endianness) -> [u8; 2] {
703 let rounded = saturating_round(value, 0.0, u16::MAX as f64) as u16;
704 match endianness {
705 Endianness::Little => rounded.to_le_bytes(),
706 Endianness::Big => rounded.to_be_bytes(),
707 }
708}
709
710fn encode_i16(value: f64, endianness: Endianness) -> [u8; 2] {
711 let rounded = saturating_round(value, i16::MIN as f64, i16::MAX as f64) as i16;
712 match endianness {
713 Endianness::Little => rounded.to_le_bytes(),
714 Endianness::Big => rounded.to_be_bytes(),
715 }
716}
717
718fn encode_u32(value: f64, endianness: Endianness) -> [u8; 4] {
719 let rounded = saturating_round(value, 0.0, u32::MAX as f64) as u32;
720 match endianness {
721 Endianness::Little => rounded.to_le_bytes(),
722 Endianness::Big => rounded.to_be_bytes(),
723 }
724}
725
726fn encode_i32(value: f64, endianness: Endianness) -> [u8; 4] {
727 let rounded = saturating_round(value, i32::MIN as f64, i32::MAX as f64) as i32;
728 match endianness {
729 Endianness::Little => rounded.to_le_bytes(),
730 Endianness::Big => rounded.to_be_bytes(),
731 }
732}
733
734fn encode_u64(value: f64, endianness: Endianness) -> [u8; 8] {
735 let rounded = saturating_round(value, 0.0, u64::MAX as f64);
736 let as_u64 = if rounded.is_finite() {
737 rounded as u64
738 } else if rounded.is_sign_negative() {
739 0
740 } else {
741 u64::MAX
742 };
743 match endianness {
744 Endianness::Little => as_u64.to_le_bytes(),
745 Endianness::Big => as_u64.to_be_bytes(),
746 }
747}
748
749fn encode_i64(value: f64, endianness: Endianness) -> [u8; 8] {
750 let rounded = saturating_round(value, i64::MIN as f64, i64::MAX as f64);
751 let as_i64 = if rounded.is_finite() {
752 rounded as i64
753 } else if rounded.is_sign_negative() {
754 i64::MIN
755 } else {
756 i64::MAX
757 };
758 match endianness {
759 Endianness::Little => as_i64.to_le_bytes(),
760 Endianness::Big => as_i64.to_be_bytes(),
761 }
762}
763
764fn encode_f32(value: f64, endianness: Endianness) -> [u8; 4] {
765 let as_f32 = value as f32;
766 let bits = as_f32.to_bits();
767 match endianness {
768 Endianness::Little => bits.to_le_bytes(),
769 Endianness::Big => bits.to_be_bytes(),
770 }
771}
772
773fn encode_f64(value: f64, endianness: Endianness) -> [u8; 8] {
774 let bits = value.to_bits();
775 match endianness {
776 Endianness::Little => bits.to_le_bytes(),
777 Endianness::Big => bits.to_be_bytes(),
778 }
779}
780
781fn saturating_round(value: f64, min: f64, max: f64) -> f64 {
782 if !value.is_finite() {
783 return if value.is_sign_negative() { min } else { max };
784 }
785 let mut rounded = value.round();
786 if rounded.is_nan() {
787 return 0.0;
788 }
789 if rounded < min {
790 rounded = min;
791 }
792 if rounded > max {
793 rounded = max;
794 }
795 rounded
796}
797
798#[derive(Clone, Copy, Debug, PartialEq, Eq)]
799enum InputType {
800 UInt8,
801 Int8,
802 UInt16,
803 Int16,
804 UInt32,
805 Int32,
806 UInt64,
807 Int64,
808 Float32,
809 Float64,
810}
811
812fn parse_input_label(label: &str) -> Result<InputType, String> {
813 match label {
814 "double" | "float64" | "real*8" => Ok(InputType::Float64),
815 "single" | "float32" | "real*4" => Ok(InputType::Float32),
816 "int8" | "schar" | "integer*1" => Ok(InputType::Int8),
817 "uint8" | "uchar" | "unsignedchar" | "char" | "byte" => Ok(InputType::UInt8),
818 "int16" | "short" | "integer*2" => Ok(InputType::Int16),
819 "uint16" | "ushort" | "unsignedshort" => Ok(InputType::UInt16),
820 "int32" | "integer*4" | "long" => Ok(InputType::Int32),
821 "uint32" | "unsignedint" | "unsignedlong" => Ok(InputType::UInt32),
822 "int64" | "integer*8" | "longlong" => Ok(InputType::Int64),
823 "uint64" | "unsignedlonglong" => Ok(InputType::UInt64),
824 other => Err(format!("fwrite: unsupported precision '{other}'")),
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use crate::builtins::common::test_support;
832 use crate::builtins::io::filetext::registry;
833 use crate::builtins::io::filetext::{fclose, fopen};
834 #[cfg(feature = "wgpu")]
835 use runmat_accelerate::backend::wgpu::provider;
836 #[cfg(feature = "wgpu")]
837 use runmat_accelerate_api::AccelProvider;
838 use runmat_accelerate_api::HostTensorView;
839 use runmat_builtins::Tensor;
840 use std::fs::{self, File};
841 use std::io::Read;
842 use std::path::PathBuf;
843 use std::time::{SystemTime, UNIX_EPOCH};
844
845 #[test]
846 fn fwrite_default_uint8_bytes() {
847 registry::reset_for_tests();
848 let path = unique_path("fwrite_uint8");
849 let open = fopen::evaluate(&[
850 Value::from(path.to_string_lossy().to_string()),
851 Value::from("w+b"),
852 ])
853 .expect("fopen");
854 let fid = open.as_open().unwrap().fid as i32;
855
856 let tensor = Tensor::new(vec![1.0, 2.0, 255.0], vec![3, 1]).unwrap();
857 let eval =
858 evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &Vec::new()).expect("fwrite");
859 assert_eq!(eval.count(), 3);
860
861 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
862
863 let bytes = fs::read(&path).expect("read");
864 assert_eq!(bytes, vec![1u8, 2, 255]);
865 fs::remove_file(path).unwrap();
866 }
867
868 #[test]
869 fn fwrite_double_precision_writes_native_endian() {
870 registry::reset_for_tests();
871 let path = unique_path("fwrite_double");
872 let open = fopen::evaluate(&[
873 Value::from(path.to_string_lossy().to_string()),
874 Value::from("w+b"),
875 ])
876 .expect("fopen");
877 let fid = open.as_open().unwrap().fid as i32;
878
879 let tensor = Tensor::new(vec![1.5, -2.25], vec![2, 1]).unwrap();
880 let args = vec![Value::from("double")];
881 let eval =
882 evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
883 assert_eq!(eval.count(), 2);
884
885 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
886
887 let bytes = fs::read(&path).expect("read");
888 let expected: Vec<u8> = if cfg!(target_endian = "little") {
889 [1.5f64.to_le_bytes(), (-2.25f64).to_le_bytes()].concat()
890 } else {
891 [1.5f64.to_be_bytes(), (-2.25f64).to_be_bytes()].concat()
892 };
893 assert_eq!(bytes, expected);
894 fs::remove_file(path).unwrap();
895 }
896
897 #[test]
898 fn fwrite_big_endian_uint16() {
899 registry::reset_for_tests();
900 let path = unique_path("fwrite_be");
901 let open = fopen::evaluate(&[
902 Value::from(path.to_string_lossy().to_string()),
903 Value::from("w+b"),
904 Value::from("ieee-be"),
905 ])
906 .expect("fopen");
907 let fid = open.as_open().unwrap().fid as i32;
908
909 let tensor = Tensor::new(vec![258.0, 772.0], vec![2, 1]).unwrap();
910 let args = vec![Value::from("uint16")];
911 let eval =
912 evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
913 assert_eq!(eval.count(), 2);
914
915 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
916
917 let bytes = fs::read(&path).expect("read");
918 assert_eq!(bytes, vec![0x01, 0x02, 0x03, 0x04]);
919 fs::remove_file(path).unwrap();
920 }
921
922 #[test]
923 fn fwrite_skip_inserts_padding() {
924 registry::reset_for_tests();
925 let path = unique_path("fwrite_skip");
926 let open = fopen::evaluate(&[
927 Value::from(path.to_string_lossy().to_string()),
928 Value::from("w+b"),
929 ])
930 .expect("fopen");
931 let fid = open.as_open().unwrap().fid as i32;
932
933 let tensor = Tensor::new(vec![10.0, 20.0, 30.0], vec![3, 1]).unwrap();
934 let args = vec![Value::from("uint8"), Value::Num(1.0)];
935 let eval =
936 evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
937 assert_eq!(eval.count(), 3);
938
939 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
940
941 let bytes = fs::read(&path).expect("read");
942 assert_eq!(bytes, vec![10u8, 0, 20, 0, 30]);
943 fs::remove_file(path).unwrap();
944 }
945
946 #[test]
947 fn fwrite_gpu_tensor_gathers_before_write() {
948 registry::reset_for_tests();
949 let path = unique_path("fwrite_gpu");
950
951 test_support::with_test_provider(|provider| {
952 registry::reset_for_tests();
953 let open = fopen::evaluate(&[
954 Value::from(path.to_string_lossy().to_string()),
955 Value::from("w+b"),
956 ])
957 .expect("fopen");
958 let fid = open.as_open().unwrap().fid as i32;
959
960 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]).unwrap();
961 let view = HostTensorView {
962 data: &tensor.data,
963 shape: &tensor.shape,
964 };
965 let handle = provider.upload(&view).expect("upload");
966 let args = vec![Value::from("uint16")];
967 let eval = evaluate(&Value::Num(fid as f64), &Value::GpuTensor(handle), &args)
968 .expect("fwrite");
969 assert_eq!(eval.count(), 4);
970
971 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
972 });
973
974 let mut file = File::open(&path).expect("open");
975 let mut bytes = Vec::new();
976 file.read_to_end(&mut bytes).expect("read");
977 assert_eq!(bytes.len(), 8);
978 let mut decoded = Vec::new();
979 for chunk in bytes.chunks_exact(2) {
980 let value = if cfg!(target_endian = "little") {
981 u16::from_le_bytes([chunk[0], chunk[1]])
982 } else {
983 u16::from_be_bytes([chunk[0], chunk[1]])
984 };
985 decoded.push(value);
986 }
987 assert_eq!(decoded, vec![1u16, 2, 3, 4]);
988 fs::remove_file(path).unwrap();
989 }
990
991 #[test]
992 fn fwrite_invalid_precision_errors() {
993 registry::reset_for_tests();
994 let path = unique_path("fwrite_invalid_precision");
995 let open = fopen::evaluate(&[
996 Value::from(path.to_string_lossy().to_string()),
997 Value::from("w+b"),
998 ])
999 .expect("fopen");
1000 let fid = open.as_open().unwrap().fid as i32;
1001
1002 let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1003 let args = vec![Value::from("bogus-class")];
1004 let err = evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).unwrap_err();
1005 assert!(err.contains("unsupported precision"));
1006 let _ = fclose::evaluate(&[Value::Num(fid as f64)]);
1007 fs::remove_file(path).unwrap();
1008 }
1009
1010 #[test]
1011 fn fwrite_negative_skip_errors() {
1012 registry::reset_for_tests();
1013 let path = unique_path("fwrite_negative_skip");
1014 let open = fopen::evaluate(&[
1015 Value::from(path.to_string_lossy().to_string()),
1016 Value::from("w+b"),
1017 ])
1018 .expect("fopen");
1019 let fid = open.as_open().unwrap().fid as i32;
1020
1021 let tensor = Tensor::new(vec![10.0], vec![1, 1]).unwrap();
1022 let args = vec![Value::from("uint8"), Value::Num(-1.0)];
1023 let err = evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).unwrap_err();
1024 assert!(err.contains("skip value must be non-negative"));
1025 let _ = fclose::evaluate(&[Value::Num(fid as f64)]);
1026 fs::remove_file(path).unwrap();
1027 }
1028
1029 #[test]
1030 #[cfg(feature = "wgpu")]
1031 fn fwrite_wgpu_tensor_roundtrip() {
1032 registry::reset_for_tests();
1033 let path = unique_path("fwrite_wgpu_roundtrip");
1034 let open = fopen::evaluate(&[
1035 Value::from(path.to_string_lossy().to_string()),
1036 Value::from("w+b"),
1037 ])
1038 .expect("fopen");
1039 let fid = open.as_open().unwrap().fid as i32;
1040
1041 let provider = provider::register_wgpu_provider(provider::WgpuProviderOptions::default())
1042 .expect("wgpu provider");
1043
1044 let tensor = Tensor::new(vec![0.5, -1.25, 3.75], vec![3, 1]).unwrap();
1045 let expected = tensor.data.clone();
1046 let view = HostTensorView {
1047 data: &tensor.data,
1048 shape: &tensor.shape,
1049 };
1050 let handle = provider.upload(&view).expect("upload to gpu");
1051 let args = vec![Value::from("double")];
1052 let eval =
1053 evaluate(&Value::Num(fid as f64), &Value::GpuTensor(handle), &args).expect("fwrite");
1054 assert_eq!(eval.count(), 3);
1055
1056 fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
1057
1058 let mut file = File::open(&path).expect("open");
1059 let mut bytes = Vec::new();
1060 file.read_to_end(&mut bytes).expect("read");
1061 assert_eq!(bytes.len(), 24);
1062 for (chunk, expected_value) in bytes.chunks_exact(8).zip(expected.iter()) {
1063 let mut buf = [0u8; 8];
1064 buf.copy_from_slice(chunk);
1065 let value = if cfg!(target_endian = "little") {
1066 f64::from_le_bytes(buf)
1067 } else {
1068 f64::from_be_bytes(buf)
1069 };
1070 assert!(
1071 (value - expected_value).abs() < 1e-12,
1072 "mismatch: {} vs {}",
1073 value,
1074 expected_value
1075 );
1076 }
1077 fs::remove_file(path).unwrap();
1078 }
1079
1080 #[test]
1081 fn fwrite_invalid_identifier_errors() {
1082 registry::reset_for_tests();
1083 let err = evaluate(&Value::Num(-1.0), &Value::Num(1.0), &Vec::new()).unwrap_err();
1084 assert!(err.contains("file identifier must be non-negative"));
1085 }
1086
1087 #[test]
1088 #[cfg(feature = "doc_export")]
1089 fn doc_examples_present() {
1090 let blocks = test_support::doc_examples(DOC_MD);
1091 assert!(!blocks.is_empty());
1092 }
1093
1094 fn unique_path(prefix: &str) -> PathBuf {
1095 let now = SystemTime::now()
1096 .duration_since(UNIX_EPOCH)
1097 .expect("time went backwards");
1098 let filename = format!(
1099 "runmat_{prefix}_{}_{}.tmp",
1100 now.as_secs(),
1101 now.subsec_nanos()
1102 );
1103 std::env::temp_dir().join(filename)
1104 }
1105}