Skip to main content

edgefirst_tensor/
lib.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4/*!
5EdgeFirst HAL - Tensor Module
6
7The `edgefirst_tensor` crate provides a unified interface for managing multi-dimensional arrays (tensors)
8across four memory backends: the platform's native zero-copy GPU buffer, POSIX shared memory, the system
9heap, and an OpenGL Pixel Buffer Object. The crate defines traits and structures for creating, reshaping,
10and mapping tensors into memory.
11
12## Examples
13```rust
14use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
15# fn main() -> Result<(), Error> {
16let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
17assert_eq!(tensor.memory(), TensorMemory::Mem);
18assert_eq!(tensor.name(), "test_tensor");
19#    Ok(())
20# }
21```
22
23## Overview
24The main structures and traits provided by the `edgefirst_tensor` crate are [`TensorTrait`] and
25[`TensorMapTrait`], which define the behavior of Tensors and their memory mappings, respectively.
26The [`Tensor<T>`] struct wraps a backend-specific storage with optional image format metadata
27([`PixelFormat`]); mapping a tensor yields a [`HostView`], which implements
28[`TensorMapTrait`] and derefs to the underlying elements. The
29[`TensorDyn`] type-erased enum wraps `Tensor<T>` for runtime element-type dispatch.
30
31[`TensorMemory::DmaBuf`] is one variant with three implementations behind it — a Linux DMA-heap DMA-BUF,
32a macOS/iOS `IOSurfaceRef`, or an Android `AHardwareBuffer`. Callers ask for `Dma` and get whichever
33the platform provides, so portable code never branches on the mechanism.
34
35## Image tensors
36Images go through [`Tensor::image`] (or [`Tensor::image_desc`] for the full-featured request) rather
37than [`Tensor::new`]. Two things set them apart:
38
39- They take a [`CpuAccess`] declaration (`ReadWrite` is the default). Hardware access — GPU, NPU,
40  ISP, codec — is always implied; what the CPU intends is the one thing the allocator cannot guess.
41  A CPU map, pin or sync beyond the declaration fails with [`Error::CpuAccessDenied`].
42- DMA-backed images carry a 64-byte-aligned row stride in every layout, because Mali and Vivante
43  reject an EGLImage import at an unaligned pitch. Read the real pitch from
44  [`Tensor::effective_row_stride`]; don't assume `width * bpp`.
45 */
46// Static backend only: reached solely through `TensorStorage`'s AHardwareBuffer variant.
47#[cfg(all(target_os = "android", not(edgefirst_tensor_dynamic)))]
48mod ahardwarebuffer;
49// Pure AHardwareBuffer layout logic (format table, descriptor geometry,
50// overflow-checked shape math) — cfg-free so it compiles and unit-tests
51// on every host; the android module above consumes it.
52#[allow(dead_code)]
53mod ahardwarebuffer_layout;
54pub mod colorimetry;
55#[cfg(feature = "tracing")]
56pub mod trace;
57
58pub mod covguard;
59mod cuda;
60mod detect;
61// Static backend only: the dynamic backend reaches DMA-BUF fds through `ef_tensor_plane_at`.
62#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
63mod dma;
64#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
65mod dmabuf;
66mod error;
67mod format;
68// Layout table is backend-agnostic (pure DXGI-format math), same reasoning
69// as `iosurface_layout` below: compiled on every platform so it is
70// unit-tested on every CI lane. The allocator that consumes it
71// (`crate::d3d11`) is Windows-only.
72#[cfg(target_os = "windows")]
73pub mod d3d11;
74pub mod d3d11_layout;
75// Layout table is backend-agnostic (pure FourCC math). Storage
76// (`IoSurfaceTensor`) is `static`-only; `dynamic` reads IOSurface handles
77// through `ef_tensor_*`.
78#[cfg(any(target_os = "macos", target_os = "ios"))]
79mod iosurface_layout;
80// `static`-only, same reasoning as `ahardwarebuffer` above: reached only
81// through `TensorStorage`'s `IoSurface` variant.
82#[cfg(all(
83    any(target_os = "macos", target_os = "ios"),
84    not(edgefirst_tensor_dynamic)
85))]
86mod iosurface;
87pub mod lens;
88// `static`-only: `MemTensor` backs `TensorStorage::Mem`, `impl<T> Tensor<T>`
89// only. `dynamic`'s host-memory allocation goes through `ef_tensor_new`/
90// `ef_tensor_builder_alloc` in `libedgefirst_tensor.so` instead -- there is
91// no local `MemTensor` under `dynamic` to be dead code, so the whole module
92// gates rather than an internal `#[allow]`.
93#[cfg(not(edgefirst_tensor_dynamic))]
94mod mem;
95mod pbo;
96pub mod pin;
97pub mod protocol;
98// `static`-only, same reasoning as `mem` above: `ShmTensor` backs
99// `TensorStorage::Shm`, `impl<T> Tensor<T>` only.
100#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
101mod shm;
102mod tensor_dyn;
103#[cfg(test)]
104mod test_support;
105pub mod view;
106mod vocabulary;
107pub use colorimetry::{
108    ColorEncoding, ColorRange, ColorSpace, ColorTransfer, Colorimetry, MatrixWeights, RangeScaling,
109};
110
111/// Retained constructor: installs the coverage flush-on-abort handler for this
112/// crate's instrumented test binary. See `covguard`. Only present under
113/// coverage on Linux (`.init_array` is ELF-only; the i.MX flush is Linux-only).
114#[cfg(all(coverage, target_os = "linux"))]
115#[used]
116#[link_section = ".init_array"]
117static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
118    extern "C" fn ctor() {
119        crate::covguard::install();
120    }
121    ctor
122};
123
124// Backing tensor/map types are internal implementation details: callers
125// allocate `Tensor<T>` / `TensorDyn` and map them, never naming the per-memory
126// backing types directly. They are `pub(crate)` so they stay nameable for the
127// `TensorStorage` / `TensorMap` enums without leaking into the public API.
128// Exceptions kept public: `Pbo*` is a GL extension point implemented by the
129// image crate, and `image_iosurface_layout` is a public helper.
130#[cfg(all(target_os = "android", not(edgefirst_tensor_dynamic)))]
131pub use crate::ahardwarebuffer::image_ahardwarebuffer_layout;
132#[cfg(all(target_os = "android", not(edgefirst_tensor_dynamic)))]
133pub(crate) use crate::ahardwarebuffer::AHardwareBufferTensor;
134#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
135pub(crate) use crate::dma::DmaTensor;
136#[cfg(all(
137    any(target_os = "macos", target_os = "ios"),
138    not(edgefirst_tensor_dynamic)
139))]
140pub(crate) use crate::iosurface::IoSurfaceTensor;
141// The two geometry helpers are compiled for both backends: three of the four
142// surfaces that read a raw texture's geometry (the Python bindings, and the
143// protocol and blob imports) link `dynamic`, and only the C ABI links
144// `static`. See `crate::d3d11::geometry`.
145#[cfg(target_os = "windows")]
146pub use crate::d3d11::geometry::{d3d11_shared_handle_geometry, d3d11_texture_geometry};
147#[cfg(any(target_os = "macos", target_os = "ios"))]
148pub use crate::iosurface_layout::image_iosurface_layout;
149#[cfg(not(edgefirst_tensor_dynamic))]
150pub(crate) use crate::mem::MemTensor;
151pub use crate::pbo::{
152    client_state_pbo_ops, read_pbo_vtable_parts, EfClientState, EfPboMapFn, EfPboUnmapFn,
153    PboClientParts, PboMapping, PboOps, PboOpsVtableParts, PboTensor,
154};
155#[cfg(all(unix, not(edgefirst_tensor_dynamic)))]
156pub(crate) use crate::shm::ShmTensor;
157pub use cuda::{
158    client_state_cuda_ops, gl_map_resource, gl_register_buffer, gl_unmap_resource,
159    gl_unregister_resource, is_cuda_available, memcpy_device_to_host, memcpy_host_to_device,
160    runtime_path as cuda_runtime_path, stream_create, stream_destroy, stream_synchronize,
161    CudaGlOps, CudaHandle, CudaMap, CudaStream,
162};
163pub use detect::{unletter_norm, BoundingBox, DetectBox, ProtoData, ProtoLayout, Segmentation};
164pub use error::{Error, Result};
165// Static-backend-implementation code: backs `tensor-capi`'s own
166// `ef_tensor_export`/`ef_tensor_import` (the static cdylib's `TensorDyn`
167// serialization), the one caller in the whole workspace. It calls
168// `TensorDyn::new`/`from_fd` and reads fields (`.quantization()`,
169// `.colorimetry()`, `.pin_host()`, ...) that only the `static` backend's
170// richer `TensorDyn` surface has, so it never compiled under `dynamic` in
171// fact even though nothing gated it -- see `PRIMITIVE-INVENTORY.md`'s
172// "structural findings".
173#[cfg(not(edgefirst_tensor_dynamic))]
174pub mod blob;
175#[cfg(not(edgefirst_tensor_dynamic))]
176pub use blob::{
177    BlobError, BlobHeader, BlobPlane, BlobStrings, BlobView, Regions, TransportMode, HEADER_LEN,
178    PLANE_RECORD_LEN,
179};
180pub use format::{ChromaLayout, PixelFormat, PixelLayout, PlaneGeometry};
181pub use lens::Element;
182use num_traits::Num;
183pub use pin::HostPin;
184pub use protocol::{
185    dtype as tensor_dtype, dtype_to_dtype, format as tensor_format, format_from_code,
186    kind as tensor_kind, SendPtr, TensorDesc, ABI_VERSION,
187};
188use serde::{Deserialize, Serialize};
189#[cfg(unix)]
190use std::os::fd::OwnedFd;
191use std::{
192    fmt,
193    sync::{
194        atomic::{AtomicU64, Ordering},
195        Arc, Weak,
196    },
197};
198// `static`'s `Tensor<T>` is declared directly above, in this file; there is
199// nothing to re-export for it. `dynamic`'s lives in `tensor_dyn` (see that
200// module's docs) and is re-exported here under the same crate-root name so
201// both backends present the same `edgefirst_tensor::Tensor` to callers.
202#[cfg(edgefirst_tensor_dynamic)]
203pub use tensor_dyn::Tensor;
204pub use tensor_dyn::{BorrowedTensor, Raw, TensorDyn};
205pub use view::HostView;
206pub use vocabulary::vocabulary_demo;
207
208/// Opaque keep-alive handle for a foreign-memory tensor (see
209/// [`Tensor::from_foreign`] / [`TensorDyn::from_foreign_ptr`]).
210///
211/// The HAL borrows the foreign buffer without owning it; this handle co-owns
212/// the *source* so the borrowed memory stays valid for the tensor's life. Its
213/// `Drop` releases the source — e.g. a small struct that calls `cudaFreeHost`,
214/// or a `Py<PyAny>` that decrements a NumPy array's refcount. Wrapping it in an
215/// `Arc` (then boxing each clone) makes the release fire exactly once, after
216/// the last sharing tensor/view/map drops, regardless of drop order.
217pub type ForeignOwner = Box<dyn std::any::Any + Send + Sync>;
218
219/// Re-export of `half::f16` so downstream crates can write
220/// `Tensor::<edgefirst_tensor::f16>::from_iosurface(…)` without
221/// adding `half` to their own dependency list. The version stays in
222/// lockstep with the `half` workspace dep.
223pub use half::f16;
224
225// =============================================================================
226// RGBA16F packed-layout geometry — single source of truth
227//
228// A `PlanarRgb` [3,H,W] or `PlanarRgba` [4,H,W] f16 tensor is represented
229// on the GPU as an RGBA16F surface (the only float format accepted by the
230// ANGLE IOSurface extension). Four contiguous f16 elements are packed into
231// each 8-byte RGBA16F texel, yielding a `(W/4, C*H)` surface.
232//
233// All call sites that need these dimensions must use `packed_rgba16f_layout`
234// so the rule lives in exactly one place. Currently consumed by:
235//  - `crates/tensor/src/iosurface.rs` `new_image` (macOS IOSurface alloc)
236//  - `crates/image/src/gl/iosurface_import.rs` (macOS GL IOSurface import)
237//  - `crates/image/src/gl/processor/float.rs` (Linux GL float render — PBO
238//    readback and DMA-BUF, also via the `dma_f16_packed_layout` wrapper)
239// =============================================================================
240
241/// Geometry of the RGBA16F-packed surface backing a planar F16 image tensor.
242///
243/// ANGLE only supports one float `(type, internal_format)` pair for IOSurface
244/// import: `(GL_HALF_FLOAT, GL_RGBA)` = RGBA16F (8 bytes/texel). To map a
245/// `[C, H, W]` f16 planar tensor onto such a surface, 4 contiguous f16
246/// elements are packed into each RGBA16F texel, yielding a surface of
247/// `(W/4, C*H)` texels at 8 bytes/texel. The byte stream is identical to a
248/// (nonexistent) R16F `(W, C*H)` surface and can be consumed as `&[f16]`
249/// with shape `[1, C, H, W]` without rearrangement.
250///
251/// Obtain via [`packed_rgba16f_layout`] — never construct directly.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct PackedRgba16fLayout {
254    /// Surface width in texels (`width / 4`).
255    pub surface_w: usize,
256    /// Surface height in texels (`planes * height`).
257    pub surface_h: usize,
258    /// Bytes per RGBA16F texel (always 8).
259    pub bytes_per_texel: usize,
260    /// Row pitch in bytes (`surface_w * 8`).
261    pub pitch: usize,
262}
263
264/// Canonical geometry for the RGBA16F-packed surface backing a planar F16
265/// image tensor.
266///
267/// Returns `Some(layout)` only when **all** of the following hold:
268///
269/// - `dtype == DType::F16`
270/// - `format` is `PixelFormat::PlanarRgb` (3 planes) or
271///   `PixelFormat::PlanarRgba` (4 planes)
272/// - `width % 4 == 0`
273///
274/// Returns `None` for any other `(format, dtype)` combination, misaligned
275/// width, or when the surface geometry would overflow `usize` — callers
276/// must fall back to a non-packed path or return a context-appropriate
277/// error.
278///
279/// # Examples
280///
281/// ```rust
282/// use edgefirst_tensor::{packed_rgba16f_layout, PixelFormat, DType};
283///
284/// let layout = packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 480).unwrap();
285/// assert_eq!(layout.surface_w, 160);
286/// assert_eq!(layout.surface_h, 1440);
287/// assert_eq!(layout.bytes_per_texel, 8);
288/// assert_eq!(layout.pitch, 1280);
289/// ```
290pub fn packed_rgba16f_layout(
291    format: PixelFormat,
292    dtype: DType,
293    width: usize,
294    height: usize,
295) -> Option<PackedRgba16fLayout> {
296    if dtype != DType::F16 {
297        return None;
298    }
299    let planes: usize = match format {
300        PixelFormat::PlanarRgb => 3,
301        PixelFormat::PlanarRgba => 4,
302        _ => return None,
303    };
304    if !width.is_multiple_of(4) {
305        return None;
306    }
307    let surface_w = width / 4;
308    // Checked arithmetic: a degenerate (height, width) could otherwise wrap
309    // and yield an under-sized layout, which downstream allocators trust for
310    // GPU/CPU buffer sizing. Overflow → None (handled like any other
311    // unsupported geometry).
312    let surface_h = planes.checked_mul(height)?;
313    let bytes_per_texel = 8;
314    let pitch = surface_w.checked_mul(bytes_per_texel)?;
315    Some(PackedRgba16fLayout {
316        surface_w,
317        surface_h,
318        bytes_per_texel,
319        pitch,
320    })
321}
322
323/// Geometry of the RGBA8888-packed surface backing a packed RGB u8/i8 image
324/// tensor.
325///
326/// GPUs have no 3-channel renderable format, so the GL engine's two-pass
327/// packed-RGB shader writes the tight `[H, W, 3]` byte stream into an
328/// RGBA8888 surface: each texel carries 4 consecutive RGB bytes, giving a
329/// `(W*3/4, H)` surface at 4 bytes/texel whose rows are byte-identical to
330/// tight RGB — consumable flat as `[H, W, 3]` with no rearrangement (the
331/// u8/i8 analog of [`packed_rgba16f_layout`]; i8 shares the layout since
332/// INT8 quantization is a per-byte `^0x80` bias, not a format change).
333///
334/// Returns `Some(layout)` only when `width % 4 == 0` (so `W*3` bytes divide
335/// into whole texels) and the geometry does not overflow `usize`.
336///
337/// # Examples
338///
339/// ```rust
340/// use edgefirst_tensor::packed_rgb888_layout;
341///
342/// let layout = packed_rgb888_layout(640, 480).unwrap();
343/// assert_eq!(layout.surface_w, 480); // 640*3/4
344/// assert_eq!(layout.surface_h, 480);
345/// assert_eq!(layout.bytes_per_texel, 4);
346/// assert_eq!(layout.pitch, 1920); // 640*3
347/// assert!(packed_rgb888_layout(641, 480).is_none());
348/// ```
349pub fn packed_rgb888_layout(width: usize, height: usize) -> Option<PackedRgb888Layout> {
350    if !width.is_multiple_of(4) {
351        return None;
352    }
353    let row_bytes = width.checked_mul(3)?;
354    let surface_w = row_bytes / 4;
355    Some(PackedRgb888Layout {
356        surface_w,
357        surface_h: height,
358        bytes_per_texel: 4,
359        pitch: row_bytes,
360    })
361}
362
363/// Geometry of the RGBA8888 surface backing a packed RGB u8/i8 image tensor.
364///
365/// Obtain via [`packed_rgb888_layout`] — never construct directly.
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub struct PackedRgb888Layout {
368    /// Surface width in texels (`width * 3 / 4`).
369    pub surface_w: usize,
370    /// Surface height in texels (`height`).
371    pub surface_h: usize,
372    /// Bytes per RGBA8888 texel (always 4).
373    pub bytes_per_texel: usize,
374    /// Row pitch in bytes (`surface_w * 4` = `width * 3`).
375    pub pitch: usize,
376}
377
378/// The logical `(width, height)` an **allocation** shape describes for
379/// `format` -- the inverse of [`PixelFormat::allocation_shape`].
380///
381/// Used wherever a tensor arrives carrying a shape and a format but not its
382/// dimensions: the C constructors, the Python bindings and the cross-package
383/// protocol import.
384///
385/// # The input is the allocation shape, not the addressing shape
386///
387/// [`PixelFormat::allocation_shape`] is what [`Tensor::image`] allocates from
388/// and what `descriptor_pinned` emits from `shape()`, so that is what this
389/// inverts. [`PixelFormat::addressing_shape`] is a different, smaller grid,
390/// and for a semi-planar format the two collide: NV12's addressing shape is
391/// `[h, w]` and its allocation shape is `[combined_h, w]`, both rank 2. Handed
392/// an addressing shape this cannot tell -- NV12 `[480, 640]` reads as a
393/// combined height of 480 and answers `(640, 320)`, silently. Nothing in the
394/// shape distinguishes them, so callers must pass the allocation shape.
395///
396/// A D3D11 import is the one place this cannot bite: the constructors check
397/// the derived size against the texture description, so a mismatch fails with
398/// [`Error::InvalidArgument`] naming both. On Windows, prefer
399/// `d3d11_texture_geometry` and `d3d11_shared_handle_geometry`, which read the
400/// dimensions off the texture itself and leave the shape as a consistency
401/// check -- except for a semi-planar texture, whose width is its row stride
402/// rather than the image width, so those helpers take the width from the shape
403/// they are given and refuse to answer without one.
404///
405/// The rank is part of the format's contract, so a shape of the wrong rank is
406/// an error rather than an interpretation of whatever dimensions are present:
407///
408/// | Layout | Shape | Result |
409/// |--------|-------|--------|
410/// | Packed | `[h, w, c]` | `(shape[1], shape[0])` |
411/// | Planar | `[c, h, w]` | `(shape[2], shape[1])` |
412/// | SemiPlanar | `[combined_h, w]` | `(shape[1], image height)` |
413///
414/// A semi-planar shape carries the *combined* plane height, so the logical
415/// height is recovered through
416/// [`PixelFormat::image_height_from_combined`], which rejects a combined
417/// height no image height produces (721 for NV12, say) instead of rounding to
418/// a nearby one.
419///
420/// # Errors
421///
422/// [`Error::InvalidShape`] for a shape of the wrong rank, or a semi-planar
423/// combined height with no image height behind it.
424///
425/// # Example
426///
427/// ```
428/// use edgefirst_tensor::{image_dims_from_shape, PixelFormat};
429/// assert_eq!(image_dims_from_shape(PixelFormat::Rgba, &[480, 640, 4]).unwrap(), (640, 480));
430/// assert_eq!(image_dims_from_shape(PixelFormat::PlanarRgb, &[3, 480, 640]).unwrap(), (640, 480));
431/// assert_eq!(image_dims_from_shape(PixelFormat::Nv12, &[722, 640]).unwrap(), (640, 481));
432/// assert!(image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).is_err());
433/// ```
434pub fn image_dims_from_shape(format: PixelFormat, shape: &[usize]) -> Result<(usize, usize)> {
435    let wrong_rank = |expected: usize| {
436        Error::InvalidShape(format!(
437            "image_dims_from_shape: {format:?} describes a rank-{expected} allocation \
438             shape, got rank {} ({shape:?})",
439            shape.len()
440        ))
441    };
442    match format.layout() {
443        PixelLayout::Packed => {
444            if shape.len() != 3 {
445                return Err(wrong_rank(3));
446            }
447            Ok((shape[1], shape[0]))
448        }
449        PixelLayout::Planar => {
450            if shape.len() != 3 {
451                return Err(wrong_rank(3));
452            }
453            Ok((shape[2], shape[1]))
454        }
455        PixelLayout::SemiPlanar => {
456            if shape.len() != 2 {
457                return Err(wrong_rank(2));
458            }
459            let height = format.image_height_from_combined(shape[0]).ok_or_else(|| {
460                Error::InvalidShape(format!(
461                    "image_dims_from_shape: no {format:?} image height has combined plane \
462                     height {} (shape {shape:?})",
463                    shape[0]
464                ))
465            })?;
466            Ok((shape[1], height))
467        }
468    }
469}
470
471/// Per-plane DMA-BUF descriptor for external buffer import.
472///
473/// Owns a duplicated file descriptor plus optional stride and offset metadata.
474/// The fd is duplicated eagerly in [`new()`](Self::new) so that a bad fd is
475/// caught immediately. `import_image` consumes the descriptor and takes
476/// ownership of the duped fd — no further cleanup is needed by the caller.
477///
478/// # Examples
479///
480/// ```rust,no_run
481/// use edgefirst_tensor::PlaneDescriptor;
482/// use std::os::fd::BorrowedFd;
483///
484/// // SAFETY: fd 42 is hypothetical; real code must pass a valid fd.
485/// let pd = unsafe { PlaneDescriptor::new(BorrowedFd::borrow_raw(42)) }
486///     .unwrap()
487///     .with_stride(2048)
488///     .with_offset(0);
489/// ```
490#[cfg(unix)]
491pub struct PlaneDescriptor {
492    fd: OwnedFd,
493    stride: Option<usize>,
494    offset: Option<usize>,
495}
496
497#[cfg(unix)]
498impl PlaneDescriptor {
499    /// Create a new plane descriptor by duplicating the given file descriptor.
500    ///
501    /// The fd is duped immediately — a bad fd fails here rather than inside
502    /// `import_image`. The caller retains ownership of the original fd.
503    ///
504    /// # Errors
505    ///
506    /// Returns an error if the `dup()` syscall fails (e.g. invalid fd or
507    /// fd limit reached).
508    pub fn new(fd: std::os::fd::BorrowedFd<'_>) -> Result<Self> {
509        let owned = fd.try_clone_to_owned()?;
510        Ok(Self {
511            fd: owned,
512            stride: None,
513            offset: None,
514        })
515    }
516
517    /// Set the row stride in bytes (consuming builder).
518    pub fn with_stride(mut self, stride: usize) -> Self {
519        self.stride = Some(stride);
520        self
521    }
522
523    /// Set the plane offset in bytes (consuming builder).
524    pub fn with_offset(mut self, offset: usize) -> Self {
525        self.offset = Some(offset);
526        self
527    }
528
529    /// Consume the descriptor and return the owned file descriptor.
530    pub fn into_fd(self) -> OwnedFd {
531        self.fd
532    }
533
534    /// Row stride in bytes, if set.
535    pub fn stride(&self) -> Option<usize> {
536        self.stride
537    }
538
539    /// Plane offset in bytes, if set.
540    pub fn offset(&self) -> Option<usize> {
541        self.offset
542    }
543}
544
545/// A rectangular sub-region of a tensor's leading spatial frame, in pixels.
546///
547/// `Region` is the single rectangle type in the workspace: the argument to
548/// [`Tensor::view`], the source sampling window in the image crate's `Crop`,
549/// and the geometry the image backend lowers to a `glViewport` (a destination
550/// tile) or a sampling rectangle (a source). Coordinates are pixel/element
551/// units of the leading spatial axes; byte addressing is derived from the
552/// parent's row stride, not stored here.
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub struct Region {
555    pub x: usize,
556    pub y: usize,
557    pub width: usize,
558    pub height: usize,
559}
560
561impl Region {
562    /// Create a region at `(x, y)` spanning `width` × `height` pixels.
563    pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
564        Self {
565            x,
566            y,
567            width,
568            height,
569        }
570    }
571
572    /// True when the region lies fully within a `width` × `height` frame.
573    pub fn fits_within(&self, width: usize, height: usize) -> bool {
574        self.x.saturating_add(self.width) <= width && self.y.saturating_add(self.height) <= height
575    }
576}
577
578/// The parent image a [`view`](Tensor::view)/[`batch`](Tensor::batch) sub-region
579/// was carved from, snapshotted at the time the view was created.
580///
581/// A view shares the parent's `BufferIdentity` and addresses a sub-rectangle of
582/// it. The GL backend keys its EGLImage import on the **parent** geometry (so all
583/// sibling views of one buffer collapse to a single import) and renders each
584/// view as a `glViewport`+`glScissor` ROI at `(x, y, width, height)` within that
585/// parent — the view is render state, never a distinct import. `parent_width`/
586/// `parent_height` are the parent's logical pixel dimensions; `x`/`y` are this
587/// view's top-left origin within the parent (pixels). Nested views compose:
588/// the snapshot always names the **root** parent, with offsets accumulated.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub struct ViewOrigin {
591    /// Logical width of the root parent image, in pixels.
592    pub parent_width: usize,
593    /// Logical height of the root parent image, in pixels. For a `batch(n)` view
594    /// of an `[N, H, W, C]` tensor this is `N * H` (the tiles stack vertically in
595    /// the shared buffer).
596    pub parent_height: usize,
597    /// The parent's row stride in **bytes**. The GL backend keys its EGLImage
598    /// import/cache and pitch on this — NOT on the view's own `row_stride`, which
599    /// a single-row view sets tight (for map-span safety). Using the parent
600    /// stride keeps the import pitch parent-consistent so single-row and
601    /// multi-row sibling views collapse onto the same parent import.
602    pub parent_row_stride: usize,
603    /// This view's top-left x origin within the root parent, in pixels.
604    pub x: usize,
605    /// This view's top-left y origin within the root parent, in pixels.
606    pub y: usize,
607}
608
609// Declared through `ef_vocabulary!` (see `vocabulary.rs`): `.code()` is this
610// crate's wire numbering, mirrored by `protocol::dtype` and the C ABI's
611// `hal_dtype`. Both surfaces are generated from the same per-variant literal
612// below rather than re-typed by hand, and `dtype_codes_are_unchanged_by_the_macro`
613// in `tests/vocabulary.rs` pins them against the values already shipping.
614crate::ef_vocabulary! {
615    /// Element type discriminant for runtime type identification.
616    #[derive(Serialize, Deserialize)]
617    #[non_exhaustive]
618    pub enum DType {
619        U8 = 0, "u8", U8,
620        I8 = 1, "i8", I8,
621        U16 = 2, "u16", U16,
622        I16 = 3, "i16", I16,
623        U32 = 4, "u32", U32,
624        I32 = 5, "i32", I32,
625        U64 = 6, "u64", U64,
626        I64 = 7, "i64", I64,
627        F16 = 8, "f16", F16,
628        F32 = 9, "f32", F32,
629        F64 = 10, "f64", F64,
630    }
631    // `#[doc(hidden)]`: this module has to be `pub` for `protocol::dtype`'s
632    // `pub use` re-export below to compile, but it is emission plumbing, not
633    // a second documented API for the same eleven codes -- `protocol::dtype`
634    // is the canonical, documented path.
635    #[doc(hidden)]
636    pub mod dtype_wire;
637}
638
639impl DType {
640    /// Size of one element in bytes.
641    pub const fn size(&self) -> usize {
642        match self {
643            Self::U8 | Self::I8 => 1,
644            Self::U16 | Self::I16 | Self::F16 => 2,
645            Self::U32 | Self::I32 | Self::F32 => 4,
646            Self::U64 | Self::I64 | Self::F64 => 8,
647        }
648    }
649
650    /// Short type name (e.g., "u8", "f32", "f16"). Identical to
651    /// [`DType::as_str`]; kept as its own method for existing callers.
652    pub fn name(&self) -> &'static str {
653        self.as_str()
654    }
655}
656
657impl fmt::Display for DType {
658    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
659        f.write_str(self.name())
660    }
661}
662
663/// Map a static numeric type `T` to its `DType` discriminant, returning
664/// `None` for types that do not have a `DType` representation (e.g.
665/// user-defined wrappers in tests).
666///
667/// Runtime dtype of a static `Tensor<T>` element type — a safe `TypeId`
668/// allowlist of HAL's primitive numeric types (`Some` for `u8`..`i64` /
669/// `f16` / `f32` / `f64`, `None` otherwise). Used by the macOS IOSurface
670/// image constructors (FourCC / pixel-format lookup) and by the `Mem`
671/// backing's `alloc_zeroed` fast path (these types' `T::zero()` is the
672/// all-zeros bit pattern), so it is compiled on every target -- but only
673/// under `static`: every call site lives in `impl<T> Tensor<T>`, itself
674/// `#[cfg(not(edgefirst_tensor_dynamic))]`.
675#[cfg(not(edgefirst_tensor_dynamic))]
676pub(crate) fn dtype_of<T: 'static>() -> Option<DType> {
677    use std::any::TypeId;
678    let id = TypeId::of::<T>();
679    if id == TypeId::of::<u8>() {
680        Some(DType::U8)
681    } else if id == TypeId::of::<i8>() {
682        Some(DType::I8)
683    } else if id == TypeId::of::<u16>() {
684        Some(DType::U16)
685    } else if id == TypeId::of::<i16>() {
686        Some(DType::I16)
687    } else if id == TypeId::of::<u32>() {
688        Some(DType::U32)
689    } else if id == TypeId::of::<i32>() {
690        Some(DType::I32)
691    } else if id == TypeId::of::<u64>() {
692        Some(DType::U64)
693    } else if id == TypeId::of::<i64>() {
694        Some(DType::I64)
695    } else if id == TypeId::of::<half::f16>() {
696        Some(DType::F16)
697    } else if id == TypeId::of::<f32>() {
698        Some(DType::F32)
699    } else if id == TypeId::of::<f64>() {
700        Some(DType::F64)
701    } else {
702        None
703    }
704}
705
706// =============================================================================
707// Quantization metadata — type-gated to integer element types via sealed
708// `IntegerType` trait. Accessors on `Tensor<T>` only compile when `T` is
709// an integer type; calling them on `Tensor<f32>` / `Tensor<f16>` etc. is a
710// compile error, not a runtime one.
711// =============================================================================
712
713mod sealed {
714    pub trait Sealed {}
715    impl Sealed for u8 {}
716    impl Sealed for i8 {}
717    impl Sealed for u16 {}
718    impl Sealed for i16 {}
719    impl Sealed for u32 {}
720    impl Sealed for i32 {}
721    impl Sealed for u64 {}
722    impl Sealed for i64 {}
723    // f16 / f32 / f64 are sealed too (crate::lens::Element covers all
724    // eleven element types), but deliberately NOT given `IntegerType` below.
725    impl Sealed for half::f16 {}
726    impl Sealed for f32 {}
727    impl Sealed for f64 {}
728}
729
730/// Integer element types that may carry quantization metadata.
731///
732/// Sealed trait: implemented for `u8`, `i8`, `u16`, `i16`, `u32`, `i32`,
733/// `u64`, `i64`. Cannot be implemented downstream. Float element types
734/// (`half::f16`, `f32`, `f64`) are explicitly excluded — quantization
735/// metadata does not apply to float tensors per the edgefirst.json spec.
736pub trait IntegerType: sealed::Sealed {}
737impl IntegerType for u8 {}
738impl IntegerType for i8 {}
739impl IntegerType for u16 {}
740impl IntegerType for i16 {}
741impl IntegerType for u32 {}
742impl IntegerType for i32 {}
743impl IntegerType for u64 {}
744impl IntegerType for i64 {}
745
746/// Quantization parameters for an integer tensor.
747///
748/// Covers all four modes the edgefirst.json spec defines:
749///
750/// | Mode | `scale.len()` | `zero_point` | `axis` |
751/// |---|---|---|---|
752/// | Per-tensor symmetric | 1 | `None` | `None` |
753/// | Per-tensor asymmetric | 1 | `Some(len == 1)` | `None` |
754/// | Per-channel symmetric | >1 | `None` | `Some(c)` |
755/// | Per-channel asymmetric | >1 | `Some(len == scale.len())` | `Some(c)` |
756///
757/// The quantized storage type is carried on the parent [`Tensor<T>`]; this
758/// struct does not duplicate it. Construct via the four named constructors
759/// (the only public entry points); direct field mutation is not allowed so
760/// invalid combinations cannot be represented.
761///
762/// Dequantization formula:
763///
764/// ```text
765///   real_value = scale[c] × (quantized_value[c] - zero_point[c])
766/// ```
767///
768/// where `c` is the channel index (always `0` for per-tensor).
769#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
770pub struct Quantization {
771    /// Per-tensor: `vec![scale]`. Per-channel: `vec![scale_0, scale_1, ...]`.
772    #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
773    scale: Vec<f32>,
774
775    /// `None` means symmetric (zero-point is 0). `Some(vec)` must have the
776    /// same length as `scale`.
777    #[serde(
778        default,
779        deserialize_with = "deserialize_opt_scalar_or_vec_i32",
780        skip_serializing_if = "Option::is_none"
781    )]
782    zero_point: Option<Vec<i32>>,
783
784    /// Channel axis for per-channel quantization. `Some(_)` iff
785    /// `scale.len() > 1`. Validated against the parent tensor's shape at
786    /// `set_quantization()` time.
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    axis: Option<usize>,
789}
790
791/// Semantic mode discriminant for hot-path kernel dispatch.
792///
793/// Obtain via [`Quantization::mode`] once at kernel entry; never inside a
794/// pixel-level loop. The enum is borrow-based so the hot kernel receives
795/// the scales / zero-points as slices without reallocation.
796#[derive(Debug, Clone, Copy)]
797pub enum QuantMode<'a> {
798    PerTensorSymmetric {
799        scale: f32,
800    },
801    PerTensor {
802        scale: f32,
803        zero_point: i32,
804    },
805    PerChannelSymmetric {
806        scales: &'a [f32],
807        axis: usize,
808    },
809    PerChannel {
810        scales: &'a [f32],
811        zero_points: &'a [i32],
812        axis: usize,
813    },
814}
815
816impl Quantization {
817    /// Per-tensor symmetric (zero_point = 0).
818    pub fn per_tensor_symmetric(scale: f32) -> Self {
819        Self {
820            scale: vec![scale],
821            zero_point: None,
822            axis: None,
823        }
824    }
825
826    /// Per-tensor asymmetric — the most common runtime shape.
827    pub fn per_tensor(scale: f32, zero_point: i32) -> Self {
828        Self {
829            scale: vec![scale],
830            zero_point: Some(vec![zero_point]),
831            axis: None,
832        }
833    }
834
835    /// Per-channel symmetric. Errors on empty `scales`.
836    pub fn per_channel_symmetric(scales: Vec<f32>, axis: usize) -> Result<Self> {
837        if scales.is_empty() {
838            return Err(Error::QuantizationInvalid {
839                field: "scale.len",
840                expected: "non-empty per-channel scales".to_string(),
841                got: "length 0".to_string(),
842            });
843        }
844        Ok(Self {
845            scale: scales,
846            zero_point: None,
847            axis: Some(axis),
848        })
849    }
850
851    /// Per-channel asymmetric. Errors on length mismatch between `scales`
852    /// and `zero_points`, or empty arrays.
853    pub fn per_channel(scales: Vec<f32>, zero_points: Vec<i32>, axis: usize) -> Result<Self> {
854        if scales.is_empty() {
855            return Err(Error::QuantizationInvalid {
856                field: "scale.len",
857                expected: "non-empty per-channel scales".to_string(),
858                got: "length 0".to_string(),
859            });
860        }
861        if scales.len() != zero_points.len() {
862            return Err(Error::QuantizationInvalid {
863                field: "zero_point.len",
864                expected: format!("length matches scale ({})", scales.len()),
865                got: format!("length {}", zero_points.len()),
866            });
867        }
868        Ok(Self {
869            scale: scales,
870            zero_point: Some(zero_points),
871            axis: Some(axis),
872        })
873    }
874
875    /// Borrow-based dispatch view. Match once at kernel entry.
876    pub fn mode(&self) -> QuantMode<'_> {
877        match (self.scale.len(), self.zero_point.as_deref(), self.axis) {
878            (1, None, _) => QuantMode::PerTensorSymmetric {
879                scale: self.scale[0],
880            },
881            (1, Some(zps), _) => QuantMode::PerTensor {
882                scale: self.scale[0],
883                zero_point: zps.first().copied().unwrap_or(0),
884            },
885            (_, None, Some(axis)) => QuantMode::PerChannelSymmetric {
886                scales: &self.scale,
887                axis,
888            },
889            (_, Some(zps), Some(axis)) => QuantMode::PerChannel {
890                scales: &self.scale,
891                zero_points: zps,
892                axis,
893            },
894            // The `validate()` path prevents constructing a
895            // per-channel Quantization without an axis, so the remaining
896            // pattern is unreachable in practice. Fall back to
897            // per-tensor symmetric using scale[0] to avoid panicking in
898            // release; debug builds assert.
899            _ => {
900                debug_assert!(
901                    false,
902                    "Quantization::mode: per-channel without axis is unreachable"
903                );
904                QuantMode::PerTensorSymmetric {
905                    scale: self.scale.first().copied().unwrap_or(1.0),
906                }
907            }
908        }
909    }
910
911    /// Returns `true` for per-tensor quantization (`scale.len() == 1`).
912    pub fn is_per_tensor(&self) -> bool {
913        self.scale.len() == 1
914    }
915
916    /// Returns `true` for per-channel quantization (`scale.len() > 1`).
917    pub fn is_per_channel(&self) -> bool {
918        self.scale.len() > 1
919    }
920
921    /// Returns `true` for symmetric quantization (no zero-point, or
922    /// zero-point vector of all zeros).
923    pub fn is_symmetric(&self) -> bool {
924        match &self.zero_point {
925            None => true,
926            Some(zps) => zps.iter().all(|&z| z == 0),
927        }
928    }
929
930    /// Borrow the scale array. Length 1 for per-tensor; `num_channels` for
931    /// per-channel.
932    pub fn scale(&self) -> &[f32] {
933        &self.scale
934    }
935
936    /// Borrow the zero-point array. `None` for symmetric.
937    pub fn zero_point(&self) -> Option<&[i32]> {
938        self.zero_point.as_deref()
939    }
940
941    /// Channel axis for per-channel quantization. `None` for per-tensor.
942    pub fn axis(&self) -> Option<usize> {
943        self.axis
944    }
945
946    /// Validate against a target tensor shape. Runs in
947    /// `Tensor::set_quantization()`. Catches:
948    ///   - empty `scale` (reject — must declare at least one factor)
949    ///   - `zero_point` length inconsistent with `scale` (reject —
950    ///     per-tensor must have len 1, per-channel must match `scale.len`)
951    ///   - `axis >= shape.len()` (axis out of range)
952    ///   - `scale.len() != shape[axis]` for per-channel
953    ///   - per-channel without axis (reject)
954    ///   - per-tensor with redundant axis (reject)
955    ///
956    /// `static`-only: `Tensor::set_quantization` is `impl<T> Tensor<T>`,
957    /// itself `#[cfg(not(edgefirst_tensor_dynamic))]`. `dynamic`'s
958    /// `TensorDyn::set_quantization` (`dynamic_backend.rs`) sends the raw
959    /// scale/zero-point/axis over FFI and lets `libedgefirst_tensor.so`
960    /// validate them on its own (`static`) side instead of calling this
961    /// method locally.
962    #[cfg(not(edgefirst_tensor_dynamic))]
963    pub(crate) fn validate(&self, shape: &[usize]) -> Result<()> {
964        // `Quantization` is `Deserialize`, so malformed JSON like
965        // `{"scale": [], "zero_point": []}` could otherwise produce an
966        // ill-defined value that confuses `mode()` selection and the
967        // per-channel kernels' indexing.
968        if self.scale.is_empty() {
969            return Err(Error::QuantizationInvalid {
970                field: "scale.len",
971                expected: ">= 1".to_string(),
972                got: "0".to_string(),
973            });
974        }
975        if let Some(zps) = self.zero_point.as_ref() {
976            // Per-tensor: scale.len() == 1 and zero_point.len() must == 1.
977            // Per-channel: zero_point.len() must == scale.len().
978            let expected = if self.scale.len() == 1 {
979                1
980            } else {
981                self.scale.len()
982            };
983            if zps.len() != expected {
984                return Err(Error::QuantizationInvalid {
985                    field: "zero_point.len",
986                    expected: format!(
987                        "{expected} (matching {})",
988                        if self.scale.len() == 1 {
989                            "per-tensor scale"
990                        } else {
991                            "per-channel scale.len"
992                        }
993                    ),
994                    got: format!("length {}", zps.len()),
995                });
996            }
997        }
998
999        match (self.scale.len(), self.axis) {
1000            (1, None) => Ok(()),
1001            (1, Some(_)) => Err(Error::QuantizationInvalid {
1002                field: "per_tensor_redundant_axis",
1003                expected: "axis=None for per-tensor quantization".to_string(),
1004                got: format!("axis={:?}", self.axis),
1005            }),
1006            (_, None) => Err(Error::QuantizationInvalid {
1007                field: "per_channel_requires_axis",
1008                expected: format!(
1009                    "axis=Some(_) for per-channel quantization (scale.len={})",
1010                    self.scale.len()
1011                ),
1012                got: "axis=None".to_string(),
1013            }),
1014            (n, Some(axis)) => {
1015                if axis >= shape.len() {
1016                    return Err(Error::QuantizationInvalid {
1017                        field: "axis",
1018                        expected: format!("axis < tensor rank ({})", shape.len()),
1019                        got: format!("axis={axis}"),
1020                    });
1021                }
1022                if shape[axis] != n {
1023                    return Err(Error::QuantizationInvalid {
1024                        field: "scale.len",
1025                        expected: format!("length matches shape[{axis}] ({})", shape[axis]),
1026                        got: format!("length {n}"),
1027                    });
1028                }
1029                Ok(())
1030            }
1031        }
1032    }
1033}
1034
1035impl From<(f32, i32)> for Quantization {
1036    /// Convenience construction from a `(scale, zero_point)` tuple. Matches
1037    /// the legacy `QuantTuple` / `Quantization::new` calling convention so
1038    /// existing `(0.1, -128).into()` sites keep working.
1039    fn from((scale, zero_point): (f32, i32)) -> Self {
1040        Self::per_tensor(scale, zero_point)
1041    }
1042}
1043
1044fn deserialize_scalar_or_vec_f32<'de, D: serde::Deserializer<'de>>(
1045    de: D,
1046) -> std::result::Result<Vec<f32>, D::Error> {
1047    use serde::de::{self, Visitor};
1048    struct V;
1049    impl<'de> Visitor<'de> for V {
1050        type Value = Vec<f32>;
1051        // Only serde's error text reads this; no value flows from it.
1052        #[cfg_attr(test, mutants::skip)]
1053        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1054            f.write_str("f32 or array of f32")
1055        }
1056        fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
1057            Ok(vec![v as f32])
1058        }
1059        #[allow(clippy::cast_possible_truncation)]
1060        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1061            Ok(vec![v as f32])
1062        }
1063        #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1064        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1065            Ok(vec![v as f32])
1066        }
1067        fn visit_seq<A: de::SeqAccess<'de>>(
1068            self,
1069            mut seq: A,
1070        ) -> std::result::Result<Self::Value, A::Error> {
1071            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1072            while let Some(x) = seq.next_element::<f32>()? {
1073                out.push(x);
1074            }
1075            Ok(out)
1076        }
1077    }
1078    de.deserialize_any(V)
1079}
1080
1081fn deserialize_opt_scalar_or_vec_i32<'de, D: serde::Deserializer<'de>>(
1082    de: D,
1083) -> std::result::Result<Option<Vec<i32>>, D::Error> {
1084    use serde::de::{self, Visitor};
1085    struct V;
1086    impl<'de> Visitor<'de> for V {
1087        type Value = Option<Vec<i32>>;
1088        // Only serde's error text reads this; no value flows from it.
1089        #[cfg_attr(test, mutants::skip)]
1090        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1091            f.write_str("null, i32, or array of i32")
1092        }
1093        // `Ok(None)` is `Ok(Default::default())`, the only replacement.
1094        #[cfg_attr(test, mutants::skip)]
1095        fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1096            Ok(None)
1097        }
1098        // As `visit_none`.
1099        #[cfg_attr(test, mutants::skip)]
1100        fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1101            Ok(None)
1102        }
1103        fn visit_some<D2: serde::Deserializer<'de>>(
1104            self,
1105            de: D2,
1106        ) -> std::result::Result<Self::Value, D2::Error> {
1107            struct Inner;
1108            impl<'de> Visitor<'de> for Inner {
1109                type Value = Vec<i32>;
1110                // Only serde's error text reads this; no value flows from it.
1111                #[cfg_attr(test, mutants::skip)]
1112                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1113                    f.write_str("i32 or array of i32")
1114                }
1115                #[allow(clippy::cast_possible_truncation)]
1116                fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1117                    Ok(vec![v as i32])
1118                }
1119                #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1120                fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1121                    Ok(vec![v as i32])
1122                }
1123                fn visit_seq<A: de::SeqAccess<'de>>(
1124                    self,
1125                    mut seq: A,
1126                ) -> std::result::Result<Self::Value, A::Error> {
1127                    let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1128                    while let Some(x) = seq.next_element::<i32>()? {
1129                        out.push(x);
1130                    }
1131                    Ok(out)
1132                }
1133            }
1134            de.deserialize_any(Inner).map(Some)
1135        }
1136        #[allow(clippy::cast_possible_truncation)]
1137        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1138            Ok(Some(vec![v as i32]))
1139        }
1140        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1141        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1142            Ok(Some(vec![v as i32]))
1143        }
1144        fn visit_seq<A: de::SeqAccess<'de>>(
1145            self,
1146            mut seq: A,
1147        ) -> std::result::Result<Self::Value, A::Error> {
1148            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1149            while let Some(x) = seq.next_element::<i32>()? {
1150                out.push(x);
1151            }
1152            Ok(Some(out))
1153        }
1154    }
1155    de.deserialize_option(V)
1156}
1157
1158/// Uniform guard for mutable access through a read-only map. Every
1159/// `TensorMap` backend calls this from `as_mut_slice`/`deref_mut` so a
1160/// `map_read()` misuse fails identically on all platforms instead of
1161/// passing on tolerant ones and exploding only on Android.
1162#[inline]
1163pub(crate) fn assert_map_writable(writable: bool, backend: &str) {
1164    assert!(
1165        writable,
1166        "{backend} map is read-only (obtained via map_read()/CpuAccess::Read) — \
1167         use map_mut() or map_write() for mutable access"
1168    );
1169}
1170
1171crate::ef_vocabulary! {
1172    /// Declared CPU involvement for a tensor, chosen at allocation.
1173    ///
1174    /// The declaration covers **all** CPU access to the buffer — the
1175    /// caller's maps, pins and sync brackets, and the HAL's own CPU work
1176    /// (the CPU image backend, codec decodes, decoder outputs). Hardware
1177    /// access (ISP, codec, GPU, NPU) needs no declaration. It selects the CPU
1178    /// usage/mapping mode at allocation (write-combined for `Write`, cached
1179    /// for `Read`) and, on Android, pins the layout linear (vendor tile
1180    /// compression requires `None`).
1181    ///
1182    /// A CPU map, pin or sync whose direction the declaration does not cover
1183    /// fails with [`Error::CpuAccessDenied`] on every backend.
1184    ///
1185    /// On Android the `ReadWrite` default asks gralloc for
1186    /// `CPU_READ_OFTEN | CPU_WRITE_OFTEN`, which may give linear, CPU-cached
1187    /// memory. Pass `None` for a buffer that is never mapped on the CPU, so
1188    /// gralloc can keep a tiled or compressed allocation. Linux DMA-BUF
1189    /// allocation does not depend on the declaration.
1190    ///
1191    /// [`CpuAccess::code`] is the wire code the C header's `ef_cpu_access`
1192    /// enumerators are asserted against.
1193    #[derive(Default)]
1194    pub enum CpuAccess {
1195        /// Hardware-only buffer: no CPU access. Required for vendor tile
1196        /// compression on platforms with compressed layouts.
1197        None = 0, "none", NONE,
1198        /// CPU reads only (verification, CPU consumers) — cached mapping.
1199        Read = 1, "read", READ,
1200        /// CPU writes only (decode targets) — write-combined mapping where
1201        /// the platform supports it.
1202        Write = 2, "write", WRITE,
1203        /// CPU reads and writes (the default).
1204        #[default]
1205        ReadWrite = 3, "readwrite", READ_WRITE,
1206    }
1207    // Hidden like `dtype_wire`: emission plumbing, not a second documented API.
1208    #[doc(hidden)]
1209    pub mod cpu_access_wire;
1210}
1211
1212impl CpuAccess {
1213    /// The declaration with exactly the given directions.
1214    pub(crate) const fn from_directions(read: bool, write: bool) -> CpuAccess {
1215        match (read, write) {
1216            (true, true) => CpuAccess::ReadWrite,
1217            (true, false) => CpuAccess::Read,
1218            (false, true) => CpuAccess::Write,
1219            (false, false) => CpuAccess::None,
1220        }
1221    }
1222
1223    /// Whether this declaration includes CPU reads.
1224    pub fn reads(self) -> bool {
1225        matches!(self, CpuAccess::Read | CpuAccess::ReadWrite)
1226    }
1227
1228    /// Whether this declaration includes CPU writes.
1229    pub fn writes(self) -> bool {
1230        matches!(self, CpuAccess::Write | CpuAccess::ReadWrite)
1231    }
1232
1233    /// Whether this declaration covers `requested` (every direction the
1234    /// request needs is declared).
1235    pub fn covers(self, requested: CpuAccess) -> bool {
1236        (!requested.reads() || self.reads()) && (!requested.writes() || self.writes())
1237    }
1238
1239    /// The declaration covering every direction of both `self` and `other`.
1240    ///
1241    /// ```
1242    /// use edgefirst_tensor::CpuAccess;
1243    /// assert_eq!(CpuAccess::Read.union(CpuAccess::Write), CpuAccess::ReadWrite);
1244    /// assert_eq!(CpuAccess::None.union(CpuAccess::Write), CpuAccess::Write);
1245    /// ```
1246    pub fn union(self, other: CpuAccess) -> CpuAccess {
1247        Self::from_directions(
1248            self.reads() || other.reads(),
1249            self.writes() || other.writes(),
1250        )
1251    }
1252
1253    /// The declaration covering only the directions both `self` and `other`
1254    /// cover.
1255    ///
1256    /// ```
1257    /// use edgefirst_tensor::CpuAccess;
1258    /// assert_eq!(CpuAccess::ReadWrite.intersection(CpuAccess::Write), CpuAccess::Write);
1259    /// assert_eq!(CpuAccess::Read.intersection(CpuAccess::Write), CpuAccess::None);
1260    /// ```
1261    pub fn intersection(self, other: CpuAccess) -> CpuAccess {
1262        Self::from_directions(
1263            self.reads() && other.reads(),
1264            self.writes() && other.writes(),
1265        )
1266    }
1267
1268    /// `Ok` when this declaration covers `requested`, otherwise
1269    /// [`Error::CpuAccessDenied`] naming both.
1270    ///
1271    /// ```
1272    /// use edgefirst_tensor::{CpuAccess, Error};
1273    /// assert!(CpuAccess::ReadWrite.require(CpuAccess::Write).is_ok());
1274    /// assert!(matches!(
1275    ///     CpuAccess::Read.require(CpuAccess::Write),
1276    ///     Err(Error::CpuAccessDenied { .. })
1277    /// ));
1278    /// ```
1279    pub fn require(self, requested: CpuAccess) -> Result<()> {
1280        if self.covers(requested) {
1281            Ok(())
1282        } else {
1283            Err(Error::CpuAccessDenied {
1284                declared: self,
1285                requested,
1286            })
1287        }
1288    }
1289
1290    /// [`require`](Self::require) for a map, pin or sync, where
1291    /// `CpuAccess::None` is no direction at all: [`Error::InvalidArgument`]
1292    /// naming `what`.
1293    pub(crate) fn require_direction(self, requested: CpuAccess, what: &str) -> Result<()> {
1294        if requested == CpuAccess::None {
1295            return Err(Error::InvalidArgument(format!(
1296                "{what}: CpuAccess::None is not a CPU access direction"
1297            )));
1298        }
1299        self.require(requested)
1300    }
1301}
1302
1303/// Requested tile-compression behavior for an image allocation (set via
1304/// [`ImageDesc::with_compression`]).
1305///
1306/// Vendor GPUs store textures in proprietary compressed tile layouts
1307/// (UBWC, AFBC, PVRIC, DCC) that cut memory bandwidth; eligibility
1308/// requires a hardware-only buffer ([`CpuAccess::None`]) because CPU
1309/// mapping pins the layout linear. The request records best knowledge on
1310/// the tensor ([`Tensor::compression`]); it never changes what bytes a
1311/// consumer sees through the GPU/NPU.
1312#[non_exhaustive]
1313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1314pub enum Compression {
1315    /// Let the platform use its native scheme when the format is
1316    /// eligible; otherwise allocate linear and count the fallback
1317    /// ([`compression_fallback_count`]). The right default for pipelines
1318    /// that want the bandwidth win without portability failures.
1319    Any,
1320    /// Require one specific scheme: allocation fails with
1321    /// [`Error::InvalidArgument`] when the device's scheme differs and
1322    /// [`Error::NotImplemented`] on platforms without vendor tile
1323    /// compression. For consumers whose ABI names a layout (e.g. a
1324    /// QNN context binary declaring UBWC inputs).
1325    Scheme(CompressionScheme),
1326}
1327
1328// Declared through `ef_vocabulary!` (see `vocabulary.rs`). This vocabulary
1329// gained a wire numbering when `ef_tensor_compression` was added: the
1330// dynamic backend reads a scheme back across the C boundary, so the enum
1331// needs codes that cannot drift from the C enumerators. Hand-writing that
1332// mapping in `edgefirst-tensor-capi` was the alternative and it is strictly
1333// worse -- `#[non_exhaustive]` makes a cross-crate `match` require a
1334// wildcard arm, so a scheme added here would silently map to "linear"
1335// there. Generated from one declaration, it cannot.
1336//
1337// Code 0 is deliberately unassigned and reserved for "linear" -- the state
1338// this crate spells `Option::None`, which has no variant to carry a code of
1339// its own. The C side names it `EF_COMPRESSION_NONE`.
1340crate::ef_vocabulary! {
1341    /// Vendor tile-compression schemes the HAL recognizes and records.
1342    ///
1343    /// Unrecognized platforms record `None` (linear) — there is deliberately
1344    /// no `Unknown` variant; a scheme is only recorded when the vendor is
1345    /// positively identified.
1346    #[non_exhaustive]
1347    pub enum CompressionScheme {
1348        /// Qualcomm Adreno Universal Bandwidth Compression.
1349        Ubwc = 1, "ubwc", UBWC,
1350        /// Arm Mali/Immortalis Framebuffer Compression.
1351        Afbc = 2, "afbc", AFBC,
1352        /// Imagination PowerVR Image Compression (Google Tensor G5+).
1353        Pvric = 3, "pvric", PVRIC,
1354        /// Samsung Xclipse (AMD RDNA) Delta Color Compression.
1355        Dcc = 4, "dcc", DCC,
1356    }
1357    // `#[doc(hidden)]` for the same reason `dtype_wire` is: emission
1358    // plumbing for `const`-only consumers, not a second documented API.
1359    #[doc(hidden)]
1360    pub mod compression_wire;
1361}
1362
1363crate::ef_vocabulary! {
1364    /// Whether a tensor's memory is known to be physically contiguous
1365    /// ([`Tensor::contiguity`]).
1366    ///
1367    /// Capture and other DMA engines without an IOMMU (the i.MX 8M Plus ISI,
1368    /// the vvcam ISP) can only write into physically contiguous memory.
1369    /// Only memory this crate allocated from a Linux DMA heap is known
1370    /// either way: the CMA heap is contiguous and the system heap is not.
1371    /// An imported fd, and every other kind of memory, is `Unknown`.
1372    #[non_exhaustive]
1373    pub enum Contiguity {
1374        /// Not known: an imported fd, or memory not allocated from a DMA heap.
1375        Unknown = 0, "unknown", UNKNOWN,
1376        /// Allocated from the CMA heap.
1377        Contiguous = 1, "contiguous", CONTIGUOUS,
1378        /// Allocated from the system heap.
1379        NonContiguous = 2, "non_contiguous", NON_CONTIGUOUS,
1380    }
1381    #[doc(hidden)]
1382    pub mod contiguity_wire;
1383}
1384
1385/// Count of image allocations that requested [`Compression::Any`] but
1386/// resolved to a linear layout (ineligible format/dtype, unrecognized
1387/// vendor, or a platform without vendor tile compression). Read via
1388/// [`compression_fallback_count`].
1389static COMPRESSION_FALLBACKS: AtomicU64 = AtomicU64::new(0);
1390
1391/// Number of [`Compression::Any`] requests since process start that
1392/// resolved to a linear layout instead of a vendor tile scheme. A
1393/// steady-state pipeline holds this flat after warmup; growth means an
1394/// allocation path keeps requesting compression it never gets.
1395pub fn compression_fallback_count() -> u64 {
1396    COMPRESSION_FALLBACKS.load(Ordering::Relaxed)
1397}
1398
1399/// Record a `Compression::Any` request resolving linear.
1400///
1401/// `static`-only: the sole caller is `Tensor::image_desc` (`impl<T>
1402/// Tensor<T>`), itself `#[cfg(not(edgefirst_tensor_dynamic))]`.
1403/// [`compression_fallback_count`] stays available under `dynamic` too --
1404/// it just always reads 0 there.
1405#[cfg(not(edgefirst_tensor_dynamic))]
1406pub(crate) fn note_compression_fallback(detail: &str) {
1407    COMPRESSION_FALLBACKS.fetch_add(1, Ordering::Relaxed);
1408    log::debug!("compression request fell back to linear: {detail}");
1409}
1410
1411/// Whether this platform can allocate `(format, dtype)` images in a
1412/// vendor tile-compressed layout. `true` requires an Android build, an
1413/// eligible format (RGBA8888 `u8`/`i8` initially), and a positively
1414/// identified GPU vendor; everywhere else the answer is `false` and
1415/// [`Compression::Any`] requests fall back to linear.
1416pub fn compression_support(format: PixelFormat, dtype: DType) -> bool {
1417    #[cfg(target_os = "android")]
1418    {
1419        crate::ahardwarebuffer_layout::compression_eligible(format, dtype)
1420            && crate::ahardwarebuffer::device_compression_scheme().is_some()
1421    }
1422    #[cfg(not(target_os = "android"))]
1423    {
1424        let _ = (format, dtype);
1425        false
1426    }
1427}
1428
1429/// Declarative image-allocation request — the full-featured front door
1430/// for image tensors ([`TensorDyn::image_desc`] and the image crate's
1431/// `ImageProcessor::create_image_desc`).
1432///
1433/// The classic constructors (`image`, `create_image`) cover the common
1434/// cases; the desc carries the optional requests — today the
1435/// [`Compression`] request — without another constructor-parameter
1436/// sweep. Fields are private and the builders consume/return by value,
1437/// so future options are non-breaking.
1438///
1439/// ```
1440/// use edgefirst_tensor::{Compression, CpuAccess, DType, ImageDesc, PixelFormat};
1441/// let desc = ImageDesc::new(640, 640, PixelFormat::Rgba, DType::U8)
1442///     .with_access(CpuAccess::None)
1443///     .with_compression(Compression::Any);
1444/// ```
1445#[derive(Debug, Clone)]
1446pub struct ImageDesc {
1447    width: usize,
1448    height: usize,
1449    format: PixelFormat,
1450    dtype: DType,
1451    memory: Option<TensorMemory>,
1452    access: CpuAccess,
1453    compression: Option<Compression>,
1454    contiguous: bool,
1455}
1456
1457impl ImageDesc {
1458    /// A new image request: auto-selected memory, [`CpuAccess::ReadWrite`],
1459    /// no compression request.
1460    pub fn new(width: usize, height: usize, format: PixelFormat, dtype: DType) -> Self {
1461        Self {
1462            width,
1463            height,
1464            format,
1465            dtype,
1466            memory: None,
1467            access: CpuAccess::default(),
1468            compression: None,
1469            contiguous: false,
1470        }
1471    }
1472
1473    /// Request a specific memory backing (`None` = auto-select).
1474    pub fn with_memory(mut self, memory: Option<TensorMemory>) -> Self {
1475        self.memory = memory;
1476        self
1477    }
1478
1479    /// Declare the CPU access (see [`CpuAccess`]). Any declaration other
1480    /// than `None` makes a compression request invalid, so a compression
1481    /// request must also call `with_access(CpuAccess::None)`.
1482    pub fn with_access(mut self, access: CpuAccess) -> Self {
1483        self.access = access;
1484        self
1485    }
1486
1487    /// Request a tile-compressed layout (see [`Compression`]).
1488    pub fn with_compression(mut self, compression: Compression) -> Self {
1489        self.compression = Some(compression);
1490        self
1491    }
1492
1493    /// Require physically contiguous memory (see [`Contiguity`]).
1494    ///
1495    /// The image is allocated from the Linux CMA DMA heap only. Allocation
1496    /// fails, naming the heap, when there is no CMA heap or it cannot fit
1497    /// the image, instead of falling back to the system heap or to another
1498    /// kind of memory. Memory must be auto-selected or
1499    /// [`TensorMemory::DmaBuf`]. Other platforms return
1500    /// [`Error::NotImplemented`].
1501    pub fn with_contiguous(mut self, contiguous: bool) -> Self {
1502        self.contiguous = contiguous;
1503        self
1504    }
1505
1506    /// Requested width in pixels.
1507    pub fn width(&self) -> usize {
1508        self.width
1509    }
1510
1511    /// Requested height in pixels.
1512    pub fn height(&self) -> usize {
1513        self.height
1514    }
1515
1516    /// Requested pixel format.
1517    pub fn format(&self) -> PixelFormat {
1518        self.format
1519    }
1520
1521    /// Requested element type.
1522    pub fn dtype(&self) -> DType {
1523        self.dtype
1524    }
1525
1526    /// Requested memory backing (`None` = auto-select).
1527    pub fn memory(&self) -> Option<TensorMemory> {
1528        self.memory
1529    }
1530
1531    /// Declared CPU access.
1532    pub fn access(&self) -> CpuAccess {
1533        self.access
1534    }
1535
1536    /// The compression request, if any.
1537    pub fn compression(&self) -> Option<Compression> {
1538        self.compression
1539    }
1540
1541    /// Whether physically contiguous memory is required.
1542    pub fn contiguous(&self) -> bool {
1543        self.contiguous
1544    }
1545}
1546
1547/// What kind of system key an identity was derived from. Participates in the
1548/// id so that, say, inode 7 and IOSurfaceID 7 cannot collide.
1549///
1550/// Invariant every variant must uphold: the key passed to
1551/// [`BufferIdentity::derived`] is unique among live buffers of that kind
1552/// **that own an allocation**. There is no "no identity" case — where no
1553/// system-wide key exists a process-local one does (a pointer, a GL name),
1554/// and a process-local key is exactly as sound as a system-wide one:
1555/// pointer/name reuse after free is the same ABA hazard as inode reuse, and
1556/// is mitigated the same way, by the retaining cache (see the design spec's
1557/// Task 6).
1558///
1559/// The allocation qualifier is load-bearing, not a hedge. A zero-element
1560/// tensor (`[0, num_protos]`, the tracker's "no detections this frame"
1561/// sentinel — see [`crate::mem`]) owns no allocation, so its `Vec` yields the
1562/// dangling well-aligned pointer `align_of::<T>()`. Every zero-element
1563/// `Tensor<f32>` in a process therefore shares one identity, and can even
1564/// collide across kinds with a small integer key from another variant.
1565///
1566/// That is safe rather than tolerated: an identity exists to answer "are
1567/// these the same bytes?", and buffers with no bytes have nothing to alias.
1568/// Nor can they reach an import cache. There are four GPU import paths —
1569/// a dma-buf fd on Linux, an IOSurface ref on macOS, an `AHardwareBuffer` on
1570/// Android, and an `ID3D11Texture2D` on Windows — and each takes only its own
1571/// object, so a tensor with no allocation reaches none of them. The Windows
1572/// path additionally keys on the texture the import itself holds a reference
1573/// to, and `edgefirst-image` **refuses** any texture whose identity is not
1574/// `D3d11Texture` over that same pointer — in every build, and on every
1575/// import including one served from its cache
1576/// (`GlPlatform::validate_import_identity`). That refusal exists because the
1577/// rule was once only written
1578/// down: the dynamic backend handed the image crate `HostPtr` identities over
1579/// recyclable handle addresses, and converts silently rendered into dropped
1580/// textures. Do not "fix" the zero-element case by minting a distinct id per
1581/// empty tensor: that reintroduces a counter, which is the state this whole
1582/// design exists to remove.
1583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1584pub enum IdentityKind {
1585    /// `(st_dev, st_ino)` of a dma-buf or memfd. Survives `dup`, and is the
1586    /// same value in every process that holds the buffer.
1587    DmaBuf,
1588    Shm,
1589    /// `IOSurfaceGetID`.
1590    IoSurface,
1591    /// `AHardwareBuffer_getId`, API 31+.
1592    AHardwareBufferId,
1593    /// The `AHardwareBuffer*` pointer, pre-API-31 where no id exists.
1594    /// Process-local: unique among live buffers here, meaningless elsewhere.
1595    AHardwareBufferPtr,
1596    /// An `ID3D11Texture2D*`. Process-local, like `AHardwareBufferPtr`, and
1597    /// the same pointer for every kind of texture tensor: one the HAL
1598    /// allocated, one wrapped from a caller's pointer, and one opened from a
1599    /// shared NT handle all key on the texture object they hold.
1600    ///
1601    /// **Both backends derive it from the same pointer, so their ids are
1602    /// equal byte for byte.** The static backend reads the `ComPtr` it owns
1603    /// (`d3d11::texture::tex_key`); the dynamic backend reads the same
1604    /// pointer back across the ABI with `ef_tensor_d3d11_texture`
1605    /// (`TensorDyn::derive_identity`). That equality is the point, not a
1606    /// coincidence: [`BufferIdentity::derived`] exists so two independently
1607    /// linked copies of this crate name one buffer identically, and
1608    /// `edgefirst-image` links the dynamic backend inside every `-capi` leaf
1609    /// while its own tests link the static one.
1610    ///
1611    /// **An `ef_tensor` handle's address must never be the key.** It is not a
1612    /// buffer at all, nothing the cache holds keeps it alive, and this
1613    /// library's allocator hands a freed handle's address to the very next
1614    /// one it mints — measured at 400 texture handles over 4 distinct
1615    /// addresses. The dynamic backend did exactly that on Windows until
1616    /// 2026-09-04, and a convert whose destination key collided rendered into
1617    /// the dropped texture while returning success.
1618    ///
1619    /// A handle import therefore does **not** compare equal to the tensor it
1620    /// was exported from, nor to a second import of the same handle:
1621    /// `OpenSharedResource1` mints a fresh `ID3D11Texture2D` per open. Keying
1622    /// on a handle value would make those compare equal, and would be unsound:
1623    /// Windows recycles a closed handle value straight into the next
1624    /// `DuplicateHandle`, so a consumer that caches on this id past the
1625    /// producing tensor's lifetime -- as the image crate's EGLImage cache
1626    /// does -- could serve one texture's entry for a later, unrelated import.
1627    /// A COM object's address cannot be recycled that way: a cache that holds
1628    /// a reference to the texture keeps the address taken for as long as its
1629    /// entry can be found. The cost is a cache miss per re-import instead of
1630    /// a wrong image.
1631    D3d11Texture,
1632    /// A host mapping address. Process-local.
1633    HostPtr,
1634    /// A GL buffer name. Valid only in the context that created it.
1635    Pbo,
1636}
1637
1638/// Unique identity for a tensor's underlying buffer.
1639///
1640/// Created fresh on every buffer allocation or import. The `id` is a cache
1641/// key, `kind` records what system key (if any) it was derived from, and the
1642/// `guard` is an `Arc<()>` whose weak references allow downstream caches to
1643/// detect when the buffer has been dropped.
1644#[derive(Debug, Clone)]
1645pub struct BufferIdentity {
1646    id: u64,
1647    kind: IdentityKind,
1648    guard: Arc<()>,
1649}
1650
1651impl BufferIdentity {
1652    /// Derive an identity from a key the operating system already guarantees.
1653    ///
1654    /// Deliberately NOT a counter: two independently-linked copies of this
1655    /// crate must derive the same id for the same buffer, or a consumer's
1656    /// import cache misses every frame. See the design spec, "The core finding".
1657    pub fn derived(kind: IdentityKind, key: u64) -> Self {
1658        // Mix the discriminant into the high bits so different kinds with the
1659        // same numeric key never collide.
1660        let id = ((kind as u64) << 56) ^ key;
1661        Self {
1662            id,
1663            kind,
1664            guard: Arc::new(()),
1665        }
1666    }
1667
1668    /// Unique identifier for this buffer. Changes when the buffer changes.
1669    pub fn id(&self) -> u64 {
1670        self.id
1671    }
1672
1673    /// What kind of system key this identity was derived from.
1674    pub fn kind(&self) -> IdentityKind {
1675        self.kind
1676    }
1677
1678    /// Returns a weak reference to the buffer guard. Goes dead when the
1679    /// owning Tensor is dropped (and no clones remain).
1680    pub fn weak(&self) -> Weak<()> {
1681        Arc::downgrade(&self.guard)
1682    }
1683}
1684
1685// `static`-only from here through `fs_magic` below: every use site lives in
1686// `impl<T> TensorStorage<T>` (`from_fd`'s DMA-BUF-vs-tmpfs classification),
1687// itself `#[cfg(not(edgefirst_tensor_dynamic))]`. `dynamic`'s `from_fd`
1688// (`dynamic_backend.rs`) drives `ef_tensor_builder_wrap` instead, which
1689// does this same classification inside `libedgefirst_tensor.so`.
1690#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
1691use nix::sys::stat::{major, minor};
1692
1693/// Filesystem magic of the internal `dma_buf` mount, from
1694/// `include/uapi/linux/magic.h` (`DMA_BUF_MAGIC`, "DMAB").
1695///
1696/// This is stable UAPI and is the only reliable way to recognize a DMA-BUF
1697/// fd — see [`TensorStorage::from_fd`] for why `st_dev` cannot be used.
1698#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
1699const DMA_BUF_MAGIC: u32 = 0x444d_4142;
1700
1701/// Filesystem magic of tmpfs, from `include/uapi/linux/magic.h`
1702/// (`TMPFS_MAGIC`). Covers both POSIX `shm_open` segments under `/dev/shm`
1703/// and anonymous `memfd_create` files.
1704#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
1705const TMPFS_MAGIC: u32 = 0x0102_1994;
1706
1707/// Normalize a raw `fstatfs` `f_type` to the 32-bit unsigned magic that
1708/// `include/uapi/linux/magic.h` documents.
1709///
1710/// The width and signedness of `f_type` are target-dependent: `__fsword_t`
1711/// on Linux/gnu (`i64` on 64-bit, `i32` on 32-bit targets such as armv7,
1712/// i686 and aarch64-ilp32), `c_int` on uclibc, `c_ulong` on musl, and
1713/// `c_uint` on s390x. Where it is signed and 32 bits wide, widening it
1714/// sign-extends every magic with bit 31 set — hugetlbfs (`0x958458f6`),
1715/// btrfs (`0x9123683e`), f2fs (`0xf2f52010`) — which would make the value
1716/// reported by [`Error::UnknownBufferType`] impossible to find in the
1717/// header the docs point callers at.
1718///
1719/// Every magic is a 32-bit quantity, so truncating back to `u32` recovers
1720/// the true value from all four representations and loses nothing.
1721#[cfg(all(target_os = "linux", not(edgefirst_tensor_dynamic)))]
1722const fn fs_magic(raw: i64) -> u32 {
1723    raw as u32
1724}
1725
1726/// The operations every memory backend implements.
1727///
1728/// This is the seam that lets [`Tensor<T>`] hide which backend is in play.
1729/// The implementors are `DmaTensor` (Linux DMA-BUF), `IoSurfaceTensor`
1730/// (macOS/iOS), `AHardwareBufferTensor` (Android), `ShmTensor`, `MemTensor`,
1731/// and [`PboTensor`]; `TensorStorage<T>` dispatches to whichever is active.
1732/// Backend types are not public — allocate a [`Tensor<T>`] or [`TensorDyn`]
1733/// and call these methods through it.
1734///
1735/// Import the trait to get `shape`, `size`, `map`, `clone_fd`,
1736/// `buffer_identity`, and the zero-copy sub-region `view` on a tensor value.
1737/// `Tensor::view` and `Tensor::batch` route through [`TensorTrait::view`], so
1738/// each backend's identity-sharing rule lives in exactly one place.
1739///
1740/// [`TensorDyn`]: crate::TensorDyn
1741pub trait TensorTrait<T>: Send + Sync
1742where
1743    T: Num + Clone + fmt::Debug,
1744{
1745    /// Create a new tensor with the given shape and optional name. If no name
1746    /// is given, a random name will be generated.
1747    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
1748    where
1749        Self: Sized;
1750
1751    #[cfg(unix)]
1752    /// Import an existing buffer as a tensor, taking ownership of its file
1753    /// descriptor. The buffer is adopted in place — no bytes are copied.
1754    ///
1755    /// The backend is **detected**, not chosen: the fd already belongs to a
1756    /// buffer type, and this call's job is to recognize which. On Linux that
1757    /// is decided by the fd's filesystem magic, which is stable UAPI
1758    /// (`include/uapi/linux/magic.h`):
1759    ///
1760    /// | Filesystem | Magic | Resulting [`TensorMemory`] |
1761    /// |------------|-------|----------------------------|
1762    /// | `dma_buf` | `DMA_BUF_MAGIC` (`0x444d4142`) | [`TensorMemory::DmaBuf`] |
1763    /// | `tmpfs` (`/dev/shm` **and** `memfd`) | `TMPFS_MAGIC` (`0x01021994`) | [`TensorMemory::Shm`] |
1764    /// | anything else | — | *rejected* — see Errors |
1765    ///
1766    /// Both supported types are identified **positively**. An unrecognized
1767    /// filesystem is an error, never a fallback to shared memory: the wrong
1768    /// branch does not fail loudly (a DMA-BUF is mmap-able, so it would
1769    /// import as a perfectly functional tensor that merely isn't DMA, and a
1770    /// pipe would import as a zero-length one), so guessing would trade a
1771    /// clear error here for silent loss of zero-copy far downstream.
1772    ///
1773    /// The device number is deliberately **not** consulted. `dma_buf` files
1774    /// live on an internal kernel mount whose `st_dev` comes from
1775    /// `get_anon_bdev()` — an IDA shared with every other anonymous
1776    /// pseudo-filesystem and allocated in boot order — so the minor a
1777    /// DMA-BUF lands on varies by kernel build and is not part of any ABI.
1778    ///
1779    /// On non-Linux Unix (macOS/iOS/Android) there is no fd-based DMA import;
1780    /// the fd is always adopted as [`TensorMemory::Shm`].
1781    ///
1782    /// # Arguments
1783    ///
1784    /// * `fd` - Owned descriptor for the buffer to import. Ownership
1785    ///   transfers to the returned tensor and the fd is closed on drop; pass
1786    ///   [`clone_fd`](TensorTrait::clone_fd) output to keep your own handle.
1787    /// * `shape` - Logical shape to interpret the buffer with. Must describe
1788    ///   no more elements than the buffer holds.
1789    /// * `name` - Optional name; a random one is generated when `None`.
1790    ///
1791    /// # Returns
1792    ///
1793    /// A tensor sharing the imported buffer's memory, whose
1794    /// [`memory()`](TensorTrait::memory) reports the detected backend.
1795    ///
1796    /// # Errors
1797    ///
1798    /// * [`Error::UnknownBufferType`] - the fd is on a filesystem that is
1799    ///   neither `dma_buf` nor `tmpfs`, so its buffer type cannot be
1800    ///   determined. Carries the observed `fstatfs` magic as a `u32`,
1801    ///   normalized so it can be looked up in `magic.h` directly on both
1802    ///   32- and 64-bit targets. Typical causes: a regular file, a pipe or
1803    ///   socket, or a `MFD_HUGETLB` memfd (hugetlbfs, not tmpfs). Linux only.
1804    /// * [`Error::UnknownDeviceType`] - the fd's `st_dev` major is non-zero,
1805    ///   i.e. it lives on a real block device rather than an anonymous or
1806    ///   in-memory filesystem. Linux only.
1807    /// * [`Error::InvalidSize`] - `shape` is empty or describes zero
1808    ///   elements.
1809    /// * [`Error::NixError`] - `fstat`, `fstatfs`, or `mmap` failed on the
1810    ///   descriptor.
1811    ///
1812    /// # Examples
1813    ///
1814    /// ```no_run
1815    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
1816    ///
1817    /// # fn main() -> edgefirst_tensor::Result<()> {
1818    /// let src = Tensor::<u8>::new(&[480, 640, 3], Some(TensorMemory::DmaBuf), None)?;
1819    ///
1820    /// // Round-tripping a DMA-BUF fd preserves the backend — the import is
1821    /// // still zero-copy, and still eligible for GPU/NPU paths.
1822    /// let imported = Tensor::<u8>::from_fd(src.clone_fd()?, src.shape(), None)?;
1823    /// assert_eq!(imported.memory(), TensorMemory::DmaBuf);
1824    /// # Ok(())
1825    /// # }
1826    /// ```
1827    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
1828    where
1829        Self: Sized;
1830
1831    #[cfg(unix)]
1832    /// Clone the file descriptor associated with this tensor.
1833    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd>;
1834
1835    /// Get the memory type of this tensor.
1836    fn memory(&self) -> TensorMemory;
1837
1838    /// Get the name of this tensor.
1839    fn name(&self) -> String;
1840
1841    /// Get the number of elements in this tensor.
1842    fn len(&self) -> usize {
1843        self.shape().iter().product()
1844    }
1845
1846    /// Check if the tensor is empty.
1847    fn is_empty(&self) -> bool {
1848        self.len() == 0
1849    }
1850
1851    /// Get the size in bytes of this tensor.
1852    fn size(&self) -> usize {
1853        self.len() * std::mem::size_of::<T>()
1854    }
1855
1856    /// Get the shape of this tensor.
1857    fn shape(&self) -> &[usize];
1858
1859    /// Reshape this tensor to the given shape. The total number of elements
1860    /// must remain the same.
1861    fn reshape(&mut self, shape: &[usize]) -> Result<()>;
1862
1863    /// Bytes of the underlying allocation (>= the current logical `size()`).
1864    fn capacity_bytes(&self) -> usize;
1865
1866    /// Set the logical shape to any shape whose byte size fits the allocation
1867    /// capacity, without the equal-size constraint of `reshape`.
1868    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()>;
1869
1870    /// Map the tensor into memory with the given access direction and
1871    /// return a TensorMap for accessing the data.
1872    ///
1873    /// `access` selects the platform mapping mode (read-only IOSurface
1874    /// lock, dma-buf sync direction, AHardwareBuffer lock usage) and the
1875    /// map's mutability: a map obtained with [`CpuAccess::Read`] rejects
1876    /// `as_mut_slice`. [`CpuAccess::None`] is not a mappable direction
1877    /// and returns [`Error::InvalidArgument`]; a direction the tensor's
1878    /// declared [`CpuAccess`] does not cover returns
1879    /// [`Error::CpuAccessDenied`].
1880    ///
1881    /// Prefer the typed wrappers [`map_read`](Self::map_read) /
1882    /// [`map_write`](Self::map_write) / [`map_mut`](Self::map_mut).
1883    ///
1884    /// Like [`pin_host`](Tensor::pin_host), `'a` bounds how long the backing
1885    /// allocation lives rather than borrowing the tensor: the returned view
1886    /// shares ownership through the pin's keepalive, so a map can coexist with
1887    /// a `&mut` on the same tensor.
1888    ///
1889    /// # Windows D3D11 textures: a write-only map of a partial window
1890    ///
1891    /// A [`CpuAccess::Write`] map is not refreshed from the texture first, so
1892    /// the bytes the caller leaves untouched are undefined -- the documented
1893    /// contract a whole-tensor map can meet and a sub-view cannot, since the
1894    /// window spans its own rows only.
1895    ///
1896    /// A tensor allocated [`CpuAccess::Write`] (no staging texture) publishes
1897    /// only the window's own rows on unmap, so a sub-view -- a
1898    /// [`view`](Tensor::view), or a shape narrowed with `set_logical_shape`
1899    /// -- is refused only when its window is not a whole, pitch-aligned row
1900    /// range (a plain-offset [`view`](Tensor::view) always is; an arbitrary
1901    /// byte offset from `set_plane_offset` might not be), which fails with
1902    /// [`Error::InvalidArgument`].
1903    ///
1904    /// A tensor allocated [`CpuAccess::Read`] or [`CpuAccess::ReadWrite`]
1905    /// (staging texture) has no such per-window publish: unmapping always
1906    /// copies the whole staging texture back, so a write-only sub-view is
1907    /// refused with [`Error::PartialWriteRequiresReadWrite`], naming
1908    /// [`CpuAccess::ReadWrite`] as the same window with the refresh that
1909    /// makes the untouched rows well-defined -- and, unlike the
1910    /// `CpuAccess::Write` case above, that retry always succeeds.
1911    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1912    where
1913        T: 'a;
1914
1915    /// Map the tensor read-write (equivalent to
1916    /// `map_with(CpuAccess::ReadWrite)`).
1917    ///
1918    /// Fails with [`Error::CpuAccessDenied`] on a `Read`- or `Write`-declared
1919    /// tensor: use [`map_read`](Self::map_read) to read one and
1920    /// [`map_for_write`](Self::map_for_write) to fill one.
1921    fn map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1922    where
1923        T: 'a,
1924    {
1925        self.map_with(CpuAccess::ReadWrite)
1926    }
1927
1928    /// Map the tensor for CPU reading only. The returned map rejects
1929    /// `as_mut_slice`; on macOS this takes the read-only IOSurface lock
1930    /// (skips the unlock flush), on Linux the dma-buf read-direction
1931    /// sync. Fails with [`Error::CpuAccessDenied`] on a `Write`- or
1932    /// `None`-declared tensor.
1933    fn map_read<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1934    where
1935        T: 'a,
1936    {
1937        self.map_with(CpuAccess::Read)
1938    }
1939
1940    /// Map the tensor for CPU writing (fill-only: reading through a
1941    /// write map may see write-combined memory — do not read the slice).
1942    /// Valid only for a pass that overwrites the whole mapping; see
1943    /// [`write_access`](Self::write_access). Fails with
1944    /// [`Error::CpuAccessDenied`] on a `Read`- or `None`-declared tensor.
1945    fn map_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1946    where
1947        T: 'a,
1948    {
1949        self.map_with(CpuAccess::Write)
1950    }
1951
1952    /// Map the tensor read-write (alias of [`map`](Self::map) with the
1953    /// intent spelled out); refused on the same declarations.
1954    fn map_mut<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1955    where
1956        T: 'a,
1957    {
1958        self.map_with(CpuAccess::ReadWrite)
1959    }
1960
1961    /// The CPU access declared for this tensor (see [`CpuAccess`]). A bare
1962    /// backing carries no declaration of its own and reports `ReadWrite`.
1963    fn cpu_access(&self) -> CpuAccess {
1964        CpuAccess::ReadWrite
1965    }
1966
1967    /// The parent-image snapshot when this tensor is a sub-region of a larger
1968    /// buffer (see [`ViewOrigin`]); a bare backing reports `None`.
1969    fn view_origin(&self) -> Option<ViewOrigin> {
1970        None
1971    }
1972
1973    /// Whether this tensor addresses part of a larger buffer (see
1974    /// [`Tensor::is_view`]); a bare backing reports `false`.
1975    fn is_view(&self) -> bool {
1976        self.view_origin().is_some()
1977    }
1978
1979    /// The direction a CPU pass that overwrites every element of this tensor
1980    /// maps with.
1981    ///
1982    /// A write-only map does not invalidate the CPU cache (on arm64,
1983    /// `DMA_BUF_SYNC_START|WRITE` only orders), so a stale cache line the pass
1984    /// writes only partly is written back over whatever a device left in the
1985    /// rest of it. The map may therefore be `Write` only when the pass covers
1986    /// the whole buffer: a whole tensor takes its declaration plus `Write`
1987    /// (`Write` on a write-only declaration), a [`view`](Tensor::view), whose
1988    /// rows share cache lines with bytes outside it, takes `ReadWrite`.
1989    fn write_access(&self) -> CpuAccess {
1990        if self.is_view() {
1991            CpuAccess::ReadWrite
1992        } else {
1993            self.cpu_access().union(CpuAccess::Write)
1994        }
1995    }
1996
1997    /// Map for a CPU pass that overwrites every element, in
1998    /// [`write_access`](Self::write_access)'s direction.
1999    ///
2000    /// # Errors
2001    ///
2002    /// [`Error::CpuAccessDenied`] when the declaration does not cover that
2003    /// direction, otherwise whatever [`map_with`](Self::map_with) returns.
2004    fn map_for_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
2005    where
2006        T: 'a,
2007    {
2008        self.map_with(self.write_access())
2009    }
2010
2011    /// Map for a CPU pass that writes only part of the buffer and keeps the
2012    /// rest (a letterbox border around a device-rendered interior): always
2013    /// `ReadWrite`, see [`write_access`](Self::write_access).
2014    ///
2015    /// # Errors
2016    ///
2017    /// [`Error::CpuAccessDenied`] unless the declaration is `ReadWrite`,
2018    /// otherwise whatever [`map_with`](Self::map_with) returns.
2019    fn map_for_partial_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
2020    where
2021        T: 'a,
2022    {
2023        self.map_with(CpuAccess::ReadWrite)
2024    }
2025
2026    /// Non-blocking [`map_with`](Self::map_with): returns
2027    /// `Err(Error::IoError(WouldBlock))` while a GPU copy the map depends on
2028    /// is still in flight, and makes progress on a retry.
2029    ///
2030    /// Backings with no such copy (host memory, shared memory, dma-buf,
2031    /// IOSurface, AHardwareBuffer, PBO) alias `map_with`, so a caller can use
2032    /// this unconditionally and only ever see `WouldBlock` where the wait is
2033    /// real -- today the Windows D3D11 texture's staging refresh.
2034    ///
2035    /// A retry loop must yield or sleep between attempts. On the WARP
2036    /// software adapter the CPU threads are the GPU, so a tight loop starves
2037    /// the very copy it is waiting for.
2038    ///
2039    /// [`CpuAccess::Write`] is refused on a Windows D3D11 texture tensor whose
2040    /// window is shorter than its backing, exactly as it is for
2041    /// [`map_with`](Self::map_with); see that method.
2042    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2043    where
2044        T: 'a,
2045    {
2046        self.map_with(access)
2047    }
2048
2049    /// Non-blocking [`map`](Self::map): read-write, and `WouldBlock` rather
2050    /// than a stall. See [`try_map_with`](Self::try_map_with).
2051    fn try_map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
2052    where
2053        T: 'a,
2054    {
2055        self.try_map_with(CpuAccess::ReadWrite)
2056    }
2057
2058    /// Get the buffer identity for cache keying and liveness tracking.
2059    fn buffer_identity(&self) -> &BufferIdentity;
2060
2061    /// Create a zero-copy sub-region view of this backing that shares the
2062    /// underlying allocation **and** [`BufferIdentity`].
2063    ///
2064    /// The window is `[offset_bytes, offset_bytes + shape.product() *
2065    /// size_of::<T>())` measured from this tensor's own logical start, so a
2066    /// sub-view of a sub-view composes by adding offsets. Sharing the parent's
2067    /// identity is the contract that lets identity-keyed caches (e.g. the GL
2068    /// EGLImage import cache) treat offset-distinct windows as one buffer rather
2069    /// than unrelated allocations — `view` must never mint a fresh identity.
2070    ///
2071    /// Defaults to [`Error::NotImplemented`]; every backend that supports
2072    /// sub-views overrides it (`Mem`, `Shm`, Linux DMA, macOS IOSurface, `Pbo`).
2073    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self>
2074    where
2075        Self: Sized,
2076    {
2077        let _ = (offset_bytes, shape);
2078        Err(Error::NotImplemented(
2079            "view (zero-copy sub-region) is not supported for this tensor backend".to_owned(),
2080        ))
2081    }
2082}
2083
2084pub trait TensorMapTrait<T>
2085where
2086    T: Num + Clone + fmt::Debug,
2087{
2088    /// Get the shape of this tensor map.
2089    fn shape(&self) -> &[usize];
2090
2091    /// Unmap the tensor from memory.
2092    fn unmap(&mut self);
2093
2094    /// Get the number of elements in this tensor map.
2095    fn len(&self) -> usize {
2096        self.shape().iter().product()
2097    }
2098
2099    /// Check if the tensor map is empty.
2100    fn is_empty(&self) -> bool {
2101        self.len() == 0
2102    }
2103
2104    /// Get the size in bytes of this tensor map.
2105    fn size(&self) -> usize {
2106        self.len() * std::mem::size_of::<T>()
2107    }
2108
2109    /// Get a slice to the data in this tensor map.
2110    fn as_slice(&self) -> &[T];
2111
2112    /// Get a mutable slice to the data in this tensor map.
2113    fn as_mut_slice(&mut self) -> &mut [T];
2114
2115    /// Whether this mapping permits writes -- i.e. whether the coherency
2116    /// bracket it holds will flush on release.
2117    ///
2118    /// The single source of truth for a binding that has to advertise
2119    /// mutability to a foreign consumer (the Python buffer protocol's
2120    /// `readonly` flag, say). Deriving that from the access a caller
2121    /// *requested* instead would leave the advertisement agreeing with the
2122    /// request while both disagreed with the mapping actually taken.
2123    fn is_writable(&self) -> bool;
2124
2125    #[cfg(feature = "ndarray")]
2126    /// Get an ndarray ArrayView of the tensor data.
2127    fn view(&'_ self) -> Result<ndarray::ArrayView<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2128        Ok(ndarray::ArrayView::from_shape(
2129            self.shape(),
2130            self.as_slice(),
2131        )?)
2132    }
2133
2134    #[cfg(feature = "ndarray")]
2135    /// Get an ndarray ArrayViewMut of the tensor data.
2136    fn view_mut(
2137        &'_ mut self,
2138    ) -> Result<ndarray::ArrayViewMut<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2139        let shape = self.shape().to_vec();
2140        Ok(ndarray::ArrayViewMut::from_shape(
2141            shape,
2142            self.as_mut_slice(),
2143        )?)
2144    }
2145}
2146
2147// Declared through `ef_vocabulary!` (see `vocabulary.rs`). These codes are
2148// the single numbering of backing stores across the stack: `code()`, the
2149// `tensor_memory_wire` constants, the C header's `ef_storage_kind`, the
2150// Python `TensorMemory` enum and the cross-package descriptor's
2151// `TensorDesc::kind` (`protocol::kind` re-exports `tensor_memory_wire`) all
2152// read the same declared literal per variant.
2153crate::ef_vocabulary! {
2154    /// Which memory backend a tensor is (or should be) allocated from.
2155    ///
2156    /// Pass `Some(..)` to a constructor to pin the backend, or `None` to
2157    /// auto-select. Auto-selection differs by constructor: [`Tensor::new`]
2158    /// tries `DmaBuf` → `Mem`, while the image constructors try `DmaBuf` →
2159    /// `Mem` and skip `Shm`. `EDGEFIRST_TENSOR_FORCE_MEM=1` short-circuits
2160    /// either chain to `Mem`, which is how tests run on hosts without
2161    /// DMA-heap permissions.
2162    ///
2163    /// A pinned request has no fallback: if the backend cannot serve it, the
2164    /// constructor fails rather than quietly giving you something slower.
2165    /// [`TensorTrait::memory`] reports what a tensor actually ended up with.
2166    ///
2167    /// # Every variant exists on every platform
2168    ///
2169    /// This is a namespace of codes, not a list of what this build can
2170    /// allocate -- [`TensorMemory::is_available`] answers the latter, at
2171    /// runtime. `Shm` used to be `#[cfg(unix)]`, which shifted every
2172    /// variant declared after it on a non-unix build and made the
2173    /// numbering a compile-time detail of the host. It also made a
2174    /// cross-platform recording unreadable: a blob captured on Linux
2175    /// carries `"dmabuf"`, and a macOS consumer has to be able to NAME
2176    /// that backing in order to report "cannot materialise here" instead
2177    /// of failing with an unrecognised code.
2178    ///
2179    /// # `#[non_exhaustive]`
2180    ///
2181    /// Match this with a wildcard arm. The variant list is expected to
2182    /// keep growing -- `IoSurface` and `Cuda` were added here, Windows
2183    /// backings and a real CUDA device backing are named as coming -- and
2184    /// this attribute is what keeps a closed-source consumer's
2185    /// `match memory { .. }` compiling across those additions. It protects
2186    /// DOWNSTREAM, not this crate: inside `edgefirst-tensor` the matches
2187    /// stay exhaustive and compile-enforced. Same rule as [`DType`],
2188    /// [`Compression`] and [`PixelFormat`], which `TensorMemory` was the
2189    /// lone exception to.
2190    #[non_exhaustive]
2191    pub enum TensorMemory {
2192        /// Regular system memory allocation. Available everywhere; the
2193        /// floor every auto-selection chain ends on.
2194        Mem = 0, "mem", MEM,
2195        /// POSIX Shared Memory allocation. Suitable for inter-process
2196        /// communication, but not for hardware acceleration. Only
2197        /// allocatable on unix; nameable everywhere.
2198        Shm = 1, "shm", SHM,
2199        /// Platform-native zero-copy GPU buffer.
2200        ///
2201        /// On Linux this is a DMA-BUF (`DmaTensor` in
2202        /// `crates/tensor/src/dma.rs`) allocated via the DRM/dma-heap
2203        /// subsystem. On macOS/iOS it is currently also this variant that
2204        /// yields an IOSurface, and on Android an AHardwareBuffer: they
2205        /// share the `TensorStorage::Dma` slot at the trait level, so the
2206        /// one code covers all of them. `IoSurface` and `D3d11Texture`
2207        /// below are the codes for naming those backings specifically; no
2208        /// backend reports either as its `memory()` yet.
2209        ///
2210        /// Allows hardware-accelerated paths (OpenGL backend on Linux via
2211        /// `EGL_EXT_image_dma_buf_import`; macOS via
2212        /// `EGL_ANGLE_iosurface_client_buffer`). CPU access via `map()`
2213        /// incurs cache-coherency overhead on Linux DMA-BUF and is similar
2214        /// in cost on IOSurface; SHM/Mem are cheaper for CPU-only workloads.
2215        ///
2216        /// On Windows it is an `ID3D11Texture2D` on the process D3D11 device
2217        /// (`crates/tensor/src/d3d11/`), allocated only through
2218        /// [`Tensor::image`](crate::Tensor::image); CPU access goes through a
2219        /// staging copy. The first such allocation creates the process
2220        /// device, so a host supplying its own device must call
2221        /// `d3d11::use_external_device` before it -- and before
2222        /// [`crate::is_gpu_buffer_available`], which creates the device too.
2223        DmaBuf = 2, "dmabuf", DMABUF,
2224        /// Apple IOSurface, named specifically rather than through the
2225        /// portable `DmaBuf` spelling.
2226        ///
2227        /// Defined as a code so a descriptor or recording that names an
2228        /// IOSurface is parseable on every platform. **No backend produces
2229        /// or accepts it yet** -- macOS/iOS allocate and report `DmaBuf`.
2230        /// Pinning a request to it fails with `NotImplemented`.
2231        IoSurface = 3, "iosurface", IOSURFACE,
2232        /// OpenGL Pixel Buffer Object memory. Created by `ImageProcessor`
2233        /// when DMA-buf is unavailable but OpenGL is present -- never by
2234        /// [`Tensor::new`], which has no GL context to create it in.
2235        Pbo = 4, "pbo", PBO,
2236        /// CUDA device memory: a device pointer, not host-addressable.
2237        ///
2238        /// Defined as a code for the same reason as `IoSurface`. **No
2239        /// backend produces or accepts it yet**; pinning a request to it
2240        /// fails with `NotImplemented`.
2241        Cuda = 5, "cuda", CUDA,
2242        /// Windows `ID3D11Texture2D`, named specifically rather than through
2243        /// the portable `DmaBuf` spelling.
2244        ///
2245        /// This is the code a Windows producer writes into
2246        /// [`TensorDesc::kind`](crate::TensorDesc::kind), where the handle
2247        /// field means an NT shared handle rather than an fd. As a
2248        /// [`TensorMemory`] it is defined for the same reason as
2249        /// `IoSurface`: **no backend allocates or reports it** -- Windows
2250        /// allocates and reports `DmaBuf` -- and pinning a request to it
2251        /// fails with `NotImplemented`.
2252        D3d11Texture = 6, "d3d11", D3D11_TEXTURE,
2253    }
2254    // `#[doc(hidden)]`: `pub` so the const-only form is reachable by an FFI
2255    // or cbindgen consumer, but it is emission plumbing rather than a second
2256    // documented API -- `TensorMemory::code()` is the documented path, and
2257    // the two are generated from the same literal.
2258    #[doc(hidden)]
2259    pub mod tensor_memory_wire;
2260}
2261
2262impl TensorMemory {
2263    /// Whether this backing can actually be allocated in this process, now.
2264    ///
2265    /// A runtime question, not a compile-time one, which is why it is a
2266    /// method on the vocabulary rather than a `cfg` on the variant:
2267    /// `DmaBuf` needs `/dev/dma_heap` to exist *and* this process to have
2268    /// permission on it (a container, a user outside the `video`/`render`
2269    /// groups, and a stock desktop give three different answers on the same
2270    /// kernel), and `Shm` needs a writable `/dev/shm`. Each probe allocates
2271    /// once and caches.
2272    ///
2273    /// Reports what can be *allocated*, which is not the same as what can be
2274    /// *named*: every variant is nameable on every platform (that is the
2275    /// point of the vocabulary), and the ones this build cannot serve say so
2276    /// here rather than by being absent from the enum.
2277    ///
2278    /// Four variants answer `false` unconditionally today, for reasons
2279    /// worth stating rather than hiding behind a probe that cannot fail:
2280    ///
2281    /// * `IoSurface` -- no backend produces or accepts it yet; macOS/iOS
2282    ///   serve IOSurface under the `DmaBuf` spelling, so `DmaBuf` is the
2283    ///   variant that answers `true` there.
2284    /// * `Pbo` -- PBO tensors are created by `ImageProcessor::create_image`
2285    ///   with a GL context current, never by [`Tensor::new`]. This crate
2286    ///   holds no GL context and cannot see one; ask the image crate
2287    ///   whether a GL backend is live.
2288    /// * `Cuda` -- there is no CUDA backing in this crate at all. Probing
2289    ///   for `libcuda` would answer `true` on a Jetson while every
2290    ///   allocation still failed, which is worse than answering `false`.
2291    /// * `D3d11Texture` -- same as `IoSurface`: Windows serves D3D11
2292    ///   textures under the `DmaBuf` spelling.
2293    ///
2294    /// One variant answers `true` for a narrower constructor than the rest:
2295    /// on Windows `DmaBuf` is an `ID3D11Texture2D`, which is image-formatted,
2296    /// so `true` there means [`Tensor::image`] can serve it and
2297    /// [`Tensor::new`] with `Some(DmaBuf)` still answers `NotImplemented`.
2298    /// [`is_gpu_buffer_available`](crate::is_gpu_buffer_available), which this
2299    /// forwards to, states the same split.
2300    pub fn is_available(self) -> bool {
2301        match self {
2302            // Always: the fallback every constructor chain ends on. If this
2303            // could fail, allocation would have no floor left.
2304            TensorMemory::Mem => true,
2305            TensorMemory::Shm => is_shm_available(),
2306            // Today `DmaBuf` is the portable spelling of "platform-native
2307            // zero-copy GPU buffer", so it is the portable probe that
2308            // answers for it -- true on macOS/iOS (IOSurface) and Android
2309            // (AHardwareBuffer) as well as Linux.
2310            TensorMemory::DmaBuf => is_gpu_buffer_available(),
2311            TensorMemory::IoSurface
2312            | TensorMemory::Pbo
2313            | TensorMemory::Cuda
2314            | TensorMemory::D3d11Texture => false,
2315        }
2316    }
2317
2318    /// The error to return when a caller pins a request to a backing this
2319    /// build knows the name of but cannot serve.
2320    ///
2321    /// Deliberately not `unreachable!()`. `IoSurface` and `Cuda` are
2322    /// unreachable *today* only because no backend reports them yet; the
2323    /// day one does, an `unreachable!()` written now becomes a panic in
2324    /// library code reached from an ordinary caller request. A "this build
2325    /// cannot do that" error is the same information without the crash, and
2326    /// it is what every other unservable request here already returns.
2327    ///
2328    /// `static`-only: every call site is inside `impl<T> TensorStorage<T>`
2329    /// (`TensorStorage::new`), itself `#[cfg(not(edgefirst_tensor_dynamic))]`.
2330    #[cfg(not(edgefirst_tensor_dynamic))]
2331    pub(crate) fn unsupported_here(self) -> Error {
2332        let reason = match self {
2333            // Not reachable: `Mem` is servable on every target. Stated
2334            // rather than panicked on, for the reason above.
2335            TensorMemory::Mem => "Mem is servable on every platform, so this should not happen",
2336            TensorMemory::Shm => "POSIX shared memory is unix-only",
2337            TensorMemory::DmaBuf => {
2338                "this target has no platform GPU-buffer allocator (DMA-BUF is Linux, \
2339                 IOSurface macOS/iOS, AHardwareBuffer Android, D3D11 textures on \
2340                 Windows)"
2341            }
2342            TensorMemory::IoSurface => {
2343                "no backend produces or accepts IOSurface under its own code yet -- \
2344                 macOS/iOS allocate and report TensorMemory::DmaBuf"
2345            }
2346            TensorMemory::Pbo => {
2347                "PBO tensors are created by ImageProcessor::create_image with a GL \
2348                 context current, never by Tensor::new"
2349            }
2350            TensorMemory::Cuda => "there is no CUDA backing in this build",
2351            TensorMemory::D3d11Texture => {
2352                "no backend produces or accepts a D3D11 texture under its own code yet -- \
2353                 Windows allocates and reports TensorMemory::DmaBuf"
2354            }
2355        };
2356        Error::NotImplemented(format!(
2357            "TensorMemory::{self:?} is not supported by this build: {reason}"
2358        ))
2359    }
2360}
2361
2362impl From<TensorMemory> for String {
2363    /// The wire string, via [`TensorMemory::as_str`] -- one spelling, not a
2364    /// second one maintained alongside it.
2365    ///
2366    /// `DmaBuf` renders as `"dmabuf"`, not the `"dma"` this impl used to
2367    /// emit. `TryFrom<&str>` still accepts `"dma"` so anything that
2368    /// persisted the old spelling keeps parsing; the benchmark result keys
2369    /// in `.github/scripts` spell it `"dma"` but build that string
2370    /// themselves and never went through this impl.
2371    fn from(memory: TensorMemory) -> Self {
2372        memory.as_str().to_owned()
2373    }
2374}
2375
2376impl TryFrom<&str> for TensorMemory {
2377    type Error = Error;
2378
2379    /// Parses [`TensorMemory::as_str`]'s spelling, plus the legacy `"dma"`
2380    /// alias for `DmaBuf`.
2381    fn try_from(s: &str) -> Result<Self> {
2382        TensorMemory::from_str_code(s)
2383            .or(if s == "dma" {
2384                Some(TensorMemory::DmaBuf)
2385            } else {
2386                None
2387            })
2388            .ok_or_else(|| Error::InvalidMemoryType(s.to_owned()))
2389    }
2390}
2391
2392// Everything from here through the end of `impl<T> TensorTrait<T> for
2393// Tensor<T>` below is the `static` backend's storage-owning `Tensor<T>`:
2394// `TensorStorage<T>` (the per-memory-backend enum) and the `Tensor<T>`
2395// struct that wraps it. `dynamic`'s `Tensor<T>` is a different, much
2396// smaller type -- a typed lens over `TensorDyn` with no storage of its
2397// own -- defined in `tensor_dyn/dynamic_tensor.rs` and re-exported below.
2398#[cfg(not(edgefirst_tensor_dynamic))]
2399#[derive(Debug)]
2400#[allow(dead_code)] // Variants are constructed by downstream crates via pub(crate) helpers
2401pub(crate) enum TensorStorage<T>
2402where
2403    T: Num + Clone + fmt::Debug + Send + Sync,
2404{
2405    /// Platform-native zero-copy GPU buffer. Inner type differs per
2406    /// target: `DmaTensor` on Linux (DMA-BUF fd), `IoSurfaceTensor` on
2407    /// macOS (CFRetained IOSurface). The shared variant name keeps the
2408    /// public `TensorMemory::DmaBuf` discriminant stable across platforms.
2409    #[cfg(target_os = "linux")]
2410    Dma(DmaTensor<T>),
2411    #[cfg(any(target_os = "macos", target_os = "ios"))]
2412    Dma(IoSurfaceTensor<T>),
2413    #[cfg(target_os = "android")]
2414    Dma(AHardwareBufferTensor<T>),
2415    #[cfg(target_os = "windows")]
2416    Dma(crate::d3d11::texture::D3d11TextureTensor<T>),
2417    #[cfg(unix)]
2418    Shm(ShmTensor<T>),
2419    Mem(MemTensor<T>),
2420    Pbo(PboTensor<T>),
2421}
2422
2423#[cfg(not(edgefirst_tensor_dynamic))]
2424impl<T> TensorStorage<T>
2425where
2426    T: Num + Clone + fmt::Debug + Send + Sync,
2427{
2428    /// The backing allocation's intrinsic physical row pitch in bytes, if it
2429    /// has one that is fixed independent of the logical shape. macOS IOSurface
2430    /// reports its 64-aligned `bytesPerRow`; other backings (Linux DMA, SHM,
2431    /// Mem, PBO) have no fixed pitch beyond the logical shape and return `None`.
2432    ///
2433    /// Used by `configure_image` to preserve the physical pitch when a reused
2434    /// pool tensor is reconfigured to a smaller logical image — so the decode
2435    /// writes rows at the surface's real stride and the GPU samples them with
2436    /// the same stride (the physical-grid / logical-ROI decoupling).
2437    ///
2438    /// Compiled only where such a backing exists; on Linux and other unix
2439    /// targets it would always answer `None`.
2440    #[cfg(any(
2441        target_os = "macos",
2442        target_os = "ios",
2443        target_os = "android",
2444        target_os = "windows"
2445    ))]
2446    pub(crate) fn backing_row_stride(&self) -> Option<usize> {
2447        match self {
2448            // Only genuine image-formatted IOSurfaces (height > 1) carry a real
2449            // per-row pitch; a generic byte-bag (height == 1) returns `None` so
2450            // `configure_image` does not adopt its whole-buffer "row" as a stride.
2451            #[cfg(any(target_os = "macos", target_os = "ios"))]
2452            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2453            // Android AHardwareBuffer: same rule — only genuine 2D
2454            // image-formatted buffers (height > 1) carry a real row pitch.
2455            #[cfg(target_os = "android")]
2456            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2457            // Windows D3D11: the driver's staging row pitch. `None` when the
2458            // tensor has no staging (write-only or hardware-only), whose host
2459            // buffer has tight rows -- except for a semi-planar tensor, whose
2460            // texture is allocated as wide as that pitch for every access, so
2461            // it always has one to report.
2462            #[cfg(target_os = "windows")]
2463            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2464            _ => None,
2465        }
2466    }
2467
2468    /// Create a new tensor storage with the given shape, memory type, and
2469    /// optional name. If no name is given, a random name will be generated.
2470    /// If no memory type is given, the best available memory type will be
2471    /// chosen based on the platform and environment variables.
2472    fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2473        match memory {
2474            #[cfg(target_os = "linux")]
2475            Some(TensorMemory::DmaBuf) => {
2476                DmaTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2477            }
2478            #[cfg(any(target_os = "macos", target_os = "ios"))]
2479            Some(TensorMemory::DmaBuf) => {
2480                IoSurfaceTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2481            }
2482            #[cfg(target_os = "android")]
2483            Some(TensorMemory::DmaBuf) => {
2484                AHardwareBufferTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2485            }
2486            // Windows has a DmaBuf backing, but it is a D3D11 texture: an image
2487            // layout, not a byte bag, so a shape alone cannot describe one.
2488            // Name the constructor that can, rather than quietly handing back
2489            // host memory the caller did not ask for.
2490            #[cfg(target_os = "windows")]
2491            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2492                "D3D11 texture tensors are image-formatted; use Tensor::image with \
2493                 TensorMemory::DmaBuf"
2494                    .to_owned(),
2495            )),
2496            #[cfg(not(any(
2497                target_os = "linux",
2498                target_os = "macos",
2499                target_os = "ios",
2500                target_os = "android",
2501                target_os = "windows"
2502            )))]
2503            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2504                "TensorMemory::DmaBuf is only available on Linux (DMA-BUF), macOS/iOS (IOSurface), \
2505                 Android (AHardwareBuffer), and Windows (D3D11 textures, via Tensor::image)"
2506                    .to_owned(),
2507            )),
2508            #[cfg(unix)]
2509            Some(TensorMemory::Shm) => {
2510                ShmTensor::<T>::new(shape, name).map(TensorStorage::Shm)
2511            }
2512            #[cfg(not(unix))]
2513            Some(m @ TensorMemory::Shm) => Err(m.unsupported_here()),
2514            Some(TensorMemory::Mem) => {
2515                MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2516            }
2517            Some(TensorMemory::Pbo) => Err(crate::error::Error::NotImplemented(
2518                "PboTensor cannot be created via Tensor::new() — use ImageProcessor::create_image()".to_owned(),
2519            )),
2520            // Defined codes with no backing behind them yet. An error, not
2521            // an `unreachable!()` -- see `unsupported_here`.
2522            Some(
2523                m @ (TensorMemory::IoSurface | TensorMemory::Cuda | TensorMemory::D3d11Texture),
2524            ) => Err(m.unsupported_here()),
2525            None => {
2526                if std::env::var("EDGEFIRST_TENSOR_FORCE_MEM")
2527                    .is_ok_and(|x| x != "0" && x.to_lowercase() != "false")
2528                {
2529                    MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2530                } else {
2531                    // Auto-select priority: Dma > Mem. Shm is intentionally NOT
2532                    // auto-selected — it offers no advantage over Mem for an
2533                    // in-process tensor and Mem always succeeds, so Shm below Mem
2534                    // is effectively never reached. Request Shm explicitly via
2535                    // `TensorMemory::Shm` when cross-process sharing is needed.
2536                    // (PBO sits between Dma and Mem but is GL-backed and created
2537                    // only via `ImageProcessor::create_image`.)
2538                    #[cfg(target_os = "linux")]
2539                    {
2540                        // Linux: Try DMA -> Mem
2541                        match DmaTensor::<T>::new(shape, name) {
2542                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2543                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2544                        }
2545                    }
2546                    #[cfg(any(target_os = "macos", target_os = "ios"))]
2547                    {
2548                        // macOS/iOS: Try IOSurface -> Mem. IOSurface is the
2549                        // GPU-shareable backend (zero-copy via ANGLE), filling the
2550                        // same role as DMA-BUF on Linux.
2551                        match IoSurfaceTensor::<T>::new(shape, name) {
2552                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2553                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2554                        }
2555                    }
2556                    #[cfg(target_os = "android")]
2557                    {
2558                        // Android: Mem. Unlike macOS (where a byte-bag
2559                        // IOSurface is still GL-importable as R8) a generic
2560                        // BLOB AHardwareBuffer cannot back a GPU texture, so
2561                        // auto-selecting it buys nothing and costs plenty: a
2562                        // gralloc allocator-HAL ioctl per allocation (orders
2563                        // slower than malloc, ≥1 page + a dmabuf fd even for
2564                        // tiny tensors) and a lock/unlock cache-maintenance
2565                        // round trip on every map(). Zero-copy image tensors
2566                        // come from `Tensor::image(..)`; callers that want a
2567                        // BLOB (NNAPI handoff) request `TensorMemory::DmaBuf`
2568                        // explicitly.
2569                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2570                    }
2571                    #[cfg(all(
2572                        unix,
2573                        not(any(
2574                            target_os = "linux",
2575                            target_os = "macos",
2576                            target_os = "ios",
2577                            target_os = "android"
2578                        ))
2579                    ))]
2580                    {
2581                        // Other Unix (BSD): Mem only (no DMA; Shm is explicit-only)
2582                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2583                    }
2584                    #[cfg(not(unix))]
2585                    {
2586                        // Windows/other: Mem only
2587                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2588                    }
2589                }
2590            }
2591        }
2592    }
2593
2594    /// Create a DMA-backed tensor storage with an explicit byte size that
2595    /// may exceed `shape.product() * sizeof(T)`. Used for image tensors
2596    /// with row-padded layouts (see `DmaTensor::new_with_byte_size`).
2597    ///
2598    /// This is intentionally DMA-only: padding is only meaningful for
2599    /// buffers that will be imported as GPU textures via EGLImage. PBO,
2600    /// Shm, and Mem storage doesn't benefit from pitch alignment and
2601    /// shouldn't pay the memory cost.
2602    /// With `contiguous`, the memory comes from the CMA heap only (see
2603    /// [`ImageDesc::with_contiguous`]).
2604    #[cfg(target_os = "linux")]
2605    pub(crate) fn new_dma_with_byte_size(
2606        shape: &[usize],
2607        byte_size: usize,
2608        name: Option<&str>,
2609        contiguous: bool,
2610    ) -> Result<Self> {
2611        DmaTensor::<T>::new_with_byte_size(shape, byte_size, name, contiguous)
2612            .map(TensorStorage::Dma)
2613    }
2614
2615    // No non-Linux stub: the only caller (`Tensor::image_with_stride`)
2616    // returns `NotImplemented` directly on non-Linux without ever
2617    // reaching the storage layer, so defining a stub here would be
2618    // dead code and fail the `-D warnings` clippy gate on macOS CI.
2619
2620    /// Create a Mem-backed tensor storage with an explicit byte size that may
2621    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2622    /// 64-byte-aligned row strides (see `MemTensor::with_capacity_bytes`).
2623    pub(crate) fn new_mem_with_byte_size(
2624        shape: &[usize],
2625        byte_size: usize,
2626        name: Option<&str>,
2627    ) -> Result<Self>
2628    where
2629        T: 'static,
2630    {
2631        MemTensor::<T>::with_capacity_bytes(shape, byte_size, name).map(TensorStorage::Mem)
2632    }
2633
2634    /// Create a Shm-backed tensor storage with an explicit byte size that may
2635    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2636    /// 64-byte-aligned row strides (see `ShmTensor::new_with_byte_size`).
2637    #[cfg(unix)]
2638    pub(crate) fn new_shm_with_byte_size(
2639        shape: &[usize],
2640        byte_size: usize,
2641        name: Option<&str>,
2642    ) -> Result<Self> {
2643        ShmTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Shm)
2644    }
2645
2646    /// Allocate an image-formatted IOSurface-backed storage (macOS).
2647    ///
2648    /// Used by `Tensor::image()` when the caller requests
2649    /// `TensorMemory::DmaBuf` and the format has an IOSurface FourCC
2650    /// mapping (YUYV, RGBA, BGRA today). Falls back to `new_with_byte_size`
2651    /// otherwise.
2652    #[cfg(any(target_os = "macos", target_os = "ios"))]
2653    pub(crate) fn new_image_iosurface(
2654        width: usize,
2655        height: usize,
2656        format: PixelFormat,
2657        dtype: DType,
2658        shape: &[usize],
2659        name: Option<&str>,
2660    ) -> Result<Self> {
2661        IoSurfaceTensor::<T>::new_image(width, height, format, dtype, shape, name)
2662            .map(TensorStorage::Dma)
2663    }
2664
2665    /// Allocate an image-formatted AHardwareBuffer-backed storage (Android).
2666    ///
2667    /// Used by `Tensor::image()` when the caller requests
2668    /// `TensorMemory::DmaBuf` and the format has an AHardwareBuffer format
2669    /// mapping (RGBA8 and the RGBA16F float paths today). Falls back to
2670    /// `new_with_byte_size` otherwise.
2671    #[cfg(target_os = "android")]
2672    pub(crate) fn new_image_ahardwarebuffer(
2673        width: usize,
2674        height: usize,
2675        format: PixelFormat,
2676        dtype: DType,
2677        shape: &[usize],
2678        name: Option<&str>,
2679        access: CpuAccess,
2680    ) -> Result<Self> {
2681        AHardwareBufferTensor::<T>::new_image(width, height, format, dtype, shape, name, access)
2682            .map(TensorStorage::Dma)
2683    }
2684
2685    /// Allocate an image-formatted D3D11 texture storage (Windows).
2686    ///
2687    /// Used by `Tensor::image()` when the caller requests
2688    /// `TensorMemory::DmaBuf`. Unlike the IOSurface and AHardwareBuffer
2689    /// constructors there is no byte-bag fallback: a format with no texture
2690    /// layout is an error, because a texture is the only thing this backing
2691    /// can be.
2692    #[cfg(target_os = "windows")]
2693    pub(crate) fn new_image_d3d11(
2694        width: usize,
2695        height: usize,
2696        format: PixelFormat,
2697        dtype: DType,
2698        shape: &[usize],
2699        name: Option<&str>,
2700        access: CpuAccess,
2701    ) -> Result<Self> {
2702        crate::d3d11::texture::D3d11TextureTensor::<T>::new_image(
2703            width, height, format, dtype, shape, name, access,
2704        )
2705        .map(TensorStorage::Dma)
2706    }
2707
2708    /// Create a new tensor storage using the given file descriptor, shape,
2709    /// and optional name.
2710    #[cfg(unix)]
2711    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2712        #[cfg(target_os = "linux")]
2713        {
2714            use nix::sys::stat::fstat;
2715            use nix::sys::statfs::fstatfs;
2716
2717            let stat = fstat(&fd)?;
2718            let major = major(stat.st_dev);
2719            let minor = minor(stat.st_dev);
2720
2721            if major != 0 {
2722                // Dma and Shm tensors are expected to have major number 0
2723                return Err(Error::UnknownDeviceType(major, minor));
2724            }
2725
2726            // Classify by filesystem magic, never by the st_dev minor.
2727            //
2728            // dma_buf files live on an internal kernel mount (`dma_buf_mnt`,
2729            // created with `kern_mount`) whose superblock draws its device
2730            // number from `get_anon_bdev()` — an IDA allocated first-come,
2731            // first-served during boot, shared with every other anonymous
2732            // pseudo-filesystem (pipefs, sockfs, anon_inodefs, nsfs, bdev,
2733            // tracefs, ...). The minor a DMA-BUF lands on is therefore a
2734            // function of which pseudo-filesystems registered first on that
2735            // kernel build: it moves with kernel config, driver load order,
2736            // initramfs use, and kernel version. It is not part of any ABI.
2737            //
2738            // Observed values for a genuine DMA-BUF: 12 on x86 desktop, 8 on
2739            // the ADIS Verdin. An earlier revision hardcoded `9 | 10` here,
2740            // which silently imported real DMA-BUFs as shared memory
2741            // everywhere else.
2742            //
2743            // The filesystem magic, by contrast, is stable UAPI
2744            // (include/uapi/linux/magic.h). Both branches are identified
2745            // positively; anything else is genuinely unknown and is rejected
2746            // rather than guessed at. Falling back to SHM would "work" — a
2747            // DMA-BUF is mmap-able, and even a pipe imports as a zero-length
2748            // tensor — which is exactly why it must not be the default.
2749            let magic = fs_magic(fstatfs(&fd)?.filesystem_type().0 as i64);
2750
2751            log::debug!(
2752                "Creating tensor from fd: major={major}, minor={minor}, magic={magic:#010x}"
2753            );
2754
2755            match magic {
2756                DMA_BUF_MAGIC => DmaTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Dma),
2757                TMPFS_MAGIC => ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm),
2758                other => Err(Error::UnknownBufferType(other)),
2759            }
2760        }
2761        #[cfg(all(unix, not(target_os = "linux")))]
2762        {
2763            // On macOS/iOS/BSD, always use SHM (no DMA-BUF fd import)
2764            ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm)
2765        }
2766    }
2767}
2768
2769#[cfg(not(edgefirst_tensor_dynamic))]
2770impl<T> TensorTrait<T> for TensorStorage<T>
2771where
2772    T: Num + Clone + fmt::Debug + Send + Sync,
2773{
2774    fn new(shape: &[usize], name: Option<&str>) -> Result<Self> {
2775        Self::new(shape, None, name)
2776    }
2777
2778    #[cfg(unix)]
2779    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2780        Self::from_fd(fd, shape, name)
2781    }
2782
2783    #[cfg(unix)]
2784    fn clone_fd(&self) -> Result<OwnedFd> {
2785        match self {
2786            #[cfg(any(
2787                target_os = "linux",
2788                target_os = "macos",
2789                target_os = "ios",
2790                target_os = "android"
2791            ))]
2792            TensorStorage::Dma(t) => t.clone_fd(),
2793            TensorStorage::Shm(t) => t.clone_fd(),
2794            TensorStorage::Mem(t) => t.clone_fd(),
2795            TensorStorage::Pbo(t) => t.clone_fd(),
2796        }
2797    }
2798
2799    fn memory(&self) -> TensorMemory {
2800        match self {
2801            #[cfg(any(
2802                target_os = "linux",
2803                target_os = "macos",
2804                target_os = "ios",
2805                target_os = "android",
2806                target_os = "windows"
2807            ))]
2808            TensorStorage::Dma(_) => TensorMemory::DmaBuf,
2809            #[cfg(unix)]
2810            TensorStorage::Shm(_) => TensorMemory::Shm,
2811            TensorStorage::Mem(_) => TensorMemory::Mem,
2812            TensorStorage::Pbo(_) => TensorMemory::Pbo,
2813        }
2814    }
2815
2816    fn name(&self) -> String {
2817        match self {
2818            #[cfg(any(
2819                target_os = "linux",
2820                target_os = "macos",
2821                target_os = "ios",
2822                target_os = "android",
2823                target_os = "windows"
2824            ))]
2825            TensorStorage::Dma(t) => t.name(),
2826            #[cfg(unix)]
2827            TensorStorage::Shm(t) => t.name(),
2828            TensorStorage::Mem(t) => t.name(),
2829            TensorStorage::Pbo(t) => t.name(),
2830        }
2831    }
2832
2833    fn shape(&self) -> &[usize] {
2834        match self {
2835            #[cfg(any(
2836                target_os = "linux",
2837                target_os = "macos",
2838                target_os = "ios",
2839                target_os = "android",
2840                target_os = "windows"
2841            ))]
2842            TensorStorage::Dma(t) => t.shape(),
2843            #[cfg(unix)]
2844            TensorStorage::Shm(t) => t.shape(),
2845            TensorStorage::Mem(t) => t.shape(),
2846            TensorStorage::Pbo(t) => t.shape(),
2847        }
2848    }
2849
2850    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
2851        match self {
2852            #[cfg(any(
2853                target_os = "linux",
2854                target_os = "macos",
2855                target_os = "ios",
2856                target_os = "android",
2857                target_os = "windows"
2858            ))]
2859            TensorStorage::Dma(t) => t.reshape(shape),
2860            #[cfg(unix)]
2861            TensorStorage::Shm(t) => t.reshape(shape),
2862            TensorStorage::Mem(t) => t.reshape(shape),
2863            TensorStorage::Pbo(t) => t.reshape(shape),
2864        }
2865    }
2866
2867    fn capacity_bytes(&self) -> usize {
2868        match self {
2869            #[cfg(any(
2870                target_os = "linux",
2871                target_os = "macos",
2872                target_os = "ios",
2873                target_os = "android",
2874                target_os = "windows"
2875            ))]
2876            TensorStorage::Dma(t) => t.capacity_bytes(),
2877            #[cfg(unix)]
2878            TensorStorage::Shm(t) => t.capacity_bytes(),
2879            TensorStorage::Mem(t) => t.capacity_bytes(),
2880            TensorStorage::Pbo(t) => t.capacity_bytes(),
2881        }
2882    }
2883
2884    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
2885        match self {
2886            #[cfg(any(
2887                target_os = "linux",
2888                target_os = "macos",
2889                target_os = "ios",
2890                target_os = "android",
2891                target_os = "windows"
2892            ))]
2893            TensorStorage::Dma(t) => t.set_logical_shape(shape),
2894            #[cfg(unix)]
2895            TensorStorage::Shm(t) => t.set_logical_shape(shape),
2896            TensorStorage::Mem(t) => t.set_logical_shape(shape),
2897            TensorStorage::Pbo(t) => t.set_logical_shape(shape),
2898        }
2899    }
2900
2901    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2902    where
2903        T: 'a,
2904    {
2905        match self {
2906            #[cfg(any(
2907                target_os = "linux",
2908                target_os = "macos",
2909                target_os = "ios",
2910                target_os = "android",
2911                target_os = "windows"
2912            ))]
2913            TensorStorage::Dma(t) => t.map_with(access),
2914            #[cfg(unix)]
2915            TensorStorage::Shm(t) => t.map_with(access),
2916            TensorStorage::Mem(t) => t.map_with(access),
2917            TensorStorage::Pbo(t) => t.map_with(access),
2918        }
2919    }
2920
2921    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2922    where
2923        T: 'a,
2924    {
2925        match self {
2926            #[cfg(any(
2927                target_os = "linux",
2928                target_os = "macos",
2929                target_os = "ios",
2930                target_os = "android",
2931                target_os = "windows"
2932            ))]
2933            TensorStorage::Dma(t) => t.try_map_with(access),
2934            #[cfg(unix)]
2935            TensorStorage::Shm(t) => t.try_map_with(access),
2936            TensorStorage::Mem(t) => t.try_map_with(access),
2937            TensorStorage::Pbo(t) => t.try_map_with(access),
2938        }
2939    }
2940
2941    fn buffer_identity(&self) -> &BufferIdentity {
2942        match self {
2943            #[cfg(any(
2944                target_os = "linux",
2945                target_os = "macos",
2946                target_os = "ios",
2947                target_os = "android",
2948                target_os = "windows"
2949            ))]
2950            TensorStorage::Dma(t) => t.buffer_identity(),
2951            #[cfg(unix)]
2952            TensorStorage::Shm(t) => t.buffer_identity(),
2953            TensorStorage::Mem(t) => t.buffer_identity(),
2954            TensorStorage::Pbo(t) => t.buffer_identity(),
2955        }
2956    }
2957
2958    /// Forward a sub-region view to the active backend, re-wrapping the
2959    /// backend's view (which shares the parent's allocation and
2960    /// [`BufferIdentity`]) back into the matching `TensorStorage` variant. Each
2961    /// backend's `view` is its own `TensorTrait::view` override; this single
2962    /// match is the only per-variant dispatch (`Tensor::subview` calls through
2963    /// here rather than matching the storage itself).
2964    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
2965        match self {
2966            #[cfg(any(
2967                target_os = "linux",
2968                target_os = "macos",
2969                target_os = "ios",
2970                target_os = "android",
2971                target_os = "windows"
2972            ))]
2973            TensorStorage::Dma(t) => t.view(offset_bytes, shape).map(TensorStorage::Dma),
2974            #[cfg(unix)]
2975            TensorStorage::Shm(t) => t.view(offset_bytes, shape).map(TensorStorage::Shm),
2976            TensorStorage::Mem(t) => t.view(offset_bytes, shape).map(TensorStorage::Mem),
2977            TensorStorage::Pbo(t) => t.view(offset_bytes, shape).map(TensorStorage::Pbo),
2978        }
2979    }
2980}
2981
2982/// Multi-backend tensor with optional image format metadata.
2983///
2984/// When `format` is `Some`, this tensor represents an image. Width, height,
2985/// and channels are derived from `shape` + `format`. When `format` is `None`,
2986/// this is a raw tensor (identical to the pre-refactoring behavior).
2987///
2988/// This is the `static` backend's `Tensor<T>` -- it owns its storage. See
2989/// `tensor_dyn/dynamic_tensor.rs` for `dynamic`'s `Tensor<T>`, a typed lens
2990/// with no storage of its own.
2991#[cfg(not(edgefirst_tensor_dynamic))]
2992#[derive(Debug)]
2993pub struct Tensor<T>
2994where
2995    T: Num + Clone + fmt::Debug + Send + Sync,
2996{
2997    /// CUDA registration for this tensor, if any. Set after creation by
2998    /// the image crate once a PBO is registered with CUDA interop.
2999    ///
3000    /// MUST be declared before `storage`: CUDA must unregister the GL buffer
3001    /// before storage's Drop deletes it (cudaGraphicsUnregisterResource before
3002    /// glDeleteBuffers). Rust drops fields in declaration order.
3003    cuda: Option<crate::cuda::CudaHandle>,
3004    pub(crate) storage: TensorStorage<T>,
3005    format: Option<PixelFormat>,
3006    chroma: Option<Box<Tensor<T>>>,
3007    /// Row stride in bytes for externally allocated buffers with row padding.
3008    /// `None` means tightly packed (stride == width * bytes_per_pixel).
3009    row_stride: Option<usize>,
3010    /// Byte offset within the DMA-BUF where image data starts.
3011    /// `None` means offset 0 (data starts at the beginning of the buffer).
3012    plane_offset: Option<usize>,
3013    /// Quantization metadata for integer-typed tensors. Public access is
3014    /// gated by the `IntegerType` trait — `Tensor<f32>` etc. carry the
3015    /// field for layout uniformity but have no way to read or write it.
3016    pub(crate) quantization: Option<Quantization>,
3017    /// Optional colorimetry metadata. `None` = undefined; never auto-filled.
3018    colorimetry: Option<crate::Colorimetry>,
3019    /// Declared CPU access (see [`CpuAccess`]). Image constructors set it
3020    /// from their `access` parameter; non-image tensors (`new`, imports,
3021    /// numpy) default to `ReadWrite` — they are CPU-centric by nature.
3022    /// Maps, pins and sync brackets beyond this declaration are refused.
3023    cpu_access: CpuAccess,
3024    /// Recorded vendor tile-compression scheme — best knowledge from
3025    /// allocation time (see [`Compression`]). `Some` only for Android
3026    /// hardware-only AHardwareBuffers whose allocation requested
3027    /// compression on an eligible format with a recognized vendor. When
3028    /// set, the row-stride accessors describe no meaningful linear
3029    /// layout. `configure_image` preserves it (physical layout, unlike
3030    /// colorimetry); views inherit it.
3031    compression: Option<CompressionScheme>,
3032    /// Parent-image snapshot when this tensor is a [`view`](Self::view)/
3033    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. Lets the GL
3034    /// backend key its import on the parent and render the view as a
3035    /// `glViewport`/`glScissor` ROI. See [`ViewOrigin`].
3036    view_origin: Option<ViewOrigin>,
3037    /// Set by [`mark_view`](Self::mark_view) on a tensor rebuilt from a view's
3038    /// descriptor, which addresses part of a larger buffer but has no
3039    /// [`ViewOrigin`], and inherited by its sub-views. See
3040    /// [`is_view`](Self::is_view).
3041    view_marked: bool,
3042}
3043
3044#[cfg(not(edgefirst_tensor_dynamic))]
3045impl<T> Tensor<T>
3046where
3047    T: Num + Clone + fmt::Debug + Send + Sync,
3048{
3049    /// Wrap a TensorStorage in a Tensor with no image metadata.
3050    pub(crate) fn wrap(storage: TensorStorage<T>) -> Self {
3051        Self {
3052            storage,
3053            format: None,
3054            chroma: None,
3055            row_stride: None,
3056            plane_offset: None,
3057            quantization: None,
3058            cuda: None,
3059            colorimetry: None,
3060            cpu_access: CpuAccess::ReadWrite,
3061            compression: None,
3062            view_origin: None,
3063            view_marked: false,
3064        }
3065    }
3066
3067    /// Construct a tensor from a row-major element slice + shape. Allocates a
3068    /// new buffer (`TensorMemory::Mem`) and memcpys the contents; caller
3069    /// retains ownership of the input slice.
3070    ///
3071    /// # Errors
3072    ///
3073    /// - [`Error::InvalidShape`] if `values.len() != shape.iter().product()`.
3074    /// - Propagates any allocation error from [`Self::new`].
3075    pub fn from_slice(values: &[T], shape: &[usize]) -> Result<Self>
3076    where
3077        T: Copy,
3078    {
3079        let expected: usize = shape.iter().product();
3080        if values.len() != expected {
3081            return Err(Error::InvalidShape(format!(
3082                "from_slice: values.len()={} but shape product={expected} (shape={shape:?})",
3083                values.len()
3084            )));
3085        }
3086        let t = Self::new(shape, Some(TensorMemory::Mem), None)?;
3087        {
3088            let mut m = t.map()?;
3089            m.as_mut_slice().copy_from_slice(values);
3090        }
3091        Ok(t)
3092    }
3093
3094    /// Wrap externally-owned memory as a tensor without copying. The tensor
3095    /// borrows `[ptr, ptr + shape.product() * size_of::<T>())` as
3096    /// [`TensorMemory::Mem`]; `owner`, when `Some`, co-owns the source so it
3097    /// outlives the tensor (and all derived views/maps). See [`ForeignOwner`].
3098    ///
3099    /// The canonical use is CUDA zero-copy: allocate host-coherent memory
3100    /// (`cudaHostAlloc`), wrap the host pointer here, and bind the matching
3101    /// device pointer to the inference engine — reads and writes hit the same
3102    /// physical buffer with no host copy. The identical primitive backs the
3103    /// Python `Tensor.from_numpy` zero-copy borrow (owner = the NumPy object).
3104    ///
3105    /// # Safety
3106    ///
3107    /// `ptr` must be non-null, aligned to `align_of::<T>()`, and valid for
3108    /// `shape.product()` elements of `T` for as long as the returned tensor —
3109    /// and every view/map sharing its backing — is alive. Pass an `owner` that
3110    /// co-owns the source to uphold that contract.
3111    ///
3112    /// # Errors
3113    ///
3114    /// [`Error::InvalidSize`] if `shape` is empty.
3115    pub unsafe fn from_foreign(
3116        ptr: *mut T,
3117        shape: &[usize],
3118        owner: Option<crate::ForeignOwner>,
3119        name: Option<&str>,
3120    ) -> Result<Self> {
3121        // SAFETY: forwarded to `from_foreign_with_capacity`'s contract; `0`
3122        // clamps to the tight shape-derived footprint below, i.e. no extra
3123        // headroom -- identical to this function's own prior behaviour.
3124        unsafe { Self::from_foreign_with_capacity(ptr, shape, 0, owner, name) }
3125    }
3126
3127    /// [`Self::from_foreign`], additionally recording that the allocation
3128    /// behind `ptr` holds at least `capacity_bytes` bytes -- headroom beyond
3129    /// the tight `shape.product() * size_of::<T>()` footprint that a later
3130    /// [`configure_image`](Self::configure_image) / `set_logical_shape` can
3131    /// grow into without erroring.
3132    ///
3133    /// This is the consumer half of the cross-package capsule protocol's
3134    /// [`crate::protocol::TensorDesc::capacity`]: a producer's pool tensor
3135    /// (allocated for its largest expected image, holding a smaller one
3136    /// today) reports its real allocation size so the imported alias
3137    /// inherits the same headroom instead of being clamped to today's
3138    /// logical shape. `capacity_bytes` below the tight footprint is clamped
3139    /// up to it, never down -- the shape-implied floor always holds.
3140    ///
3141    /// # Safety
3142    ///
3143    /// Same as [`Self::from_foreign`], except `ptr` must be valid for
3144    /// `capacity_bytes` bytes (or the tight shape footprint, whichever is
3145    /// larger), not just the shape-implied footprint.
3146    ///
3147    /// # Errors
3148    ///
3149    /// [`Error::InvalidSize`] if `shape` is empty.
3150    pub unsafe fn from_foreign_with_capacity(
3151        ptr: *mut T,
3152        shape: &[usize],
3153        capacity_bytes: usize,
3154        owner: Option<crate::ForeignOwner>,
3155        name: Option<&str>,
3156    ) -> Result<Self> {
3157        if shape.is_empty() {
3158            return Err(Error::InvalidSize(0));
3159        }
3160        if ptr.is_null() {
3161            return Err(Error::InvalidArgument(
3162                "from_foreign: ptr must be non-null".to_owned(),
3163            ));
3164        }
3165        shape
3166            .iter()
3167            .copied()
3168            .try_fold(1usize, usize::checked_mul)
3169            .ok_or_else(|| {
3170                Error::InvalidArgument(format!(
3171                    "from_foreign: shape.product() overflows usize (shape={shape:?})"
3172                ))
3173            })?;
3174        let capacity_elems = capacity_bytes / std::mem::size_of::<T>();
3175        // SAFETY: this function's own contract (see its doc comment) is
3176        // exactly `MemTensor::from_foreign_with_capacity`'s contract.
3177        let mem = unsafe {
3178            MemTensor::<T>::from_foreign_with_capacity(ptr, shape, capacity_elems, owner, name)
3179        };
3180        Ok(Self::wrap(TensorStorage::Mem(mem)))
3181    }
3182
3183    /// Pin a stable host address for this tensor's data.
3184    ///
3185    /// The returned [`HostPin`] carries **no borrow of the tensor**, so a
3186    /// caller can pin once at init and still pass `&mut self` to
3187    /// `ImageProcessor::convert` every frame. That borrow conflict is the
3188    /// blocker reported in [#134](https://github.com/EdgeFirstAI/hal/issues/134).
3189    ///
3190    /// The address stays valid until the pin is dropped, and survives
3191    /// re-layout: `configure_image` and `set_logical_shape` only rewrite the
3192    /// logical shape after a capacity check, and every backend's pin owns its
3193    /// own mapping, so nothing can move underneath it.
3194    ///
3195    /// A pin is therefore a **snapshot**, not a tracker: [`HostPin::len`]
3196    /// reports the extent at pin time and does not follow a later re-layout.
3197    /// That is the safe direction — the window always stays inside the
3198    /// allocation — but it means a caller who re-layouts should re-pin if it
3199    /// wants the new extent.
3200    ///
3201    /// The address is **not** a coherency guarantee: on non-coherent backends
3202    /// bracket CPU access with
3203    /// [`sync_for_cpu`](Self::sync_for_cpu) and
3204    /// [`sync_for_device`](Self::sync_for_device), which are no-ops where the
3205    /// backend does not require them.
3206    ///
3207    /// # Cost
3208    ///
3209    /// On `Mem` this is an `Arc` refcount bump — the backing allocation is
3210    /// never resized, so its base pointer is already stable. Other backends
3211    /// establish a mapping once and reuse it.
3212    ///
3213    /// # Errors
3214    ///
3215    /// - [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`]: a
3216    ///   pinned mapping is CPU access by definition, so this is an error
3217    ///   rather than a warn-and-stage.
3218    /// - [`Error::CpuAccessDenied`] when the tensor's declared
3219    ///   [`CpuAccess`] does not cover `access`.
3220    /// - [`Error::NotImplemented`] for the two backends that **cannot** pin,
3221    ///   rather than merely not having been wired yet: a PBO
3222    ///   (`glMapBufferRange` establishes the address and the visibility
3223    ///   together, and there is no `GL_MAP_PERSISTENT_BIT` in GLES) and an
3224    ///   AHardwareBuffer (`AHardwareBuffer_lock` is what yields the address at
3225    ///   all, so there is no address outside the lock). Both name
3226    ///   `map()`/`map_with()` as the alternative. `Mem`, `Shm`, DMA-BUF,
3227    ///   IOSurface and the Windows D3D11 texture all pin — the texture through
3228    ///   a persistent host buffer the storage owns, which
3229    ///   [`sync_for_cpu`](Self::sync_for_cpu) and
3230    ///   [`sync_for_device`](Self::sync_for_device) move bytes between and the
3231    ///   texture, rather than through the staging mapping (a pin outlives every
3232    ///   map guard, so holding the staging texture mapped would freeze it).
3233    pub fn pin_host<'a>(&self, access: CpuAccess) -> Result<HostPin<'a>>
3234    where
3235        T: 'a,
3236    {
3237        self.cpu_access.require_direction(access, "pin_host")?;
3238        // Backends yield everything addressable from the tensor's offset;
3239        // narrow to the logical extent so a consumer cannot mistake pitch
3240        // padding for data. A map guard keeps the wider window.
3241        let logical = self.len() * std::mem::size_of::<T>();
3242        let pin = match &self.storage {
3243            TensorStorage::Mem(m) => Ok(m.host_pin()),
3244            #[cfg(unix)]
3245            TensorStorage::Shm(s) => s.host_pin(),
3246            #[cfg(target_os = "linux")]
3247            TensorStorage::Dma(d) => d.host_pin(),
3248            #[cfg(any(target_os = "macos", target_os = "ios"))]
3249            TensorStorage::Dma(s) => s.host_pin(),
3250            // Windows D3D11: the pin is the storage's own persistent host
3251            // buffer, not the staging mapping -- see `D3d11TextureTensor::host_pin`.
3252            // The direction goes with it: a writable pin is what tells
3253            // `sync_for_device` the host buffer, not the staging copy, carries
3254            // the caller's writes.
3255            #[cfg(target_os = "windows")]
3256            TensorStorage::Dma(t) => t.host_pin(access),
3257            // AHardwareBuffer cannot support a pin at all, and that is a
3258            // platform contract rather than missing work: the NDK exposes no
3259            // way to obtain a CPU address outside AHardwareBuffer_lock, so a
3260            // "persistent" address would mean holding the lock for the pin's
3261            // lifetime -- which blocks the GPU from the buffer and makes any
3262            // further lock undefined. Both are the opposite of what a pin is
3263            // for. Say so, rather than implying a TODO.
3264            #[cfg(target_os = "android")]
3265            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3266                "pin_host: AHardwareBuffer has no host address outside \
3267                 AHardwareBuffer_lock, so it cannot hand out one that outlives \
3268                 a guard. Use map()/map_with() for CPU access; for zero-copy \
3269                 handoff pass the buffer itself."
3270                    .to_owned(),
3271            )),
3272            // PBO is the same shape of constraint as AHardwareBuffer. The
3273            // message is a shared constant because `TensorDyn::pin_host`
3274            // (`tensor_dyn/dynamic_backend.rs`) must repeat this refusal --
3275            // the ABI has `ef_tensor_map` and no `ef_tensor_pin_host`, so the
3276            // library cannot tell a pin from a map. See
3277            // `crate::pbo::PIN_HOST_PBO_REFUSAL` for the reasoning at length.
3278            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3279                crate::pbo::PIN_HOST_PBO_REFUSAL.to_owned(),
3280            )),
3281        }?;
3282        Ok(pin.narrowed(logical))
3283    }
3284
3285    /// Make CPU reads/writes of this tensor coherent — the CPU is about to
3286    /// access the buffer.
3287    ///
3288    /// Corresponds to `DMA_BUF_SYNC_START`, and the name matches the Linux
3289    /// kernel DMA API for the same reason: `for_cpu` says who is about to
3290    /// touch the memory, which is what the caller actually knows.
3291    ///
3292    /// Pair with [`sync_for_device`](Self::sync_for_device).
3293    ///
3294    /// # Which backends owe maintenance
3295    ///
3296    /// | backend | behaviour |
3297    /// |---|---|
3298    /// | `Mem`, `Shm` | **no-op** — plain CPU memory, coherent by construction |
3299    /// | `Dma` (Linux DMA-BUF) | one `DMA_BUF_IOCTL_SYNC`, direction-tagged |
3300    /// | `Dma` (macOS/iOS IOSurface) | `IOSurfaceLock` / `IOSurfaceUnlock`, options matched to `access` |
3301    /// | `Dma` (Android AHardwareBuffer) | [`Error::NotImplemented`] — no sync independent of the lock |
3302    /// | `Dma` (Windows D3D11 texture) | refreshes the pinned host buffer from the texture through the staging copy, or uploads it back; a no-op until something pins |
3303    /// | `Pbo` | [`Error::NotImplemented`] — no coherency window independent of the map |
3304    ///
3305    /// Only the first row is a no-op, so "bracket unconditionally and pay
3306    /// nothing" holds for `Mem`/`Shm` alone. The two that report
3307    /// `NotImplemented` do so because their platform fuses addressing and
3308    /// coherency into one call — `AHardwareBuffer_lock` and
3309    /// `glMapBufferRange` each perform the maintenance *as* they hand out the
3310    /// address, so a separate bracket would have to nest inside a map guard
3311    /// that already owns the pair. Both name `map()`/`map_with()` as the
3312    /// alternative. They previously fell through a wildcard and answered
3313    /// `Ok(())`, which is worse than an error because the symptom is
3314    /// stale-but-plausible pixels rather than a failure. The match is
3315    /// exhaustive so a new backend cannot inherit that silence — it must state
3316    /// its coherency story to compile.
3317    ///
3318    /// # Cost
3319    ///
3320    /// `Mem`/`Shm`: nothing. `Dma` on Linux: one ioctl. IOSurface: one
3321    /// lock/unlock pair. Windows D3D11 texture: one `CopyResource` plus a
3322    /// staging map per direction, and nothing at all on a tensor no one has
3323    /// pinned.
3324    ///
3325    /// # Errors
3326    ///
3327    /// [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`], which
3328    /// describes no access to make coherent; [`Error::CpuAccessDenied`] when
3329    /// the tensor's declared [`CpuAccess`] does not cover `access`.
3330    pub fn sync_for_cpu(&self, access: CpuAccess) -> Result<()> {
3331        self.check_sync_access(access)?;
3332        match &self.storage {
3333            // Plain CPU memory: coherent by construction.
3334            TensorStorage::Mem(_) => Ok(()),
3335            #[cfg(unix)]
3336            TensorStorage::Shm(_) => Ok(()),
3337            #[cfg(target_os = "linux")]
3338            TensorStorage::Dma(d) => {
3339                // The direction tells the kernel which maintenance this access
3340                // needs: a read-only bracket skips the writeback at END, a
3341                // write-only one skips the invalidate at START. It MUST match
3342                // the direction passed to sync_for_device.
3343                crate::dmabuf::sync_access(&d.fd, true, access).map_err(Error::NixError)
3344            }
3345            #[cfg(any(target_os = "macos", target_os = "ios"))]
3346            TensorStorage::Dma(s) => s.sync_for_cpu(access),
3347            // Windows D3D11: refreshes the pinned host buffer from the texture
3348            // through the staging copy.
3349            #[cfg(target_os = "windows")]
3350            TensorStorage::Dma(t) => t.sync_for_cpu(access),
3351            #[cfg(target_os = "android")]
3352            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3353                "sync_for_cpu: AHardwareBuffer has no sync independent of its \
3354                 lock. AHardwareBuffer_lock both establishes the address and \
3355                 performs the cache invalidate, and the NDK leaves locking an \
3356                 already-locked buffer undefined, so a bracket on top of a map \
3357                 guard would nest. Use map()/map_with(), which owns the pair."
3358                    .to_owned(),
3359            )),
3360            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3361                "sync_for_cpu: a PBO has no coherency window independent of its \
3362                 map -- glMapBufferRange establishes the address and the \
3363                 visibility together, and glUnmapBuffer publishes the writes. \
3364                 Use map()/map_with(), which owns the pair."
3365                    .to_owned(),
3366            )),
3367        }
3368    }
3369
3370    /// Release the buffer back to the device — the CPU is done accessing it.
3371    ///
3372    /// Corresponds to `DMA_BUF_SYNC_END`. See
3373    /// [`sync_for_cpu`](Self::sync_for_cpu) for the pairing and cost.
3374    pub fn sync_for_device(&self, access: CpuAccess) -> Result<()> {
3375        self.check_sync_access(access)?;
3376        match &self.storage {
3377            TensorStorage::Mem(_) => Ok(()),
3378            #[cfg(unix)]
3379            TensorStorage::Shm(_) => Ok(()),
3380            #[cfg(target_os = "linux")]
3381            TensorStorage::Dma(d) => {
3382                crate::dmabuf::sync_access(&d.fd, false, access).map_err(Error::NixError)
3383            }
3384            #[cfg(any(target_os = "macos", target_os = "ios"))]
3385            TensorStorage::Dma(s) => s.sync_for_device(access),
3386            // Windows D3D11: uploads the pinned host buffer to the texture.
3387            #[cfg(target_os = "windows")]
3388            TensorStorage::Dma(t) => t.sync_for_device(access),
3389            #[cfg(target_os = "android")]
3390            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3391                "sync_for_device: AHardwareBuffer has no sync independent of \
3392                 its lock — see sync_for_cpu. Use map()/map_with()."
3393                    .to_owned(),
3394            )),
3395            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3396                "sync_for_device: a PBO has no coherency window independent of \
3397                 its map -- see sync_for_cpu. Use map()/map_with()."
3398                    .to_owned(),
3399            )),
3400        }
3401    }
3402
3403    fn check_sync_access(&self, access: CpuAccess) -> Result<()> {
3404        self.cpu_access
3405            .require_direction(access, "sync_for_cpu/sync_for_device")
3406    }
3407
3408    /// Construct a tensor from a 3-D ndarray view. Respects strides — one
3409    /// copy in all cases; contiguous views take a memcpy fast path.
3410    ///
3411    /// Only available when the `ndarray` feature is enabled.
3412    #[cfg(feature = "ndarray")]
3413    pub fn from_arrayview3(view: ndarray::ArrayView3<'_, T>) -> Result<Self>
3414    where
3415        T: Copy,
3416    {
3417        let (h, w, c) = view.dim();
3418        let t = Self::new(&[h, w, c], Some(TensorMemory::Mem), None)?;
3419        {
3420            let mut m = t.map()?;
3421            let dst = m.as_mut_slice();
3422            if let Some(src) = view.as_slice() {
3423                dst.copy_from_slice(src);
3424            } else {
3425                for (d, &s) in dst.iter_mut().zip(view.iter()) {
3426                    *d = s;
3427                }
3428            }
3429        }
3430        Ok(t)
3431    }
3432
3433    /// Create a new tensor with the given shape, memory type, and optional
3434    /// name. If no name is given, a random name will be generated. If no
3435    /// memory type is given, the best available memory type will be chosen
3436    /// based on the platform and environment variables.
3437    ///
3438    /// On Linux platforms, the order of preference is: Dma -> Shm -> Mem.
3439    /// On other Unix platforms (macOS), the order is: Shm -> Mem.
3440    /// On non-Unix platforms, only Mem is available.
3441    ///
3442    /// # Environment Variables
3443    /// - `EDGEFIRST_TENSOR_FORCE_MEM`: If set to a non-zero and non-false
3444    ///   value, forces the use of regular system memory allocation
3445    ///   (`TensorMemory::Mem`) regardless of platform capabilities.
3446    ///
3447    /// # Example
3448    /// ```rust
3449    /// use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
3450    /// # fn main() -> Result<(), Error> {
3451    /// let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
3452    /// assert_eq!(tensor.memory(), TensorMemory::Mem);
3453    /// assert_eq!(tensor.name(), "test_tensor");
3454    /// #    Ok(())
3455    /// # }
3456    /// ```
3457    pub fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
3458        let _span = tracing::trace_span!(
3459            "tensor.alloc",
3460            ?shape,
3461            memory = ?memory,
3462            dtype = std::any::type_name::<T>(),
3463        )
3464        .entered();
3465        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
3466        let mut t = TensorStorage::new(shape, memory, name).map(Self::wrap)?;
3467        // Best-effort: attach a CUDA ExternalMemory handle for DMA tensors on
3468        // CUDA-capable hosts. Never blocks tensor creation on failure.
3469        // RUNTIME-UNVALIDATED: no CUDA+dma_heap test platform available; ABI
3470        // layout-asserted vs. CUDA 12.6 driver_types.h; mechanism proven by
3471        // gpu-probe O5 on Orin.
3472        #[cfg(target_os = "linux")]
3473        t.try_init_dma_cuda();
3474        Ok(t)
3475    }
3476
3477    /// Import an existing buffer as a tensor, taking ownership of its file
3478    /// descriptor. No bytes are copied.
3479    #[cfg(unix)]
3480    pub fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
3481        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
3482        let mut t = Self::wrap(TensorStorage::from_fd(fd, shape, name)?);
3483        // Best-effort CUDA external memory import for DMA-backed tensors.
3484        // RUNTIME-UNVALIDATED: see try_init_dma_cuda().
3485        #[cfg(target_os = "linux")]
3486        t.try_init_dma_cuda();
3487        Ok(t)
3488    }
3489
3490    /// Create an image tensor with the given format.
3491    /// Allocate an image tensor from a declarative request — the
3492    /// full-featured constructor behind [`Self::image`] and friends.
3493    ///
3494    /// Adds the [`Compression`] request to the classic parameters:
3495    ///
3496    /// - a request with any CPU access other than [`CpuAccess::None`] is
3497    ///   [`Error::InvalidArgument`] (CPU mapping pins the layout linear);
3498    /// - [`Compression::Scheme`] fails with [`Error::NotImplemented`] on
3499    ///   platforms without vendor tile compression, and with
3500    ///   [`Error::InvalidArgument`] when the device's native scheme or
3501    ///   the format eligibility doesn't match;
3502    /// - [`Compression::Any`] never fails for compression reasons: it
3503    ///   records the scheme when the allocation is eligible and
3504    ///   otherwise resolves linear, incrementing
3505    ///   [`compression_fallback_count`].
3506    ///
3507    /// The recorded outcome is readable via [`Tensor::compression`].
3508    ///
3509    /// A [`ImageDesc::with_contiguous`] request allocates from the CMA heap
3510    /// only, and fails instead of falling back; [`Tensor::contiguity`]
3511    /// reports the heap.
3512    pub fn image_desc(desc: &ImageDesc) -> Result<Self>
3513    where
3514        T: 'static,
3515    {
3516        let Some(t_dtype) = dtype_of::<T>() else {
3517            return Err(Error::InvalidArgument(
3518                "image_desc: element type has no DType mapping".into(),
3519            ));
3520        };
3521        if t_dtype != desc.dtype {
3522            return Err(Error::InvalidArgument(format!(
3523                "image_desc: desc.dtype is {:?} but the tensor element type is {t_dtype:?}",
3524                desc.dtype
3525            )));
3526        }
3527
3528        // A contiguous request is a CMA DMA-BUF or an error: no fallback to
3529        // another heap or another kind of memory.
3530        if desc.contiguous {
3531            #[cfg(not(target_os = "linux"))]
3532            {
3533                return Err(Error::NotImplemented(
3534                    "image_desc: physically contiguous memory needs the Linux CMA DMA heap".into(),
3535                ));
3536            }
3537            #[cfg(target_os = "linux")]
3538            if !matches!(desc.memory, None | Some(TensorMemory::DmaBuf)) {
3539                return Err(Error::InvalidArgument(format!(
3540                    "image_desc: physically contiguous memory is DMA-BUF from the CMA heap, \
3541                     but {:?} was requested",
3542                    desc.memory
3543                )));
3544            }
3545        }
3546
3547        // Compression-request guards. CPU access pins the layout linear,
3548        // so a request combined with any declared access is a
3549        // contradiction the caller should hear about immediately.
3550        if desc.compression.is_some() && desc.access != CpuAccess::None {
3551            return Err(Error::InvalidArgument(format!(
3552                "image_desc: a compression request requires CpuAccess::None                  (declared {:?}) — CPU mapping pins the layout linear",
3553                desc.access
3554            )));
3555        }
3556        if let Some(Compression::Scheme(requested)) = desc.compression {
3557            #[cfg(not(target_os = "android"))]
3558            {
3559                return Err(Error::NotImplemented(format!(
3560                    "image_desc: Compression::Scheme({requested:?}) — no vendor tile                      compression on this platform (request Compression::Any for a                      portable fallback)"
3561                )));
3562            }
3563            #[cfg(target_os = "android")]
3564            {
3565                if matches!(
3566                    desc.memory,
3567                    Some(TensorMemory::Mem) | Some(TensorMemory::Shm) | Some(TensorMemory::Pbo)
3568                ) {
3569                    return Err(Error::InvalidArgument(format!(
3570                        "image_desc: Compression::Scheme({requested:?}) requires                          hardware memory (TensorMemory::DmaBuf or auto-select), got {:?}",
3571                        desc.memory
3572                    )));
3573                }
3574                if !crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype) {
3575                    return Err(Error::InvalidArgument(format!(
3576                        "image_desc: ({:?}, {:?}) is not compression-eligible                          (RGBA8888 u8/i8 initially)",
3577                        desc.format, desc.dtype
3578                    )));
3579                }
3580                let device = crate::ahardwarebuffer::device_compression_scheme();
3581                if device != Some(requested) {
3582                    return Err(Error::InvalidArgument(format!(
3583                        "image_desc: Compression::Scheme({requested:?}) but the device's                          native scheme is {device:?}"
3584                    )));
3585                }
3586            }
3587        }
3588
3589        // A compression request implies a hardware pipeline, so auto
3590        // memory promotes to the platform's zero-copy allocation first
3591        // (`Tensor::image` only takes the AHardwareBuffer/IOSurface path
3592        // under an explicit Dma request). `Scheme` propagates the Dma
3593        // failure — the caller demanded a layout only that allocator can
3594        // produce; `Any` falls back to plain auto-select.
3595        #[allow(unused_mut)]
3596        let mut t = if desc.contiguous {
3597            Self::image_impl(
3598                desc.width,
3599                desc.height,
3600                desc.format,
3601                Some(TensorMemory::DmaBuf),
3602                desc.access,
3603                true,
3604            )?
3605        } else {
3606            match (desc.memory, desc.compression) {
3607                (None, Some(request)) => {
3608                    match Self::image(
3609                        desc.width,
3610                        desc.height,
3611                        desc.format,
3612                        Some(TensorMemory::DmaBuf),
3613                        desc.access,
3614                    ) {
3615                        Ok(t) => t,
3616                        Err(e) if matches!(request, Compression::Scheme(_)) => return Err(e),
3617                        Err(_) => {
3618                            Self::image(desc.width, desc.height, desc.format, None, desc.access)?
3619                        }
3620                    }
3621                }
3622                (memory, _) => {
3623                    Self::image(desc.width, desc.height, desc.format, memory, desc.access)?
3624                }
3625            }
3626        };
3627
3628        // Record best knowledge / count fallbacks. Only an Android
3629        // hardware-only AHardwareBuffer allocation can actually hold a
3630        // vendor tile layout; everywhere else an Any request resolves
3631        // linear and is counted.
3632        if let Some(request) = desc.compression {
3633            #[cfg(target_os = "android")]
3634            {
3635                let eligible =
3636                    crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype);
3637                let scheme = crate::ahardwarebuffer::device_compression_scheme();
3638                let is_ahb = t.memory() == TensorMemory::DmaBuf;
3639                match (request, scheme) {
3640                    (_, Some(s)) if eligible && is_ahb => {
3641                        t.set_compression_unchecked(Some(s));
3642                    }
3643                    (Compression::Scheme(requested), _) => {
3644                        // Pre-validated eligible + scheme match, so the only
3645                        // way here is the allocation resolving off-AHB.
3646                        return Err(Error::InvalidOperation(format!(
3647                            "image_desc: Compression::Scheme({requested:?}) requested but                              the allocation resolved to {:?} (not an AHardwareBuffer)",
3648                            t.memory()
3649                        )));
3650                    }
3651                    (Compression::Any, _) => {
3652                        note_compression_fallback(&format!(
3653                            "({:?}, {:?}) {}x{}: eligible={eligible}, scheme={scheme:?},                              memory={:?}",
3654                            desc.format,
3655                            desc.dtype,
3656                            desc.width,
3657                            desc.height,
3658                            t.memory()
3659                        ));
3660                    }
3661                }
3662            }
3663            #[cfg(not(target_os = "android"))]
3664            {
3665                debug_assert!(matches!(request, Compression::Any));
3666                let _ = request;
3667                note_compression_fallback(&format!(
3668                    "({:?}, {:?}) {}x{}: no vendor tile compression on this platform",
3669                    desc.format, desc.dtype, desc.width, desc.height
3670                ));
3671            }
3672        }
3673        Ok(t)
3674    }
3675
3676    /// Allocate an image tensor of `width` × `height` in `format`.
3677    ///
3678    /// This is the common-case image constructor. The shape follows from the
3679    /// format's layout — packed `[H, W, C]`, planar `[C, H, W]`, semi-planar
3680    /// `[H + chroma_rows, W]` — and the format is recorded on the tensor, so
3681    /// the image crate and the codec can work from it without out-of-band
3682    /// parameters.
3683    ///
3684    /// `memory` requests a backend, or `None` to auto-select. Auto-select for
3685    /// images is **DMA → Mem**: unlike [`Tensor::new`] it does not try SHM,
3686    /// which offers an image nothing that Mem doesn't (it is not
3687    /// GPU-importable, and Mem always succeeds). Ask for
3688    /// [`TensorMemory::Shm`] explicitly if you need it.
3689    ///
3690    /// `access` declares what the **CPU** intends to do with the buffer and is
3691    /// required; see [`CpuAccess`]. Hardware access — GPU, NPU, ISP, codec —
3692    /// is always implied and never declared. Prefer the narrowest declaration
3693    /// that fits: `None` keeps the allocation eligible for Android vendor tile
3694    /// compression, and anything else pins the layout linear.
3695    ///
3696    /// DMA-backed images always get a **64-byte-aligned row stride**, in every
3697    /// layout, because Mali and Vivante reject an EGLImage import at an
3698    /// unaligned pitch. Read the real pitch back with
3699    /// [`Tensor::effective_row_stride`] rather than assuming `width × bpp`.
3700    ///
3701    /// For a compression request or any other less common knob, build an
3702    /// [`ImageDesc`] and call [`Tensor::image_desc`].
3703    ///
3704    /// # Errors
3705    ///
3706    /// - [`Error::InvalidArgument`] if `width` × `height` is not a valid size
3707    ///   for `format` (for example odd dimensions where the format's chroma
3708    ///   subsampling forbids them), or, on macOS with an explicit
3709    ///   [`TensorMemory::DmaBuf`], if the format cannot be expressed as an
3710    ///   image-formatted IOSurface at this width. The message names the
3711    ///   aligned width to use.
3712    /// - Whatever the chosen backend returns if the allocation itself fails —
3713    ///   for an explicit `memory` request there is no fallback.
3714    ///
3715    /// # Example
3716    ///
3717    /// ```rust
3718    /// use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
3719    ///
3720    /// # fn main() -> Result<(), edgefirst_tensor::Error> {
3721    /// // A camera frame the GPU converts and the CPU never touches.
3722    /// let frame = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12,
3723    ///                                 Some(TensorMemory::Mem), CpuAccess::None)?;
3724    /// assert_eq!(frame.format(), Some(PixelFormat::Nv12));
3725    /// # Ok(())
3726    /// # }
3727    /// ```
3728    pub fn image(
3729        width: usize,
3730        height: usize,
3731        format: PixelFormat,
3732        memory: Option<TensorMemory>,
3733        access: CpuAccess,
3734    ) -> Result<Self>
3735    where
3736        T: 'static,
3737    {
3738        Self::image_impl(width, height, format, memory, access, false)
3739    }
3740
3741    /// [`Tensor::image`], plus the [`ImageDesc::with_contiguous`] request.
3742    /// `contiguous` is only honoured with `Some(TensorMemory::DmaBuf)` on
3743    /// Linux; [`Tensor::image_desc`] checks that before calling.
3744    fn image_impl(
3745        width: usize,
3746        height: usize,
3747        format: PixelFormat,
3748        memory: Option<TensorMemory>,
3749        access: CpuAccess,
3750        contiguous: bool,
3751    ) -> Result<Self>
3752    where
3753        T: 'static,
3754    {
3755        debug_assert!(!contiguous || memory == Some(TensorMemory::DmaBuf));
3756        #[cfg(not(target_os = "linux"))]
3757        let _ = contiguous;
3758        // Shape comes from the shared `PixelFormat::allocation_shape` helper (packed /
3759        // planar / semi-planar NV12·NV16). NV12 supports odd dimensions via the
3760        // `H + ceil(H/2)` combined-plane height.
3761        // The `T: 'static` bound is required by the macOS IOSurface path below.
3762        let shape = format.allocation_shape(width, height).ok_or_else(|| {
3763            Error::InvalidArgument(format!(
3764                "invalid dimensions {width}x{height} for format {format:?}"
3765            ))
3766        })?;
3767
3768        // macOS Dma path: allocate a format-aware IOSurface (FourCC +
3769        // 2D dimensions) so the GL backend can bind it via
3770        // `EGL_ANGLE_iosurface_client_buffer`. Without this, the IOSurface
3771        // would default to a generic byte buffer (FourCC 'L008') and
3772        // ANGLE would reject the import with `EGL_BAD_ATTRIBUTE`.
3773        //
3774        // Guard: IOSurface rounds `bytes_per_row` up to 64-byte alignment.
3775        // If the natural row pitch (`width * channels * sizeof(T)`) is not
3776        // already 64-byte aligned, the padded allocation cannot be mapped
3777        // as a contiguous packed tensor — CPU reads/writes would use the
3778        // wrong stride.
3779        //
3780        // Explicit-Dma contract: when the caller passes
3781        // `Some(TensorMemory::DmaBuf)` they have asked for an
3782        // **image-formatted IOSurface**. Silently downgrading to the
3783        // generic 'L008' byte-bag when alignment fails buries the
3784        // mismatch — the caller only finds out hours later when ANGLE
3785        // (or any GL importer) rejects the bind with
3786        // `EGL_BAD_ATTRIBUTE`. Same anti-pattern bit us previously on
3787        // Mali GPUs with DMA-BUF padding. The right behaviour is to
3788        // fail loudly here with the alignment requirement spelled out
3789        // so the caller can either pick aligned dimensions, request
3790        // SHM/Mem explicitly, or pass `memory=None` for auto-select.
3791        #[cfg(any(target_os = "macos", target_os = "ios"))]
3792        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3793            // For planar formats the IOSurface stacks channels
3794            // vertically (channels * height rows), so the row stride is
3795            // single-channel width * sizeof(T). Packed formats keep the
3796            // natural width * channels * sizeof(T) stride.
3797            let natural_row_bytes = match format.layout() {
3798                PixelLayout::Planar => width * std::mem::size_of::<T>(),
3799                _ => width * format.channels() * std::mem::size_of::<T>(),
3800            };
3801            // A format with a real IOSurface FourCC (RGBA/BGRA/YUYV packed,
3802            // GREY/NV12/NV16/NV24 as R8) tolerates a non-64-aligned natural
3803            // pitch: the surface is allocated with its own 64-aligned
3804            // `bytes_per_row`, the tensor records that stride below, and a CPU
3805            // map iterates rows correctly via the strided-map path while the GL
3806            // import uses the surface's pitch directly — fully zero-copy.
3807            // Planar (the F16 RGBA16F packing) is consumed flat as
3808            // `[1, C, H, W]` with no stride, so it still requires an aligned
3809            // pitch; and formats without a FourCC would fall through to a
3810            // generic byte-bag GL can't bind. Both fail loudly rather than
3811            // silently downgrade.
3812            let has_image_fourcc = dtype_of::<T>()
3813                .and_then(|dt| crate::iosurface_layout::image_iosurface_layout(format, dt))
3814                .is_some();
3815            let padded_ok = has_image_fourcc && format.layout() != PixelLayout::Planar;
3816            if !natural_row_bytes.is_multiple_of(64) && !padded_ok {
3817                let elem_size = std::mem::size_of::<T>();
3818                let per_pixel_bytes = match format.layout() {
3819                    PixelLayout::Planar => elem_size.max(1),
3820                    _ => format.channels().max(1) * elem_size.max(1),
3821                };
3822                // Compute the next 64-byte-aligned width by rounding the
3823                // natural row pitch up to the next multiple of 64 and
3824                // dividing back by per-pixel bytes. This handles every
3825                // `per_pixel_bytes` value correctly:
3826                //
3827                //   * Divisors of 64 (1/2/4/8/16/32/64) → the suggestion
3828                //     is always 64-byte aligned.
3829                //   * Non-divisors of 64 (e.g. RGB u8 with 3 B/pixel) →
3830                //     the next aligned row pitch may not be an integer
3831                //     multiple of per_pixel_bytes (3 doesn't divide 64
3832                //     in any way), so a "pad width to N" suggestion is
3833                //     structurally impossible — omit the suggestion
3834                //     instead of printing a wrong number.
3835                //   * per_pixel_bytes > 64 → same situation, also
3836                //     omitted; the previous formula divided by zero.
3837                //
3838                // The error always names the alignment requirement
3839                // verbatim and lists the two non-DMA alternatives so
3840                // the caller has at least one always-applicable fix.
3841                let aligned_row_bytes = natural_row_bytes.next_multiple_of(64);
3842                let pad_hint =
3843                    if per_pixel_bytes > 0 && aligned_row_bytes.is_multiple_of(per_pixel_bytes) {
3844                        let w = aligned_row_bytes / per_pixel_bytes;
3845                        format!("Pad width to {w} (the next 64-byte-aligned stride), ")
3846                    } else {
3847                        String::new()
3848                    };
3849                return Err(Error::InvalidArgument(format!(
3850                    "Tensor::image: {format:?} {width}x{height} with element \
3851                     size {elem_size} produces a {natural_row_bytes}-byte natural \
3852                     row pitch, which is not 64-byte aligned. \
3853                     IOSurface rounds bytes_per_row up to 64 bytes, so a \
3854                     contiguous CPU map of this tensor would read garbage. \
3855                     {pad_hint}pass memory=None to auto-fall-back to SHM, or \
3856                     pass memory=Some(TensorMemory::Shm) or \
3857                     Some(TensorMemory::Mem) explicitly."
3858                )));
3859            }
3860            // Alignment OK. Explicit-Dma contract: the caller asked for an
3861            // **image-formatted, GL-importable** IOSurface, so every failure
3862            // from here on is loud. The old behaviour fell through to the
3863            // generic 'L008' byte-bag `Some(other)` arm below, and the caller
3864            // only found out when the GL import rejected the bind with
3865            // `EGL_BAD_ATTRIBUTE` — the same silent-downgrade anti-pattern as
3866            // the alignment case above. (Semi-planar/Grey u8 map to 'L008'
3867            // *by design* in `image_iosurface_layout` — the R8-plane
3868            // representation the YUV shaders sample — so they return through
3869            // the mapped path and never reach these errors.)
3870            let dtype = dtype_of::<T>().ok_or_else(|| {
3871                Error::InvalidArgument(format!(
3872                    "Tensor::image: element type {} has no DType, so no \
3873                     image-formatted IOSurface exists. Pass memory=None or \
3874                     Some(TensorMemory::Mem) for a CPU tensor.",
3875                    std::any::type_name::<T>()
3876                ))
3877            })?;
3878            if crate::iosurface_layout::image_iosurface_layout(format, dtype).is_none() {
3879                return Err(Error::InvalidArgument(format!(
3880                    "Tensor::image: no zero-copy IOSurface mapping exists for \
3881                     {format:?}/{dtype:?} on macOS/iOS (supported: \
3882                     Rgba/Rgb @ U8/I8, Bgra/Yuyv/Grey/Nv12/Nv16/Nv24 @ U8, \
3883                     Rgba/PlanarRgb/PlanarRgba @ F16). Pass memory=None to \
3884                     auto-select, or Some(TensorMemory::Mem) explicitly, for \
3885                     a CPU tensor."
3886                )));
3887            }
3888            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3889            // reject a width the texel packing cannot express up front
3890            // (mirrors the Android pre-guard).
3891            if format == PixelFormat::Rgb
3892                && matches!(dtype, DType::U8 | DType::I8)
3893                && packed_rgb888_layout(width, height).is_none()
3894            {
3895                return Err(Error::InvalidArgument(format!(
3896                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3897                     IOSurface packing (got width={width}). Pad the width, or pass \
3898                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3899                )));
3900            }
3901            let storage = TensorStorage::<T>::new_image_iosurface(
3902                width, height, format, dtype, &shape, None,
3903            )?;
3904            let mut t = Self::wrap(storage);
3905            t.format = Some(format);
3906            // IOSurface rounds `bytes_per_row` up to 64 bytes. When that
3907            // pitch exceeds the natural packed/planar row stride, record
3908            // it so CPU consumers iterate rows correctly (the GL import
3909            // already uses the surface's own pitch). For 64-aligned rows
3910            // — the common model-input case — the two match and no stride
3911            // is stored, leaving the flat mapping unchanged.
3912            if let TensorStorage::Dma(ref io) = t.storage {
3913                let bpr = io.bytes_per_row();
3914                if let Some(natural) = t.effective_row_stride() {
3915                    if bpr > natural {
3916                        t.set_row_stride_unchecked(bpr);
3917                    }
3918                }
3919            }
3920            t.cpu_access = access;
3921            return Ok(t);
3922        }
3923
3924        // Android Dma path: allocate a format-aware AHardwareBuffer so the
3925        // GL backend can import it as an EGLImage
3926        // (`eglGetNativeClientBufferANDROID` → `eglCreateImageKHR`).
3927        //
3928        // Geometry: gralloc chooses the row pitch (`desc.stride`) at
3929        // allocation, and pads freely (validated on the Galaxy S26 Ultra,
3930        // where SnapAlloc pads the planar-F16 RGBA16F surface). A padded
3931        // pitch is recorded on the tensor so CPU maps iterate rows via the
3932        // strided-map path; the GL render uses the buffer's own pitch
3933        // through the EGLImage either way, so the GPU path stays fully
3934        // zero-copy. Consumers needing a FLAT layout (the future NPU
3935        // handoff's `[1, C, H, W]` contract) must check `row_stride()` and
3936        // repack when set — flatness is a per-device property here, unlike
3937        // macOS where IOSurface's 64-BYTE alignment keeps model-sized F16
3938        // surfaces naturally flat.
3939        #[cfg(target_os = "android")]
3940        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3941            // Explicit-Dma contract (mirrors the macOS block above): the
3942            // caller asked for an image-formatted, GL-importable
3943            // AHardwareBuffer, so an unmapped combination or a gralloc
3944            // refusal errors here instead of falling through to a BLOB
3945            // byte-bag the GL backend can never import — that downgrade
3946            // only surfaced as a silent per-frame CPU upload.
3947            let dtype = dtype_of::<T>().ok_or_else(|| {
3948                Error::InvalidArgument(format!(
3949                    "Tensor::image: element type {} has no DType, so no \
3950                     image-formatted AHardwareBuffer exists. Pass memory=None \
3951                     or Some(TensorMemory::Mem) for a CPU tensor.",
3952                    std::any::type_name::<T>()
3953                ))
3954            })?;
3955            // Planar F16 requires width % 4 == 0 for the RGBA16F
3956            // packing — reject up front with the requirement spelled
3957            // out (mirrors the macOS pre-allocation guard) instead of
3958            // falling through to a byte-bag GL cannot bind.
3959            if format.layout() == PixelLayout::Planar
3960                && dtype == DType::F16
3961                && packed_rgba16f_layout(format, dtype, width, height).is_none()
3962            {
3963                return Err(Error::InvalidArgument(format!(
3964                    "Tensor::image: {format:?} F16 requires width%4==0 for the RGBA16F \
3965                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3966                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3967                )));
3968            }
3969            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3970            // the same whole-texel constraint as the F16 packing above.
3971            if format == PixelFormat::Rgb
3972                && matches!(dtype, DType::U8 | DType::I8)
3973                && packed_rgb888_layout(width, height).is_none()
3974            {
3975                return Err(Error::InvalidArgument(format!(
3976                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3977                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3978                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3979                )));
3980            }
3981            if crate::ahardwarebuffer::image_ahardwarebuffer_layout(format, dtype).is_none() {
3982                return Err(Error::InvalidArgument(format!(
3983                    "Tensor::image: no zero-copy AHardwareBuffer mapping exists \
3984                     for {format:?}/{dtype:?} on Android (Grey/NV* need \
3985                     R8-format buffers, API 29+; the HAL floor is API 26 — see \
3986                     `image_ahardwarebuffer_layout`). Pass memory=None to \
3987                     auto-select, or Some(TensorMemory::Mem) explicitly, for a \
3988                     CPU tensor; camera NV12 stays zero-copy by wrapping the \
3989                     camera's own AHardwareBuffer instead of allocating one."
3990                )));
3991            }
3992            let storage = TensorStorage::<T>::new_image_ahardwarebuffer(
3993                width, height, format, dtype, &shape, None, access,
3994            )?;
3995            let mut t = Self::wrap(storage);
3996            t.format = Some(format);
3997            if let TensorStorage::Dma(ref ahb) = t.storage {
3998                // gralloc chooses the row pitch (Qualcomm's
3999                // SnapAlloc pads e.g. the 160-px-wide RGBA16F
4000                // surface of a 640-wide planar F16 target).
4001                // When it exceeds the natural stride, record
4002                // it so CPU consumers iterate rows correctly
4003                // via `effective_row_stride()` — the GPU
4004                // renders through the EGLImage at the
4005                // buffer's real pitch regardless, so the
4006                // render stays fully zero-copy. Consumers
4007                // that need the FLAT `[1, C, H, W]` layout
4008                // (the future NPU handoff) must check
4009                // `row_stride()` is unset and repack — or
4010                // pick an aligned width — rather than assume
4011                // flatness (see the module docs).
4012                let bpr = ahb.bytes_per_row();
4013                if let Some(natural) = t.effective_row_stride() {
4014                    if bpr > natural {
4015                        log::debug!(
4016                            "Tensor::image: gralloc padded the {format:?} \
4017                             AHardwareBuffer pitch to {bpr} bytes (natural \
4018                             {natural}); recording row stride"
4019                        );
4020                        t.set_row_stride_unchecked(bpr);
4021                    }
4022                }
4023            }
4024            t.cpu_access = access;
4025            return Ok(t);
4026        }
4027
4028        // Windows Dma path: allocate an `ID3D11Texture2D` on the process
4029        // device so the GL backend can bind it through ANGLE's D3D11 texture
4030        // import and a D3D12/CUDA consumer can open its shared NT handle.
4031        //
4032        // Explicit-Dma contract, as on macOS and Android: the caller asked for
4033        // a texture, so a format with no texture layout is an error here
4034        // rather than a silent fall-through to the host-memory arms below,
4035        // which would only surface much later as a failed GPU import.
4036        #[cfg(target_os = "windows")]
4037        if matches!(memory, Some(TensorMemory::DmaBuf)) {
4038            let dtype = dtype_of::<T>().ok_or_else(|| {
4039                Error::InvalidArgument(format!(
4040                    "Tensor::image: element type {} has no DType, so no D3D11 texture \
4041                     layout exists. Pass memory=None or Some(TensorMemory::Mem) for a \
4042                     CPU tensor.",
4043                    std::any::type_name::<T>()
4044                ))
4045            })?;
4046            let storage = TensorStorage::<T>::new_image_d3d11(
4047                width, height, format, dtype, &shape, None, access,
4048            )?;
4049            let mut t = Self::wrap(storage);
4050            t.format = Some(format);
4051            record_d3d11_row_stride(&mut t, format, "Tensor::image");
4052            t.cpu_access = access;
4053            // Best-effort CUDA import, as the Linux DMA arms do: a texture a
4054            // CUDA consumer can map is worth the one import at allocation.
4055            t.try_init_d3d11_cuda();
4056            return Ok(t);
4057        }
4058
4059        // Compute the **64-byte-aligned** row stride for every image layout.
4060        //
4061        // Embedded GPUs reject `eglCreateImage` DMA-BUF imports whose row pitch
4062        // is not 64-byte aligned: Mali returns `EGL_BAD_ALLOC`, Vivante
4063        // `EGL_BAD_ACCESS`. This bit packed RGBA/RGB destinations at odd widths
4064        // AND at even non-multiple-of-16 widths (e.g. 321→1284, 322→1288 bytes —
4065        // neither divisible by 64), so an odd-source → RGBA convert failed on
4066        // imx95/imx8mp while succeeding on V3D/Tegra. Semi-planar already aligned
4067        // here; we now align packed and planar identically so every image()
4068        // allocation is GPU-importable regardless of width.
4069        //
4070        // The per-layout natural pitch and total row count:
4071        //   * SemiPlanar `[total_h, width]`     — pitch = even(width)·elem, rows = total_h
4072        //   * Packed     `[height, width, ch]`  — pitch = width·ch·elem,    rows = height
4073        //   * Planar     `[ch, height, width]`  — pitch = width·elem,       rows = ch·height
4074        // Allocation byte size = `aligned_stride · total_rows` (NOT the shape
4075        // product, which reflects only the logical width and under-allocates the
4076        // padding on odd / unaligned widths).
4077        let elem = std::mem::size_of::<T>();
4078        let channels = format.channels();
4079        let (natural_stride, total_rows) = match format.layout() {
4080            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
4081            PixelLayout::Packed => (width * channels * elem, height),
4082            PixelLayout::Planar => (width * elem, channels * height),
4083        };
4084        let aligned_stride = natural_stride.next_multiple_of(64);
4085        let semi = format.layout() == PixelLayout::SemiPlanar;
4086
4087        // DMA buffers MUST carry a 64-aligned row pitch — Mali/Vivante reject a
4088        // DMA-BUF EGLImage whose pitch is not 64-aligned. Semi-planar also needs
4089        // the aligned pitch on every backend (its chroma-plane offset math
4090        // assumes it). Packed/planar on host-only memory (Mem/Shm) keep the
4091        // natural tight pitch so the many flat CPU consumers are unaffected.
4092        let host_stride = if semi { aligned_stride } else { natural_stride };
4093        let host_byte_size = host_stride * total_rows;
4094        #[cfg(target_os = "linux")]
4095        let dma_byte_size = aligned_stride * total_rows;
4096
4097        // `used_stride` is the actual row pitch of the storage created below.
4098        let (storage, used_stride) = match memory {
4099            #[cfg(target_os = "linux")]
4100            Some(TensorMemory::DmaBuf) => (
4101                TensorStorage::<T>::new_dma_with_byte_size(
4102                    &shape,
4103                    dma_byte_size,
4104                    None,
4105                    contiguous,
4106                )?,
4107                aligned_stride,
4108            ),
4109            #[cfg(unix)]
4110            Some(TensorMemory::Shm) => (
4111                TensorStorage::<T>::new_shm_with_byte_size(&shape, host_byte_size, None)?,
4112                host_stride,
4113            ),
4114            Some(TensorMemory::Mem) => (
4115                TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
4116                host_stride,
4117            ),
4118            #[allow(unused_variables)]
4119            Some(other) => {
4120                // PBO and any future variants: fall through to standard new().
4121                return {
4122                    let mut t = Self::new(&shape, Some(other), None)?;
4123                    t.format = Some(format);
4124                    t.cpu_access = access;
4125                    Ok(t)
4126                };
4127            }
4128            None => {
4129                // Auto-select priority: DMA → Mem (DMA gets the 64-aligned
4130                // pitch; the Mem fallback keeps the tight host pitch, so the
4131                // recorded stride matches the storage used). Shm is NOT
4132                // auto-selected — it offers no advantage over Mem for an
4133                // in-process image and Mem always succeeds, so it sits below Mem
4134                // and is reached only via an explicit `TensorMemory::Shm`.
4135                #[cfg(target_os = "linux")]
4136                {
4137                    match TensorStorage::<T>::new_dma_with_byte_size(
4138                        &shape,
4139                        dma_byte_size,
4140                        None,
4141                        false,
4142                    ) {
4143                        Ok(s) => (s, aligned_stride),
4144                        Err(_) => (
4145                            TensorStorage::<T>::new_mem_with_byte_size(
4146                                &shape,
4147                                host_byte_size,
4148                                None,
4149                            )?,
4150                            host_stride,
4151                        ),
4152                    }
4153                }
4154                #[cfg(not(target_os = "linux"))]
4155                {
4156                    (
4157                        TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
4158                        host_stride,
4159                    )
4160                }
4161            }
4162        };
4163
4164        let mut t = Self::wrap(storage);
4165        t.format = Some(format);
4166        // Record the row stride when it exceeds the natural tight pitch (padding
4167        // is present — DMA packed/planar at an unaligned width, or always for
4168        // semi-planar), mirroring the IOSurface path above. Aligned-width and
4169        // host-only packed/planar images keep their flat layout with no explicit
4170        // stride; `effective_row_stride()` then falls back to the identical
4171        // computed pitch. When padding IS present, consumers must iterate rows by
4172        // `effective_row_stride()` to skip it.
4173        if semi || used_stride > natural_stride {
4174            t.set_row_stride_unchecked(used_stride);
4175        }
4176        debug_assert!(
4177            t.row_stride.is_some() || !semi,
4178            "image() must always set row_stride for semi-planar tensors"
4179        );
4180        t.cpu_access = access;
4181        #[cfg(target_os = "linux")]
4182        t.try_init_dma_cuda();
4183        Ok(t)
4184    }
4185
4186    /// Create a DMA-backed image tensor with an explicit row stride that
4187    /// may exceed the natural `width * channels * sizeof(T)` pitch.
4188    ///
4189    /// Used for image tensors that need GPU pitch alignment padding: the
4190    /// underlying DMA-BUF is sized to `row_stride * height` bytes, but
4191    /// the tensor's logical shape stays at `[height, width, channels]`.
4192    /// `width()` / `height()` / `shape()` continue to report the
4193    /// user-requested values; the padding is visible only via
4194    /// `row_stride()` / `effective_row_stride()` and is automatically
4195    /// propagated to the GL backend's EGLImage import so Mali Valhall
4196    /// accepts the buffer.
4197    ///
4198    /// # Supported formats
4199    ///
4200    /// Currently only **packed** pixel layouts (RGBA8, BGRA8, RGB888,
4201    /// Grey, etc.) are supported — the formats the GL backend uses as
4202    /// render destinations. Semi-planar formats (NV12, NV16) come from
4203    /// external allocators (camera capture, video decoders) and are
4204    /// imported via `TensorDyn::from_fd` + `set_row_stride`, which
4205    /// already supports padded strides.
4206    ///
4207    /// # Supported memory
4208    ///
4209    /// Currently only `TensorMemory::DmaBuf` is supported. PBO and Mem
4210    /// storage don't go through EGLImage import so they don't need
4211    /// pitch alignment; if you pass any other memory type this returns
4212    /// `NotImplemented`. `None` (auto-select) is treated as `Dma`.
4213    ///
4214    /// # Errors
4215    ///
4216    /// - `InvalidArgument` if `row_stride_bytes < width * channels * sizeof(T)`
4217    ///   (the requested stride would not fit a single row)
4218    /// - `NotImplemented` for non-packed formats or non-DMA memory
4219    /// - `IoError` if the DMA-heap allocation fails (propagated from
4220    ///   `DmaTensor::new_with_byte_size`)
4221    pub fn image_with_stride(
4222        width: usize,
4223        height: usize,
4224        format: PixelFormat,
4225        row_stride_bytes: usize,
4226        memory: Option<TensorMemory>,
4227        access: CpuAccess,
4228    ) -> Result<Self> {
4229        #[cfg(not(target_os = "linux"))]
4230        let _ = access;
4231        // DMA backing (the only thing this constructor produces) is
4232        // Linux-only. On macOS/BSD/Windows the non-Linux block below is
4233        // the only compiled body and returns `NotImplemented` directly;
4234        // on Linux the non-Linux block is cfg-removed and the function
4235        // falls through to the real validation + allocation path. Each
4236        // target compiles exactly one of the two blocks, and the block
4237        // serves as the function's tail expression in both cases — so
4238        // neither needs an explicit `return` (avoids
4239        // `clippy::needless_return` on the macOS CI gate).
4240        #[cfg(not(target_os = "linux"))]
4241        {
4242            let _ = (width, height, format, row_stride_bytes, memory);
4243            Err(Error::NotImplemented(
4244                "image_with_stride requires DMA support (Linux only)".to_owned(),
4245            ))
4246        }
4247
4248        #[cfg(target_os = "linux")]
4249        {
4250            if format.layout() != PixelLayout::Packed {
4251                return Err(Error::NotImplemented(format!(
4252                    "Tensor::image_with_stride only supports packed pixel layouts, got {format:?}"
4253                )));
4254            }
4255            let elem = std::mem::size_of::<T>();
4256            let min_stride = width
4257                .checked_mul(format.channels())
4258                .and_then(|p| p.checked_mul(elem))
4259                .ok_or_else(|| {
4260                    Error::InvalidArgument(format!(
4261                        "image_with_stride: width {width} × channels {} × sizeof::<T>={elem} \
4262                         overflows usize",
4263                        format.channels()
4264                    ))
4265                })?;
4266            if row_stride_bytes < min_stride {
4267                return Err(Error::InvalidArgument(format!(
4268                    "image_with_stride: row_stride {row_stride_bytes} < minimum {min_stride} \
4269                     ({width} px × {} ch × {elem} B)",
4270                    format.channels()
4271                )));
4272            }
4273            let total_byte_size = row_stride_bytes.checked_mul(height).ok_or_else(|| {
4274                Error::InvalidArgument(format!(
4275                    "image_with_stride: row_stride {row_stride_bytes} × height {height} overflows usize"
4276                ))
4277            })?;
4278
4279            let shape = vec![height, width, format.channels()];
4280
4281            let storage = match memory {
4282                Some(TensorMemory::DmaBuf) | None => TensorStorage::<T>::new_dma_with_byte_size(
4283                    &shape,
4284                    total_byte_size,
4285                    None,
4286                    false,
4287                )?,
4288                Some(other) => {
4289                    return Err(Error::NotImplemented(format!(
4290                        "image_with_stride: only TensorMemory::DmaBuf is supported, got {other:?}"
4291                    )));
4292                }
4293            };
4294
4295            let mut t = Self::wrap(storage);
4296            t.format = Some(format);
4297            t.row_stride = Some(row_stride_bytes);
4298            t.cpu_access = access;
4299            // Match new()/from_fd(): a DMA tensor must attempt CUDA external-
4300            // memory import so a strided DMA buffer is also zero-copy
4301            // CUDA-mappable (no-op when libcudart is absent).
4302            t.try_init_dma_cuda();
4303            Ok(t)
4304        }
4305    }
4306
4307    /// Attach format metadata to an existing tensor.
4308    ///
4309    /// # Arguments
4310    ///
4311    /// * `format` - The pixel format to attach
4312    ///
4313    /// # Returns
4314    ///
4315    /// `Ok(())` on success, with the format stored as metadata on the tensor.
4316    ///
4317    /// # Errors
4318    ///
4319    /// Returns `Error::InvalidShape` if the tensor shape is incompatible with
4320    /// the format's layout (packed expects `[H, W, C]`, planar expects
4321    /// `[C, H, W]`, semi-planar expects `[H*k, W]` with format-specific
4322    /// height constraints).
4323    pub fn set_format(&mut self, format: PixelFormat) -> Result<()> {
4324        let shape = self.shape();
4325        match format.layout() {
4326            PixelLayout::Packed => {
4327                if shape.len() != 3 || shape[2] != format.channels() {
4328                    return Err(Error::InvalidShape(format!(
4329                        "packed format {format:?} expects [H, W, {}], got {shape:?}",
4330                        format.channels()
4331                    )));
4332                }
4333            }
4334            PixelLayout::Planar => {
4335                if shape.len() != 3 || shape[0] != format.channels() {
4336                    return Err(Error::InvalidShape(format!(
4337                        "planar format {format:?} expects [{}, H, W], got {shape:?}",
4338                        format.channels()
4339                    )));
4340                }
4341            }
4342            PixelLayout::SemiPlanar => {
4343                if shape.len() != 2 {
4344                    return Err(Error::InvalidShape(format!(
4345                        "semi-planar format {format:?} expects [H*k, W], got {shape:?}"
4346                    )));
4347                }
4348                match format {
4349                    // Combined-plane height is `H + ceil(H/2)` (luma + chroma
4350                    // rows). For even H that is `3H/2` (≡ 0 mod 3); for odd H it
4351                    // is `(3H+1)/2` (≡ 2 mod 3). Only totals ≡ 1 mod 3 are
4352                    // unreachable, so reject just those — odd-height NV12 is
4353                    // valid (e.g. 725 rows for a 483-tall image).
4354                    PixelFormat::Nv12 if shape[0] % 3 == 1 => {
4355                        return Err(Error::InvalidShape(format!(
4356                            "NV12 contiguous shape[0] must be H + ceil(H/2) for some height; \
4357                             {} is unreachable (≡ 1 mod 3)",
4358                            shape[0]
4359                        )));
4360                    }
4361                    PixelFormat::Nv16 if !shape[0].is_multiple_of(2) => {
4362                        return Err(Error::InvalidShape(format!(
4363                            "NV16 contiguous shape[0] must be even, got {}",
4364                            shape[0]
4365                        )));
4366                    }
4367                    // NV24 (4:4:4): combined-plane height is 3H (Y + 2H chroma).
4368                    PixelFormat::Nv24 if !shape[0].is_multiple_of(3) => {
4369                        return Err(Error::InvalidShape(format!(
4370                            "NV24 contiguous shape[0] must be a multiple of 3 (= 3H), got {}",
4371                            shape[0]
4372                        )));
4373                    }
4374                    _ => {}
4375                }
4376            }
4377        }
4378        // Clear stored stride/offset when format changes — they may be invalid
4379        // for the new format. Caller must re-set after changing format.
4380        if self.format != Some(format) {
4381            self.row_stride = None;
4382            self.plane_offset = None;
4383            match self.storage {
4384                TensorStorage::Mem(ref mut m) => m.set_offset(0),
4385                #[cfg(target_os = "linux")]
4386                TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
4387                // Paired with the arm in `set_plane_offset`: a setter that
4388                // takes effect on this backing needs a clear that does too,
4389                // or the wrapper reports `None` while `map()` still starts at
4390                // the old offset -- a stale window rather than a lost one.
4391                // `reshape`'s clear site needs no such arm:
4392                // `IoSurfaceTensor::reshape` zeroes `view_offset` itself
4393                // before the match there runs.
4394                #[cfg(any(target_os = "macos", target_os = "ios"))]
4395                TensorStorage::Dma(ref mut io) => io.view_offset = 0,
4396                // The same pair for D3D11. Unlike `IoSurfaceTensor`,
4397                // `D3d11TextureTensor::reshape` leaves `view_offset` alone, so
4398                // `reshape`'s clear site carries this arm as well.
4399                #[cfg(target_os = "windows")]
4400                TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
4401                // Paired with the arm in `set_plane_offset`: a setter that
4402                // takes effect on this backing needs a clear that does too.
4403                // `reshape`'s clear site needs no `Pbo` arm --
4404                // `PboTensor::reshape` zeroes `view_offset` itself before the
4405                // match there runs, exactly as `IoSurfaceTensor::reshape`
4406                // does. `set_logical_shape` deliberately does not, matching
4407                // `Mem`.
4408                TensorStorage::Pbo(ref mut p) => p.view_offset = 0,
4409                // Same pair, `Shm` side. `cfg(unix)` mirrors the `Shm`
4410                // variant's own gate on `TensorStorage`, not a per-OS
4411                // multiplex -- see the comment on the `set_plane_offset`
4412                // arm.
4413                #[cfg(unix)]
4414                TensorStorage::Shm(ref mut s) => s.set_offset(0),
4415                // No-op, pairing with `set_plane_offset`'s Android arm:
4416                // nothing writes `AHardwareBufferTensor::view_offset` there
4417                // either yet, so there is nothing here for a format change
4418                // to clear. See that arm's comment for the full accounting
4419                // of the still-incomplete gap.
4420                #[cfg(target_os = "android")]
4421                TensorStorage::Dma(_) => {}
4422            }
4423        }
4424        self.format = Some(format);
4425        Ok(())
4426    }
4427
4428    /// Set this tensor's logical dimensions and pixel format to a decoded
4429    /// image, reusing the existing allocation. The shape is derived from the
4430    /// format layout; fails with `Error::InsufficientCapacity` if the
4431    /// allocation cannot hold `width`×`height` in `format`, or
4432    /// `Error::InvalidArgument` if the dimensions are invalid for the format.
4433    ///
4434    /// For NV12/NV16/NV24 the buffer width is rounded up to even (a chroma-plane
4435    /// interleaving requirement); the true odd width is reported by the decoder
4436    /// in `ImageInfo` and trimmed by a `convert()` crop. See
4437    /// [`PixelFormat::allocation_shape`].
4438    ///
4439    /// When the backing has a fixed physical row pitch (an IOSurface's
4440    /// 64-aligned `bytesPerRow`) that exceeds the new format's natural row
4441    /// stride — i.e. a reused max-sized pool tensor reconfigured to a smaller
4442    /// image — the physical pitch is preserved as the tensor's `row_stride`.
4443    /// This keeps the **physical grid** (allocation stride/surface) fixed while
4444    /// the **logical ROI** (this image's W×H) changes, so the decode writes rows
4445    /// at the surface's real stride and the GPU samples them at the same stride.
4446    /// Exact-sized buffers (pitch == natural) stay tightly packed unchanged.
4447    pub fn configure_image(
4448        &mut self,
4449        width: usize,
4450        height: usize,
4451        format: PixelFormat,
4452    ) -> Result<()> {
4453        let shape = format.allocation_shape(width, height).ok_or_else(|| {
4454            Error::InvalidArgument(format!(
4455                "invalid dimensions {width}x{height} for format {format:?}"
4456            ))
4457        })?;
4458        // Capture the pre-existing row stride before `set_format` clears it.
4459        // For pool tensors that were allocated at a larger width (e.g. 1920-wide
4460        // pool decoding a 789-wide image), this preserves the backing pitch so
4461        // rows are still written at the correct physical stride.
4462        let prior_stride = self.row_stride;
4463
4464        self.storage.set_logical_shape(&shape)?;
4465        self.set_format(format)?;
4466        // `set_format` keeps the stride when the format is unchanged; the new
4467        // geometry's stride is chosen below, never inherited by accident.
4468        self.row_stride = None;
4469
4470        // Restore the correct row pitch for the new geometry. A DMA buffer of
4471        // ANY layout, and a semi-planar buffer on any backing, MUST carry a
4472        // 64-byte-aligned pitch: Mali/Vivante reject a DMA-BUF EGLImage whose
4473        // row pitch is not 64-aligned (`EGL_BAD_ALLOC` / `EGL_BAD_ACCESS`), and
4474        // semi-planar chroma-offset math assumes it. This mirrors the pitch
4475        // `Tensor::image()` allocates, so a recycled pool buffer imports
4476        // identically to a fresh one — without it a `configure_image()`'d packed
4477        // buffer (e.g. Y800 96-wide → tight pitch 96) failed convert() on
4478        // imx95/imx8mp via the texture-upload fallback while the fresh oracle
4479        // (aligned 128) succeeded. Packed/planar on host-only memory (Mem/Shm)
4480        // keep the tight pitch so flat CPU consumers stay unaffected — matching
4481        // `image()`'s `host_stride` rule.
4482        let elem = std::mem::size_of::<T>();
4483        let channels = format.channels();
4484        let (min_stride, total_rows) = match format.layout() {
4485            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
4486            PixelLayout::Packed => (width * channels * elem, height),
4487            PixelLayout::Planar => (width * elem, channels * height),
4488        };
4489        let needs_align = self.storage.memory() == TensorMemory::DmaBuf
4490            || format.layout() == PixelLayout::SemiPlanar;
4491
4492        // A backing with its own fixed pitch (IOSurface, AHardwareBuffer, a
4493        // staged D3D11 texture) keeps it whenever it exceeds the new row.
4494        #[cfg(any(
4495            target_os = "macos",
4496            target_os = "ios",
4497            target_os = "android",
4498            target_os = "windows"
4499        ))]
4500        if let Some(pitch) = self.storage.backing_row_stride() {
4501            let active_stride = if pitch > min_stride {
4502                self.set_row_stride_unchecked(pitch);
4503                pitch
4504            } else {
4505                min_stride
4506            };
4507            if !needs_align {
4508                return Ok(());
4509            }
4510            return self.check_configured_stride(active_stride, total_rows);
4511        }
4512
4513        if !needs_align {
4514            return Ok(());
4515        }
4516        // Priority:
4517        //   1. Prior stride (pool reuse): if the pre-existing stride is
4518        //      64-aligned, >= this layout's minimum row, and fits the
4519        //      allocation, keep it. This is the hot-loop reuse case (large
4520        //      pool, small image).
4521        //   2. Compute a fresh 64-aligned stride for the current width.
4522        let aligned = min_stride.next_multiple_of(64);
4523        let capacity = self.storage.capacity_bytes();
4524
4525        let candidate = if let Some(ps) = prior_stride {
4526            if ps >= min_stride && ps % 64 == 0 && ps * total_rows <= capacity {
4527                ps
4528            } else {
4529                aligned
4530            }
4531        } else {
4532            aligned
4533        };
4534
4535        let active_stride = if candidate * total_rows <= capacity {
4536            self.set_row_stride_unchecked(candidate);
4537            candidate
4538        } else {
4539            // The tight row may still fit where the aligned one does not.
4540            min_stride
4541        };
4542        self.check_configured_stride(active_stride, total_rows)
4543    }
4544
4545    /// The last step of [`configure_image`](Self::configure_image) for an
4546    /// aligned layout: rows that do not fit the allocation are refused here,
4547    /// rather than left to fault on a later map or write.
4548    fn check_configured_stride(&self, active_stride: usize, total_rows: usize) -> Result<()> {
4549        let needed = active_stride * total_rows;
4550        let capacity = self.storage.capacity_bytes();
4551        if needed > capacity {
4552            return Err(Error::InsufficientCapacity { needed, capacity });
4553        }
4554        Ok(())
4555    }
4556
4557    /// Allocate an image tensor sized to hold up to `width`×`height` in
4558    /// `format`, reusable for any smaller image via `configure_image`.
4559    pub fn image_with_capacity(
4560        width: usize,
4561        height: usize,
4562        format: PixelFormat,
4563        memory: Option<TensorMemory>,
4564        access: CpuAccess,
4565    ) -> Result<Self>
4566    where
4567        T: 'static,
4568    {
4569        Self::image(width, height, format, memory, access)
4570    }
4571
4572    /// Pixel format (None if not an image).
4573    pub fn format(&self) -> Option<PixelFormat> {
4574        self.format
4575    }
4576
4577    /// The format and the trailing shape dims that describe one image: packed
4578    /// `[H, W, C]`, planar `[C, H, W]` or semi-planar `[H*k, W]`, optionally
4579    /// behind a leading batch `N` (see [`batch`](Self::batch)). `None` when
4580    /// unformatted or when the shape has any other rank --
4581    /// `set_logical_shape` keeps the format on a shape it no longer describes.
4582    fn image_dims(&self) -> Option<(PixelFormat, &[usize])> {
4583        let fmt = self.format?;
4584        let shape = self.shape();
4585        let rank = image_rank(fmt.layout());
4586        let batch_dims = shape.len().checked_sub(rank).filter(|&n| n <= 1)?;
4587        Some((fmt, &shape[batch_dims..]))
4588    }
4589
4590    /// Image width (None if not an image). For a batched `[N, ...]` tensor
4591    /// this is the width of one element.
4592    pub fn width(&self) -> Option<usize> {
4593        let (fmt, dims) = self.image_dims()?;
4594        match fmt.layout() {
4595            PixelLayout::Packed => Some(dims[1]),
4596            PixelLayout::Planar => Some(dims[2]),
4597            PixelLayout::SemiPlanar => Some(dims[1]),
4598        }
4599    }
4600
4601    /// Image height (None if not an image). For a batched `[N, ...]` tensor
4602    /// this is the height of one element.
4603    ///
4604    /// For semi-planar formats the combined-plane shape row count is divided
4605    /// by the format's luma-to-total ratio to recover logical height. This
4606    /// returns the exact logical height (including odd heights) only because
4607    /// the logical dimensions are tracked separately from the physical shape —
4608    /// `configure_image` stores the actual `(width, height)` in the format's
4609    /// `allocation_shape`, which round-trips losslessly via these accessors.
4610    pub fn height(&self) -> Option<usize> {
4611        let (fmt, dims) = self.image_dims()?;
4612        match fmt.layout() {
4613            PixelLayout::Packed => Some(dims[0]),
4614            PixelLayout::Planar => Some(dims[1]),
4615            PixelLayout::SemiPlanar => {
4616                if self.is_multiplane() {
4617                    Some(dims[0])
4618                } else {
4619                    match fmt {
4620                        PixelFormat::Nv12 => Some(dims[0] * 2 / 3),
4621                        PixelFormat::Nv16 => Some(dims[0] / 2),
4622                        PixelFormat::Nv24 => Some(dims[0] / 3),
4623                        _ => None,
4624                    }
4625                }
4626            }
4627        }
4628    }
4629
4630    /// Create from separate Y and UV planes (multiplane NV12/NV16).
4631    pub fn from_planes(luma: Tensor<T>, chroma: Tensor<T>, format: PixelFormat) -> Result<Self> {
4632        if format.layout() != PixelLayout::SemiPlanar {
4633            return Err(Error::InvalidArgument(format!(
4634                "from_planes requires a semi-planar format, got {format:?}"
4635            )));
4636        }
4637        if chroma.format.is_some() || chroma.chroma.is_some() {
4638            return Err(Error::InvalidArgument(
4639                "chroma tensor must be a raw tensor (no format or chroma metadata)".into(),
4640            ));
4641        }
4642        let luma_shape = luma.shape();
4643        let chroma_shape = chroma.shape();
4644        if luma_shape.len() != 2 || chroma_shape.len() != 2 {
4645            return Err(Error::InvalidArgument(format!(
4646                "from_planes expects 2D shapes, got luma={luma_shape:?} chroma={chroma_shape:?}"
4647            )));
4648        }
4649        if luma_shape[1] != chroma_shape[1] {
4650            return Err(Error::InvalidArgument(format!(
4651                "luma width {} != chroma width {}",
4652                luma_shape[1], chroma_shape[1]
4653            )));
4654        }
4655        match format {
4656            PixelFormat::Nv12 => {
4657                if luma_shape[0] % 2 != 0 {
4658                    return Err(Error::InvalidArgument(format!(
4659                        "NV12 requires even luma height, got {}",
4660                        luma_shape[0]
4661                    )));
4662                }
4663                if chroma_shape[0] != luma_shape[0] / 2 {
4664                    return Err(Error::InvalidArgument(format!(
4665                        "NV12 chroma height {} != luma height / 2 ({})",
4666                        chroma_shape[0],
4667                        luma_shape[0] / 2
4668                    )));
4669                }
4670            }
4671            PixelFormat::Nv16 => {
4672                if chroma_shape[0] != luma_shape[0] {
4673                    return Err(Error::InvalidArgument(format!(
4674                        "NV16 chroma height {} != luma height {}",
4675                        chroma_shape[0], luma_shape[0]
4676                    )));
4677                }
4678            }
4679            // NV24's chroma plane is full-resolution (2×-wide interleaved UV),
4680            // which the equal-width plane check above doesn't model. Multiplane
4681            // NV24 is unused (the JPEG decoder emits a contiguous NV24 buffer),
4682            // so it's not supported here yet.
4683            _ => {
4684                return Err(Error::InvalidArgument(format!(
4685                    "from_planes only supports NV12 and NV16 (NV24 multiplane not yet \
4686                     supported — use a contiguous NV24 tensor), got {format:?}"
4687                )));
4688            }
4689        }
4690
4691        Ok(Tensor {
4692            storage: luma.storage,
4693            format: Some(format),
4694            chroma: Some(Box::new(chroma)),
4695            row_stride: luma.row_stride,
4696            plane_offset: luma.plane_offset,
4697            quantization: luma.quantization,
4698            // A multiplane tensor spans two DMA-BUFs (luma + chroma); CUDA
4699            // external-memory import is per-fd, so there is no single device
4700            // pointer for the composite. Any CUDA handle the luma plane carried
4701            // is intentionally dropped — consumers needing CUDA access to
4702            // multiplane data must import each plane independently.
4703            cuda: None,
4704            colorimetry: luma.colorimetry,
4705            cpu_access: luma.cpu_access,
4706            compression: luma.compression,
4707            // A composed multiplane tensor is a whole image, not a sub-view.
4708            view_origin: None,
4709            view_marked: false,
4710        })
4711    }
4712
4713    /// Whether this tensor uses separate plane allocations.
4714    pub fn is_multiplane(&self) -> bool {
4715        self.chroma.is_some()
4716    }
4717
4718    /// Access the chroma plane for multiplane semi-planar images.
4719    pub fn chroma(&self) -> Option<&Tensor<T>> {
4720        self.chroma.as_deref()
4721    }
4722
4723    /// Mutable access to the chroma plane for multiplane semi-planar images.
4724    pub fn chroma_mut(&mut self) -> Option<&mut Tensor<T>> {
4725        self.chroma.as_deref_mut()
4726    }
4727
4728    /// Independent tensor over this plane's allocation, copying geometry
4729    /// metadata. Used to answer `ef_tensor_chroma` from the handle without a
4730    /// client-side shadow field.
4731    pub(crate) fn clone_chroma_plane(&self) -> Result<Self> {
4732        // Every storage accepted by `from_planes` implements `view` as a
4733        // shared-backing lens. Using that common path keeps
4734        // `is_multiplane() => chroma().is_some()` true for SHM, PBO and
4735        // D3D11 too, instead of silently losing chroma when a hand-written
4736        // per-storage clone omits a variant.
4737        self.subview(0, self.shape())
4738    }
4739
4740    /// Row stride in bytes (`None` = tightly packed).
4741    pub fn row_stride(&self) -> Option<usize> {
4742        self.row_stride
4743    }
4744
4745    /// Effective row stride in bytes: the stored stride if set, otherwise the
4746    /// minimum stride computed from the format, width, and element size.
4747    /// Returns `None` only when no format is set and no explicit stride was
4748    /// stored via [`set_row_stride`](Self::set_row_stride).
4749    ///
4750    /// **GREY note:** `effective_row_stride()` for a GREY tensor returns the
4751    /// tight `width` bytes (no padding), which is what `normalize_to_numpy` and
4752    /// the CPU convert path expect. The codec's internal `native_row_stride`
4753    /// (64-byte-aligned) is used only during decoding and is not propagated to
4754    /// the tensor's stored stride, so callers reading via
4755    /// `effective_row_stride()` always see the tight value for GREY.
4756    pub fn effective_row_stride(&self) -> Option<usize> {
4757        if let Some(s) = self.row_stride {
4758            return Some(s);
4759        }
4760        let fmt = self.format?;
4761        let w = self.width()?;
4762        let elem = std::mem::size_of::<T>();
4763        Some(match fmt.layout() {
4764            PixelLayout::Packed => w * fmt.channels() * elem,
4765            PixelLayout::Planar => w * elem,
4766            // Semi-planar: minimum stride must cover the even width so the
4767            // interleaved chroma columns are byte-aligned on odd-width images.
4768            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4769        })
4770    }
4771
4772    /// Copy the tensor's logical bytes into `dst`, compacting away any
4773    /// recorded row-stride padding.
4774    ///
4775    /// The flatness helper for NPU handoff: consumers that need a FLAT
4776    /// layout (e.g. `[1, C, H, W]` for NNAPI/LiteRT when the runtime cannot
4777    /// take a padded pitch) call this when [`row_stride`](Self::row_stride)
4778    /// is `Some` — on a tight tensor it degenerates to one memcpy, so it is
4779    /// safe to call unconditionally. `dst.len()` must equal the tight byte
4780    /// footprint (`shape` product × element size). Zero-copy consumers
4781    /// should prefer the buffer handle + [`Tensor::effective_row_stride`]
4782    /// (Self::effective_row_stride) and skip this copy entirely.
4783    pub fn copy_to_flat(&self, dst: &mut [u8]) -> Result<()> {
4784        let tight_bytes = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(self.shape())?;
4785        if dst.len() != tight_bytes {
4786            return Err(Error::InvalidArgument(format!(
4787                "copy_to_flat: dst is {} bytes but the tensor's tight \
4788                 footprint is {tight_bytes} bytes (shape {:?})",
4789                dst.len(),
4790                self.shape()
4791            )));
4792        }
4793        // Read-only: this helper never writes back into the tensor, so a
4794        // read map suffices — and is required on backends (D3D11) that
4795        // reject a writable map on a tensor allocated CpuAccess::Read.
4796        let map = self.map_read()?;
4797        // SAFETY: T is a plain numeric type (crate-wide bound); viewing the
4798        // mapped elements as bytes is sound.
4799        let src: &[u8] = unsafe {
4800            std::slice::from_raw_parts(
4801                map.as_slice().as_ptr() as *const u8,
4802                std::mem::size_of_val(map.as_slice()),
4803            )
4804        };
4805        let Some(stride) = self.row_stride else {
4806            // Tight layout: the mapped window is exactly the logical bytes.
4807            let Some(tight) = src.get(..tight_bytes) else {
4808                return Err(Error::InvalidOperation(format!(
4809                    "copy_to_flat: mapped {} bytes < tight footprint {tight_bytes}",
4810                    src.len()
4811                )));
4812            };
4813            dst.copy_from_slice(tight);
4814            return Ok(());
4815        };
4816        // Strided: the rows the strided map spans, each holding one tight
4817        // logical row. `rows` is a factor of the element count, so it divides
4818        // the tight footprint exactly.
4819        let rows = strided_rows(self)?;
4820        if rows == 0 {
4821            return Err(Error::InvalidOperation(
4822                "copy_to_flat: a strided tensor with no rows has nothing to copy".into(),
4823            ));
4824        }
4825        let row_bytes = tight_bytes / rows;
4826        let need = (rows - 1)
4827            .checked_mul(stride)
4828            .and_then(|b| b.checked_add(row_bytes))
4829            .ok_or_else(|| {
4830                Error::InvalidOperation(format!(
4831                    "copy_to_flat: stride {stride} × rows {rows} overflows usize"
4832                ))
4833            })?;
4834        if src.len() < need {
4835            return Err(Error::InvalidOperation(format!(
4836                "copy_to_flat: mapped {} bytes but strided rows need {need}",
4837                src.len()
4838            )));
4839        }
4840        for r in 0..rows {
4841            dst[r * row_bytes..(r + 1) * row_bytes]
4842                .copy_from_slice(&src[r * stride..r * stride + row_bytes]);
4843        }
4844        Ok(())
4845    }
4846
4847    /// Set the row stride in bytes for externally allocated buffers with
4848    /// row padding (e.g. V4L2 or GStreamer allocators).
4849    ///
4850    /// The stride is propagated to the EGL DMA-BUF import attributes so
4851    /// the GPU interprets the padded buffer layout correctly. Must be
4852    /// called after [`set_format`](Self::set_format) and before the tensor
4853    /// is first passed to `ImageProcessor::convert`. The stored stride
4854    /// is cleared automatically if the pixel format is later changed.
4855    ///
4856    /// No stride-vs-buffer-size validation is performed because the
4857    /// backing allocation size is not reliably known: external DMA-BUFs
4858    /// may be over-allocated by the allocator, and internal tensors store
4859    /// a logical (unpadded) shape. An incorrect stride will be caught by
4860    /// the EGL driver at import time.
4861    ///
4862    /// # Arguments
4863    ///
4864    /// * `stride` - Row stride in bytes. Must be >= the minimum stride for
4865    ///   the format (width * channels * sizeof(T) for packed,
4866    ///   width * sizeof(T) for planar/semi-planar).
4867    ///
4868    /// # Errors
4869    ///
4870    /// * `InvalidArgument` if no pixel format is set on this tensor
4871    /// * `InvalidArgument` if `stride` is less than the minimum for the
4872    ///   format and width
4873    pub fn set_row_stride(&mut self, stride: usize) -> Result<()> {
4874        let fmt = self.format.ok_or_else(|| {
4875            Error::InvalidArgument("cannot set row_stride without a pixel format".into())
4876        })?;
4877        let w = self.width().ok_or_else(|| {
4878            Error::InvalidArgument("cannot determine width for row_stride validation".into())
4879        })?;
4880        let elem = std::mem::size_of::<T>();
4881        let min_stride = match fmt.layout() {
4882            PixelLayout::Packed => w * fmt.channels() * elem,
4883            PixelLayout::Planar => w * elem,
4884            // Semi-planar: minimum must cover even width for chroma alignment.
4885            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4886        };
4887        if stride < min_stride {
4888            return Err(Error::InvalidArgument(format!(
4889                "row_stride {stride} < minimum {min_stride} for {fmt:?} at width {w}"
4890            )));
4891        }
4892        self.row_stride = Some(stride);
4893        Ok(())
4894    }
4895
4896    /// Set the row stride without format validation.
4897    ///
4898    /// Use this for raw sub-tensors (e.g. chroma planes) that don't carry
4899    /// format metadata. The caller is responsible for ensuring the stride
4900    /// is valid.
4901    pub fn set_row_stride_unchecked(&mut self, stride: usize) {
4902        self.row_stride = Some(stride);
4903    }
4904
4905    /// Builder-style variant of [`set_row_stride`](Self::set_row_stride),
4906    /// consuming and returning `self`.
4907    ///
4908    /// # Errors
4909    ///
4910    /// Same conditions as [`set_row_stride`](Self::set_row_stride).
4911    pub fn with_row_stride(mut self, stride: usize) -> Result<Self> {
4912        self.set_row_stride(stride)?;
4913        Ok(self)
4914    }
4915
4916    /// Byte offset within the DMA-BUF where image data starts (`None` = 0).
4917    pub fn plane_offset(&self) -> Option<usize> {
4918        self.plane_offset
4919    }
4920
4921    /// The parent-image snapshot if this tensor is a [`view`](Self::view)/
4922    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. The GL
4923    /// backend keys its import on the parent geometry and renders this view as a
4924    /// `glViewport`/`glScissor` ROI at `(x, y, width, height)`. See [`ViewOrigin`].
4925    pub fn view_origin(&self) -> Option<ViewOrigin> {
4926        self.view_origin
4927    }
4928
4929    /// Whether this tensor addresses part of a larger buffer: a
4930    /// [`view`](Self::view)/[`batch`](Self::batch) with a [`ViewOrigin`], or a
4931    /// tensor rebuilt from such a view's descriptor, which carries none.
4932    ///
4933    /// A view's rows share cache lines with bytes outside it, so a CPU pass
4934    /// that writes it maps `ReadWrite` (see [`TensorTrait::write_access`]).
4935    pub fn is_view(&self) -> bool {
4936        self.view_origin.is_some() || self.view_marked
4937    }
4938
4939    /// Record that this tensor addresses part of a larger buffer, as
4940    /// [`is_view`](Self::is_view) reports. The descriptor import calls this
4941    /// for a producer that was a view; it is never cleared.
4942    pub fn mark_view(&mut self) {
4943        self.view_marked = true;
4944    }
4945
4946    /// Set the byte offset within the DMA-BUF where image data starts.
4947    ///
4948    /// Propagated to `EGL_DMA_BUF_PLANE0_OFFSET_EXT` on GPU import.
4949    /// Unlike [`set_row_stride`](Self::set_row_stride), no format is required
4950    /// since the offset is format-independent.
4951    pub fn set_plane_offset(&mut self, offset: usize) {
4952        self.plane_offset = Some(offset);
4953        // The offset consulted by `map()` lives inside the storage variant.
4954        // Keep it in sync with the wrapper field for every backing that
4955        // honors it; see also the clear sites in `set_format` and `reshape`.
4956        //
4957        // Note that `TensorStorage::Dma` is a cfg-multiplexed *name* rather
4958        // than one type -- `DmaTensor` on Linux, `IoSurfaceTensor` on
4959        // macOS/iOS, `AHardwareBufferTensor` on Android,
4960        // `D3d11TextureTensor` on Windows -- so each platform needs its own
4961        // arm below; a missing one would silently become a fall-through
4962        // rather than a compile error, which is how macOS went without one
4963        // for a while. The match is kept exhaustive (no `_ => {}`)
4964        // specifically so that hazard is a compile error, not a silent gap.
4965        match self.storage {
4966            TensorStorage::Mem(ref mut m) => m.set_offset(offset),
4967            #[cfg(target_os = "linux")]
4968            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = offset,
4969            // The offset `IoSurfaceTensor::scoped_pin`/`map_inner` add to the
4970            // locked base address. Writing it here is what makes a
4971            // *reconstructed* view address its own sub-region instead of the
4972            // parent surface's origin; `IoSurfaceTensor::view` already set it
4973            // on the fresh-view path, which is why only the descriptor round
4974            // trip was ever wrong. Idempotent against `subview`, which sets
4975            // the same absolute value by both routes -- pinned by
4976            // `nested_iosurface_subviews_do_not_compound_their_plane_offset`.
4977            #[cfg(any(target_os = "macos", target_os = "ios"))]
4978            TensorStorage::Dma(ref mut io) => io.view_offset = offset,
4979            // The offset every `D3d11TextureTensor` pin adds to its host or
4980            // staging base, in backing (pitched) space like `row_stride()`.
4981            // `view()` bounds-checks the value it computes; this route does
4982            // not, so the pins refuse an offset past the backing instead
4983            // (`check_view_offset` in `d3d11/texture.rs`). Pinned by the
4984            // `d3d11` plane-offset tests in `crates/tensor/tests`.
4985            #[cfg(target_os = "windows")]
4986            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(offset),
4987            // The offset `PboTensor::scoped_pin` adds to the mapped base.
4988            // Writing it here is what makes a *reconstructed* view address
4989            // its own sub-region instead of the parent buffer's origin;
4990            // `PboTensor::view` already set it on the fresh-view path, which
4991            // is why only the round trip was ever wrong -- the same shape
4992            // IOSurface had. Idempotent against `subview`, which sets the
4993            // same absolute value by both routes -- pinned by
4994            // `nested_pbo_subviews_do_not_compound_their_plane_offset`.
4995            //
4996            // Not `cfg`-gated, and deliberately so: unlike
4997            // `TensorStorage::Dma` -- a cfg-multiplexed *name* whose gated
4998            // arm silently becomes `_ => {}` on the other targets, the
4999            // mechanism behind this whole issue class -- `TensorStorage::Pbo`
5000            // is one type on every platform.
5001            TensorStorage::Pbo(ref mut p) => p.view_offset = offset,
5002            // The offset `ShmTensor::map_inner`/`host_pin` add to the mmap
5003            // base, exactly like `MemTensor::offset`. `cfg(unix)` here
5004            // mirrors `TensorStorage::Shm`'s own gate on the enum
5005            // definition -- it is not a per-OS multiplex the way
5006            // `TensorStorage::Dma` is, so this arm cannot silently become
5007            // `_ => {}` on a target where the `Shm` variant still exists.
5008            #[cfg(unix)]
5009            TensorStorage::Shm(ref mut s) => s.set_offset(offset),
5010            // STILL INCOMPLETE: `AHardwareBufferTensor` (Android) has a
5011            // `view_offset` that its own `map()` adds and its own `view()`
5012            // sets, but nothing writes it here yet, so restoring an offset
5013            // onto an already-built tensor is silently a no-op on this
5014            // backing -- a caller gets a map at the parent's origin. Not
5015            // reachable through `TensorDyn::import_descriptor` today
5016            // (`import_storage`'s DMABUF arm is `cfg(target_os = "linux")`),
5017            // so it is latent rather than silent. See
5018            // `edgefirst-python-common`'s `interop::apply_plane_offset` for
5019            // the full per-backing accounting.
5020            #[cfg(target_os = "android")]
5021            TensorStorage::Dma(_) => {}
5022        }
5023    }
5024
5025    /// The CPU access declared for this tensor (see [`CpuAccess`]): set at
5026    /// allocation, possibly narrowed by
5027    /// [`restrict_cpu_access`](Self::restrict_cpu_access). A view copies its
5028    /// parent's declaration when it is taken.
5029    pub fn cpu_access(&self) -> CpuAccess {
5030        self.cpu_access
5031    }
5032
5033    /// Narrow the declared CPU access to the directions both it and `access`
5034    /// cover, on the chroma plane too. Never widens: a declaration must not
5035    /// exceed what the allocation was made for, so a wider `access` leaves
5036    /// it unchanged. An importer uses this to carry a producer's declaration
5037    /// onto an alias of the same buffer.
5038    ///
5039    /// ```
5040    /// # use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
5041    /// # fn main() -> Result<(), edgefirst_tensor::Error> {
5042    /// let mut t = Tensor::<u8>::image(4, 4, PixelFormat::Rgba, Some(TensorMemory::Mem), CpuAccess::ReadWrite)?;
5043    /// t.restrict_cpu_access(CpuAccess::Read);
5044    /// assert_eq!(t.cpu_access(), CpuAccess::Read);
5045    /// t.restrict_cpu_access(CpuAccess::ReadWrite);
5046    /// assert_eq!(t.cpu_access(), CpuAccess::Read);
5047    /// # Ok(())
5048    /// # }
5049    /// ```
5050    pub fn restrict_cpu_access(&mut self, access: CpuAccess) {
5051        self.cpu_access = self.cpu_access.intersection(access);
5052        if let Some(chroma) = self.chroma.as_mut() {
5053            chroma.restrict_cpu_access(access);
5054        }
5055    }
5056
5057    /// Set the declared CPU access without re-allocating — crate-private:
5058    /// the declaration must reflect the underlying allocation's real
5059    /// capabilities (constructors and importers set it; arbitrary widening
5060    /// would defeat the contract).
5061    // Only the Android AHardwareBuffer importer derives a declaration from usage bits.
5062    #[cfg(target_os = "android")]
5063    pub(crate) fn set_cpu_access_unchecked(&mut self, access: CpuAccess) {
5064        self.cpu_access = access;
5065    }
5066
5067    /// The vendor tile-compression scheme recorded at allocation, or
5068    /// `None` for a linear layout. `Some` means the pixels live in a
5069    /// proprietary tile order: the row-stride accessors describe no
5070    /// meaningful linear layout and CPU maps are best-effort (see
5071    /// [`Compression`]). Only Android hardware-only allocations that
5072    /// requested compression record a scheme.
5073    pub fn compression(&self) -> Option<CompressionScheme> {
5074        self.compression
5075    }
5076
5077    /// Whether this tensor's memory is known to be physically contiguous:
5078    /// [`Contiguity::Contiguous`] for a DMA-BUF this crate allocated from
5079    /// the CMA heap, [`Contiguity::NonContiguous`] for one from the system
5080    /// heap, and [`Contiguity::Unknown`] for an imported fd and every other
5081    /// kind of memory.
5082    pub fn contiguity(&self) -> Contiguity {
5083        match &self.storage {
5084            #[cfg(target_os = "linux")]
5085            TensorStorage::Dma(d) => d.contiguity,
5086            _ => Contiguity::Unknown,
5087        }
5088    }
5089
5090    /// Record the compression scheme — crate-private: recording is an
5091    /// allocation-time fact ([`Tensor::image_desc`] sets it; arbitrary
5092    /// mutation would misdescribe the physical layout).
5093    // Only the Android allocation path records a scheme.
5094    #[cfg(target_os = "android")]
5095    pub(crate) fn set_compression_unchecked(&mut self, scheme: Option<CompressionScheme>) {
5096        self.compression = scheme;
5097    }
5098
5099    pub fn colorimetry(&self) -> Option<crate::Colorimetry> {
5100        self.colorimetry
5101    }
5102
5103    /// Attach/clear colorimetry metadata.
5104    pub fn set_colorimetry(&mut self, c: Option<crate::Colorimetry>) {
5105        self.colorimetry = c;
5106    }
5107
5108    /// Builder-style colorimetry attach.
5109    pub fn with_colorimetry(mut self, c: crate::Colorimetry) -> Self {
5110        self.colorimetry = Some(c);
5111        self
5112    }
5113
5114    /// Create a zero-copy sub-region view of this tensor's backing buffer.
5115    ///
5116    /// The returned tensor shares this tensor's allocation (no copy) and maps
5117    /// the window `[offset_bytes, offset_bytes + shape.product()*size_of::<T>())`
5118    /// measured from this tensor's own logical start. N sub-views into one
5119    /// parent can be written independently, enabling batched assembly into a
5120    /// single buffer. Identical semantics across `Mem` (shared `Arc`) and
5121    /// `Dma` (shared fd) backings.
5122    ///
5123    /// # Disjointness
5124    ///
5125    /// Independent writes are sound *only* when the windows do not overlap. The
5126    /// shared backing uses interior mutability (`UnsafeCell` cells), so two
5127    /// sub-views whose byte ranges intersect alias the same cells: writing one
5128    /// while reading or writing the other is a data race and therefore
5129    /// **undefined behaviour**. The caller is responsible for keeping the
5130    /// windows disjoint; this method does not check for overlap.
5131    ///
5132    /// # Errors
5133    ///
5134    /// - [`Error::InvalidOperation`] if the backing is not `Mem` or `Dma`, or
5135    ///   if `offset_bytes` is not a multiple of `align_of::<T>()`.
5136    /// - [`Error::InsufficientCapacity`] / [`Error::InvalidSize`] if the window
5137    ///   exceeds the parent allocation.
5138    pub(crate) fn subview(&self, offset_bytes: usize, shape: &[usize]) -> Result<Tensor<T>> {
5139        // Offset is absolute into the backing allocation: a sub-view of a
5140        // sub-view composes by adding this tensor's own offset.
5141        let abs_offset = self
5142            .plane_offset
5143            .unwrap_or(0)
5144            .checked_add(offset_bytes)
5145            .ok_or(Error::InvalidSize(offset_bytes))?;
5146        // Every backend exposes `view(offset, shape)` via `TensorTrait`, sharing
5147        // the resource AND `BufferIdentity` (unlike `from_fd`/`from_surface`,
5148        // which mint a fresh identity). The GL backend keys the import on the
5149        // shared identity so offset-distinct sub-views of one buffer reuse a
5150        // single import and address their window via `glViewport`. `Mem`/`Shm`
5151        // share via the allocation `Arc` / a cloned fd; `Pbo` via the GL-buffer
5152        // `Arc`; Linux DMA-BUF / macOS IOSurface via the shared fd / CFRetain.
5153        // `TensorStorage::view` performs the one remaining per-variant dispatch.
5154        let mut t = Tensor::wrap(self.storage.view(offset_bytes, shape)?);
5155        // Inherit the parent's image metadata so the view is a ready-to-use
5156        // sub-image (e.g. a `convert()` destination). The offset is applied
5157        // LAST because `set_format` deliberately clears it — the offset is a
5158        // structural property of the sub-region, not format-dependent metadata.
5159        if let Some(fmt) = self.format {
5160            t.set_format(fmt)?;
5161        }
5162        if let Some(rs) = self.row_stride {
5163            t.set_row_stride_unchecked(rs);
5164        }
5165        t.quantization = self.quantization.clone();
5166        // A sub-region of an image carries the parent's colorimetry — it is the
5167        // same pixels, same color encoding. Inherit it like the other image
5168        // metadata above so a sub-view is a faithful convert() source/target.
5169        t.set_colorimetry(self.colorimetry);
5170        // The declared CPU access is a property of the underlying
5171        // allocation, so every view shares the parent's declaration.
5172        t.cpu_access = self.cpu_access;
5173        // Likewise the recorded compression scheme: the view shares the
5174        // parent's physical layout.
5175        t.compression = self.compression;
5176        t.view_marked = self.is_view();
5177        if abs_offset > 0 {
5178            t.set_plane_offset(abs_offset);
5179        }
5180        Ok(t)
5181    }
5182
5183    /// Borrow batch element `n` of a batched tensor as a zero-copy view.
5184    ///
5185    /// A batched tensor prepends `N` as the leading dimension over the
5186    /// per-element image layout (`[N, H, W, C]` packed or `[N, C, H, W]`
5187    /// planar) — `N` is over the whole per-element block regardless of
5188    /// `HWC`/`CHW`. `batch(n)` returns element `n`: the contiguous per-element
5189    /// region at byte offset `n * element_size`, sharing the parent's
5190    /// `BufferIdentity` and inheriting its format / row stride / colorimetry.
5191    /// `batch(0)` on a tensor with `N == 1` is equivalent to the whole tensor.
5192    ///
5193    /// # Errors
5194    ///
5195    /// - [`Error::BatchIndexOutOfBounds`] if `n >= N`.
5196    /// - [`Error::InvalidShape`] if the tensor is not batched (a formatted
5197    ///   tensor whose rank lacks the leading `N`, or an empty shape).
5198    pub fn batch(&self, n: usize) -> Result<Tensor<T>> {
5199        let shape = self.shape();
5200        // With a format we know the exact per-element rank, so a missing leading
5201        // `N` is a misuse we reject rather than silently treating a spatial dim
5202        // as the batch. Raw tensors take shape[0] as `N` by contract.
5203        if let Some(fmt) = self.format {
5204            let elem_rank = image_rank(fmt.layout());
5205            if shape.len() != elem_rank + 1 {
5206                return Err(Error::InvalidShape(format!(
5207                    "batch(): tensor is not batched ({fmt:?} expects a leading N over a \
5208                     {elem_rank}-D element, got shape {shape:?})"
5209                )));
5210            }
5211        }
5212        let batch = *shape
5213            .first()
5214            .ok_or_else(|| Error::InvalidShape("batch(): empty shape".into()))?;
5215        if n >= batch {
5216            return Err(Error::BatchIndexOutOfBounds { index: n, batch });
5217        }
5218        let elem_shape: Vec<usize> = shape[1..].to_vec();
5219        let elem_count: usize = elem_shape.iter().product();
5220        elem_count
5221            .checked_mul(std::mem::size_of::<T>())
5222            .ok_or(Error::InvalidSize(elem_count))?;
5223        // One element's footprint including row padding: the descriptor's
5224        // stride for `N`, so a padded batch is addressed the way it is mapped.
5225        let dims: Vec<u64> = shape.iter().map(|&d| d as u64).collect();
5226        let elem_bytes = crate::protocol::c_byte_strides(
5227            &dims,
5228            std::mem::size_of::<T>() as i64,
5229            self.format.map(|f| f.layout()),
5230            self.row_stride,
5231        )[0] as usize;
5232        let offset = n.checked_mul(elem_bytes).ok_or(Error::InvalidSize(n))?;
5233        // For a packed `[N, H, W, C]` tensor the N tiles stack vertically in the
5234        // shared buffer, so the GL import sees one `(W, N*H)` parent and each
5235        // tile is the row-band at `y = n*H`. Snapshot that parent so the backend
5236        // imports once and renders the tile via `glViewport`. Non-packed
5237        // (planar/semi-planar) batching keeps the per-slot path for now (planar
5238        // NCHW tiling is a separate step); raw tensors have no pixel geometry.
5239        let view_origin = match self.format.map(|f| f.layout()) {
5240            Some(PixelLayout::Packed) => {
5241                let tile_h = elem_shape[0];
5242                let tile_w = elem_shape[1];
5243                // Per-row pitch of the tall `(W, N*H)` parent — padded stride if
5244                // set, else the tight row width. The GL import keys on this.
5245                let parent_stride = self
5246                    .effective_row_stride()
5247                    .expect("a formatted tensor of its batched rank has a width");
5248                Some(self.compose_view_origin(tile_w, batch * tile_h, parent_stride, 0, n * tile_h))
5249            }
5250            _ => None,
5251        };
5252        let mut t = self.subview(offset, &elem_shape)?;
5253        t.view_origin = view_origin;
5254        Ok(t)
5255    }
5256
5257    /// Borrow a rectangular spatial sub-region of an image tensor as a
5258    /// zero-copy view — the **destination/source crop** primitive.
5259    ///
5260    /// `region` is in pixels of the image's leading frame. The returned view
5261    /// shares the parent's `BufferIdentity` and addresses the sub-rectangle by
5262    /// offset + the **parent's** row pitch (so each row lands at the correct
5263    /// columns). `convert(src, &mut dst.view(rect), …)` renders into that
5264    /// sub-rectangle of `dst`; a letterbox fit then clears the view and renders
5265    /// the aspect-preserved content into its inner region. `view`/`batch`/the
5266    /// whole tensor are the one coherent destination model — there is no
5267    /// separate `dst_rect`.
5268    ///
5269    /// # Known limitation: bottom-right multi-row views
5270    ///
5271    /// A multi-row view is mapped as `parent_pitch × rows` bytes **from its own
5272    /// offset**, so the window it asks for overruns the allocation by
5273    /// `region.x * bpp` whenever the view is both offset horizontally *and*
5274    /// reaches the parent's last row. Such a view constructs fine but fails at
5275    /// [`map`](TensorTrait::map) time with [`Error::InsufficientCapacity`] —
5276    /// loudly, never as an out-of-bounds access. For a tiled destination this is
5277    /// the bottom row of tiles at every column but the first. The single-row
5278    /// case is already special-cased below (it keeps its own tight stride); the
5279    /// general fix is to clamp the last row's exposure to the view's own row
5280    /// bytes, which is not yet implemented.
5281    ///
5282    /// # Errors
5283    ///
5284    /// - [`Error::RegionOutOfBounds`] if `region` exceeds the image bounds.
5285    /// - [`Error::InvalidOperation`] if the tensor is not a packed-format image
5286    ///   (planar/semi-planar spatial sub-rects are not a single strided window;
5287    ///   use [`batch`](Self::batch) for batched planar tensors).
5288    pub fn view(&self, region: Region) -> Result<Tensor<T>> {
5289        let fmt = self.format.ok_or_else(|| {
5290            Error::InvalidOperation("view() requires a formatted image tensor".into())
5291        })?;
5292        if fmt.layout() != PixelLayout::Packed {
5293            return Err(Error::InvalidOperation(format!(
5294                "view() supports packed formats only (got {fmt:?}); use batch(n) for batched \
5295                 planar tensors"
5296            )));
5297        }
5298        let w = self
5299            .width()
5300            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image width".into()))?;
5301        let h = self
5302            .height()
5303            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image height".into()))?;
5304        if !region.fits_within(w, h) {
5305            return Err(Error::RegionOutOfBounds {
5306                region,
5307                bounds: (w, h),
5308            });
5309        }
5310        let elem = std::mem::size_of::<T>();
5311        let bpp = fmt.channels() * elem;
5312        let stride = self
5313            .effective_row_stride()
5314            .expect("a tensor with an image width has a row stride");
5315        let offset = region
5316            .y
5317            .checked_mul(stride)
5318            .and_then(|yo| yo.checked_add(region.x.checked_mul(bpp)?))
5319            .ok_or(Error::InvalidSize(region.y))?;
5320        let sub_shape = fmt
5321            .allocation_shape(region.width, region.height)
5322            .ok_or_else(|| Error::InvalidShape(format!("view(): invalid shape for {fmt:?}")))?;
5323        let mut t = self.subview(offset, &sub_shape)?;
5324        // A multi-row sub-rect must advance rows by the PARENT pitch so each row
5325        // addresses the correct columns. A single-row view uses its own tight
5326        // stride — the parent pitch would make the strided `map()` (which exposes
5327        // `stride × rows`) expose a trailing row that runs past the buffer tail
5328        // for an offset (x>0 / bottom) view. The GL backend does NOT rely on this
5329        // (single-row-tight) `row_stride`: it reads the parent pitch from
5330        // `view_origin.parent_row_stride` so its import/cache pitch stays
5331        // parent-consistent for views of any height — see `ViewOrigin`.
5332        let view_stride = if region.height > 1 {
5333            stride
5334        } else {
5335            region.width * bpp
5336        };
5337        t.set_row_stride_unchecked(view_stride);
5338        // Snapshot the parent `(w, h, row_stride)` so the GL backend imports the
5339        // parent once (keyed on the parent pitch, not this view's possibly-tight
5340        // single-row stride) and renders this sub-rect as a `glViewport`/
5341        // `glScissor` ROI at `(region.x, region.y)`. Composes when viewing an
5342        // existing view.
5343        t.view_origin = Some(self.compose_view_origin(w, h, stride, region.x, region.y));
5344        Ok(t)
5345    }
5346
5347    /// Build the [`ViewOrigin`] for a new sub-region of `self`. When `self` is a
5348    /// whole tensor the snapshot names `self` as the parent; when `self` is
5349    /// already a view, the snapshot keeps the **root** parent and accumulates the
5350    /// local origin so nested views still resolve to one import.
5351    fn compose_view_origin(
5352        &self,
5353        parent_width: usize,
5354        parent_height: usize,
5355        parent_row_stride: usize,
5356        x: usize,
5357        y: usize,
5358    ) -> ViewOrigin {
5359        match self.view_origin {
5360            Some(root) => ViewOrigin {
5361                parent_width: root.parent_width,
5362                parent_height: root.parent_height,
5363                parent_row_stride: root.parent_row_stride,
5364                x: root.x.saturating_add(x),
5365                y: root.y.saturating_add(y),
5366            },
5367            None => ViewOrigin {
5368                parent_width,
5369                parent_height,
5370                parent_row_stride,
5371                x,
5372                y,
5373            },
5374        }
5375    }
5376
5377    /// The wrapped `PboTensor`, for this module's own PBO accessors.
5378    ///
5379    /// Crate-private: `PboTensor<T>` is a `static`-side value with no
5380    /// `dynamic` counterpart to lend (there the buffer lives inside
5381    /// `libedgefirst_tensor.so`), so a public borrow of it could not have
5382    /// the same signature on both backends. Callers use
5383    /// [`Self::pbo_id`]/[`Self::pbo_is_mapped`], which are the whole surface
5384    /// `edgefirst-image` ever used.
5385    pub(crate) fn as_pbo(&self) -> Option<&PboTensor<T>> {
5386        match &self.storage {
5387            TensorStorage::Pbo(p) => Some(p),
5388            _ => None,
5389        }
5390    }
5391
5392    /// The GL buffer ID backing this tensor (for GL backends / the
5393    /// cross-package protocol's `handle`). `None` when the tensor is not
5394    /// PBO-backed.
5395    pub fn pbo_id(&self) -> Option<u32> {
5396        self.as_pbo().map(PboTensor::buffer_id)
5397    }
5398
5399    /// Whether this PBO currently holds -- or is establishing -- a CPU
5400    /// mapping. `None` when the tensor is not PBO-backed.
5401    ///
5402    /// The question a GL caller is really asking is "is this buffer free
5403    /// for GL operations?", so a map or unmap still in flight answers
5404    /// `Some(true)`: the GL side must not touch the buffer until the
5405    /// transition finishes. See [`PboTensor::is_mapped`].
5406    pub fn pbo_is_mapped(&self) -> Option<bool> {
5407        self.as_pbo().map(PboTensor::is_mapped)
5408    }
5409
5410    /// The C-ABI `PboOpsVtable` address backing this tensor (for the
5411    /// cross-package protocol's `ptr` under `kind::PBO`; see
5412    /// [`crate::protocol::TensorDesc::ptr`]'s doc comment). `None` when the
5413    /// tensor is not PBO-backed.
5414    pub fn pbo_vtable_ptr(&self) -> Option<*const std::ffi::c_void> {
5415        self.as_pbo()
5416            .map(|p| p.pbo_vtable() as *const _ as *const std::ffi::c_void)
5417    }
5418
5419    /// A type-erased keepalive that must stay alive for at least as long as
5420    /// [`Self::pbo_vtable_ptr`]'s address is used -- see
5421    /// [`PboTensor::pbo_keepalive`]'s own doc comment. `None` when the
5422    /// tensor is not PBO-backed.
5423    pub fn pbo_keepalive(&self) -> Option<Arc<dyn Send + Sync>> {
5424        self.as_pbo().map(PboTensor::pbo_keepalive)
5425    }
5426
5427    /// Downcast to DMA tensor reference (for EGL import, G2D).
5428    #[cfg(target_os = "linux")]
5429    pub fn as_dma(&self) -> Option<&DmaTensor<T>> {
5430        match &self.storage {
5431            TensorStorage::Dma(d) => Some(d),
5432            _ => None,
5433        }
5434    }
5435
5436    /// Borrow the DMA-BUF file descriptor backing this tensor.
5437    ///
5438    /// # Returns
5439    ///
5440    /// A borrowed reference to the DMA-BUF file descriptor, tied to `self`'s
5441    /// lifetime.
5442    ///
5443    /// # Errors
5444    ///
5445    /// Returns `Error::NotImplemented` if the tensor is not DMA-backed.
5446    #[cfg(target_os = "linux")]
5447    pub fn dmabuf(&self) -> Result<std::os::fd::BorrowedFd<'_>> {
5448        use std::os::fd::AsFd;
5449        match &self.storage {
5450            TensorStorage::Dma(dma) => Ok(dma.fd.as_fd()),
5451            _ => Err(Error::NotImplemented(format!(
5452                "dmabuf requires DMA-backed tensor, got {:?}",
5453                self.storage.memory()
5454            ))),
5455        }
5456    }
5457
5458    /// Construct a Tensor from a PBO tensor (for GL backends that allocate PBOs).
5459    ///
5460    /// Infallible in this backend (`Ok` always) -- kept as `Result` to match
5461    /// `dynamic`'s own `Tensor::from_pbo` (`dynamic_tensor.rs`), which
5462    /// genuinely can fail: it must allocate a real `ef_tensor_*` handle
5463    /// alongside `pbo` to carry shape/dtype/format metadata (this backend
5464    /// has nowhere else to put it), an allocation this backend never
5465    /// needed since it just wraps `pbo` into `TensorStorage::Pbo` directly.
5466    /// One shared, fallible signature lets `edgefirst-image`'s own call
5467    /// sites (`gl/threaded.rs`) stay backend-agnostic.
5468    pub fn from_pbo(pbo: PboTensor<T>) -> Result<Self> {
5469        Ok(Self {
5470            storage: TensorStorage::Pbo(pbo),
5471            format: None,
5472            chroma: None,
5473            row_stride: None,
5474            plane_offset: None,
5475            quantization: None,
5476            cuda: None,
5477            colorimetry: None,
5478            cpu_access: CpuAccess::ReadWrite,
5479            compression: None,
5480            view_origin: None,
5481            view_marked: false,
5482        })
5483    }
5484
5485    /// The CUDA registration for this tensor, if any (set at creation on CUDA devices).
5486    pub fn cuda(&self) -> Option<&crate::cuda::CudaHandle> {
5487        self.cuda.as_ref()
5488    }
5489
5490    /// Whether a CUDA registration is attached to this tensor.
5491    pub fn is_cuda_attached(&self) -> bool {
5492        self.cuda.is_some()
5493    }
5494
5495    /// Attach a CUDA handle (called by ImageProcessor::create_image after registering a PBO).
5496    pub fn set_cuda_handle(&mut self, h: crate::cuda::CudaHandle) {
5497        self.cuda = Some(h);
5498    }
5499
5500    /// Fast-fail CUDA map: None (no GL routing) when no handle; else map (PBO routes to the GL worker).
5501    ///
5502    /// Returns a scoped [`CudaMap`] guard holding the raw CUDA device pointer
5503    /// for the duration of the mapping. For GL-buffer-backed tensors the unmap is deferred until the
5504    /// guard drops, freeing the PBO for the next `convert()` call. When no CUDA handle is attached
5505    /// (the common case for plain `Mem`/`DMA` tensors without CUDA registration), returns `None`
5506    /// immediately — no GL routing, no allocation.
5507    ///
5508    /// # Example — zero-copy CUDA input with host fallback
5509    ///
5510    /// ```no_run
5511    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
5512    /// # fn feed_tensorrt(_dptr: *mut std::ffi::c_void, _bytes: usize) {}
5513    /// # fn demo(t: &Tensor<f32>) {
5514    /// // Try the zero-copy CUDA device pointer first.
5515    /// if let Some(cuda) = t.cuda_map() {
5516    ///     feed_tensorrt(cuda.device_ptr(), cuda.len());
5517    ///     // `cuda` (a CudaMap guard) unmaps when it goes out of scope, freeing
5518    ///     // the GPU buffer for the next convert().
5519    /// } else {
5520    ///     // Fall back to the host mapping when no CUDA handle is attached.
5521    ///     let _host = t.map().expect("host map fallback must succeed");
5522    ///     // `_host` is a TensorMap<f32> that derefs to &[f32].
5523    /// }
5524    /// # }
5525    /// ```
5526    ///
5527    /// # D3D11 texture tensors: the mapping is tight rows
5528    ///
5529    /// On Windows the mapping is a linear copy of the texture laid out at
5530    /// `width * bytes_per_texel`, and `len()` is that times the row count --
5531    /// *not* [`row_stride`](Self::row_stride), which reports the D3D11 staging
5532    /// pitch a CPU map sees and is the larger number on a padded backing. A
5533    /// consumer that strides the device pointer by `row_stride()` reads past
5534    /// the end of the mapping.
5535    ///
5536    /// Semi-planar formats (NV12, NV16, NV24) are the exception: their texture
5537    /// is allocated as wide as its own row pitch, so the tight row *is* the
5538    /// pitch and the mapping equals `row_stride() x combined_height` bytes.
5539    /// Striding the device pointer by `row_stride()` is exactly right there.
5540    pub fn cuda_map(&self) -> Option<crate::cuda::CudaMap<'_>> {
5541        self.cuda.as_ref()?.map()
5542    }
5543
5544    /// Writable counterpart of [`cuda_map`](Self::cuda_map). See
5545    /// [`CudaHandle::map_mut`](crate::cuda::CudaHandle::map_mut) for which
5546    /// backings distinguish the two.
5547    ///
5548    /// The D3D11 arm is the one that does: the guard's release copies the
5549    /// buffer back into the texture. Its layout is [`cuda_map`](Self::cuda_map)'s
5550    /// -- tight rows of `width * bytes_per_texel`, not `row_stride()` -- and
5551    /// writing it at the wrong pitch scrambles the image rather than failing.
5552    pub fn cuda_map_mut(&self) -> Option<crate::cuda::CudaMap<'_>> {
5553        self.cuda.as_ref()?.map_mut()
5554    }
5555
5556    /// Attempt to attach a CUDA `ExternalMemory` handle for DMA-backed tensors.
5557    ///
5558    /// On a CUDA-capable host, imports the DMA-BUF fd via
5559    /// `cudaImportExternalMemory(OpaqueFd)` and maps it to a device pointer.
5560    /// Sets `self.cuda` to a persistent `ExternalMem` handle on success. No-op
5561    /// if CUDA is unavailable, the tensor is not DMA-backed, or a handle is
5562    /// already set. Import failure is silently ignored — the tensor remains
5563    /// usable without a CUDA handle.
5564    ///
5565    /// # RUNTIME-UNVALIDATED
5566    ///
5567    /// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
5568    /// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
5569    /// by gpu-probe O5 on Orin. Best-effort: tensor creation never fails here.
5570    #[cfg(target_os = "linux")]
5571    pub fn try_init_dma_cuda(&mut self) {
5572        // Fast-path: already imported, CUDA not available, or not a DMA tensor.
5573        if self.cuda.is_some() || !crate::cuda::is_cuda_available() {
5574            return;
5575        }
5576        let (raw_fd, buf_size) = match &self.storage {
5577            TensorStorage::Dma(dma) => {
5578                use std::os::fd::AsRawFd;
5579                (dma.fd.as_raw_fd(), dma.buf_size)
5580            }
5581            _ => return,
5582        };
5583        if let Some((ext, dptr)) = crate::cuda::import_dma_fd(raw_fd, buf_size) {
5584            self.cuda = Some(crate::cuda::CudaHandle::new_external(ext, dptr, buf_size));
5585        }
5586    }
5587
5588    /// Attempt to attach a CUDA external-memory handle for D3D11 texture
5589    /// tensors — the Windows counterpart of
5590    /// [`try_init_dma_cuda`](Self::try_init_dma_cuda).
5591    ///
5592    /// Imports the texture's shared NT handle via
5593    /// `cudaImportExternalMemory(D3D11Resource)` and the process device's
5594    /// fence as an external semaphore, so [`cuda_map`](Self::cuda_map) hands
5595    /// out a linear device buffer whose refresh is ordered after the D3D11
5596    /// work a producer recorded with [`set_gpu_write`](Self::set_gpu_write).
5597    ///
5598    /// No-op when a handle is already attached or the tensor is not
5599    /// texture-backed. Import failure is silently ignored: the WARP adapter
5600    /// has no CUDA device at all, and a texture whose import fails is still a
5601    /// valid tensor whose `cuda_map()` is `None`.
5602    #[cfg(target_os = "windows")]
5603    pub fn try_init_d3d11_cuda(&mut self) {
5604        if self.cuda.is_some() {
5605            return;
5606        }
5607        let TensorStorage::Dma(tex) = &self.storage else {
5608            return;
5609        };
5610        if let Some(h) = tex.try_init_cuda() {
5611            self.cuda = Some(h);
5612        }
5613    }
5614}
5615
5616/// Windows D3D11 texture accessors. No `'static` bound: none of these needs
5617/// `dtype_of::<T>()`.
5618#[cfg(all(not(edgefirst_tensor_dynamic), target_os = "windows"))]
5619impl<T> Tensor<T>
5620where
5621    T: Num + Clone + fmt::Debug + Send + Sync,
5622{
5623    /// The borrowed `ID3D11Texture2D*` backing this tensor, valid while the
5624    /// tensor lives. `None` when the tensor is not texture-backed.
5625    pub fn d3d11_texture(&self) -> Option<*mut std::ffi::c_void> {
5626        match &self.storage {
5627            TensorStorage::Dma(t) => Some(t.texture_ptr()),
5628            _ => None,
5629        }
5630    }
5631
5632    /// The texture geometry the HAL chose for this image: DXGI format, texel
5633    /// dimensions and the matching GL internal format. `None` when the tensor
5634    /// is not texture-backed.
5635    pub fn d3d11_layout(&self) -> Option<crate::d3d11_layout::D3d11ImageLayout> {
5636        match &self.storage {
5637            TensorStorage::Dma(t) => Some(t.layout()),
5638            _ => None,
5639        }
5640    }
5641
5642    /// A duplicated NT handle the caller owns: open it on a D3D12 device,
5643    /// another D3D11 device, or CUDA, or duplicate it into another process.
5644    /// Closing it does not affect this tensor.
5645    pub fn d3d11_shared_handle(&self) -> Result<std::os::windows::io::OwnedHandle> {
5646        match &self.storage {
5647            TensorStorage::Dma(t) => t.shared_handle(),
5648            _ => Err(Error::NotImplemented(
5649                "not a D3D11 texture tensor".to_owned(),
5650            )),
5651        }
5652    }
5653
5654    /// The fence handle plus value a GPU consumer waits on before reading this
5655    /// texture, or `None` when no GPU write has been recorded on it.
5656    pub fn gpu_completion(&self) -> Result<Option<crate::d3d11::GpuCompletion>> {
5657        match &self.storage {
5658            TensorStorage::Dma(t) => t.gpu_completion(),
5659            _ => Err(Error::NotImplemented(
5660                "not a D3D11 texture tensor".to_owned(),
5661            )),
5662        }
5663    }
5664
5665    /// Record that GPU work writing this texture completes at `value` of the
5666    /// process device's fence. Monotonic: an older value never displaces a
5667    /// newer one, so recording out of order is safe rather than merely
5668    /// tolerated.
5669    ///
5670    /// Takes `&self`, not `&mut self`: the fence slot is an `AtomicU64` and
5671    /// this is a `fetch_max` on it. A producer records a write on the very
5672    /// tensor its consumers already hold and are reading, so demanding
5673    /// exclusive access here would forbid the one call this exists for.
5674    pub fn set_gpu_write(&self, value: u64) -> Result<()> {
5675        match &self.storage {
5676            TensorStorage::Dma(t) => {
5677                t.set_gpu_write(value);
5678                Ok(())
5679            }
5680            _ => Err(Error::NotImplemented(
5681                "not a D3D11 texture tensor".to_owned(),
5682            )),
5683        }
5684    }
5685
5686    /// The tensor's own NT handle value -- not a duplicate -- for descriptors
5687    /// whose consumer keeps the producing tensor alive.
5688    ///
5689    /// Borrowed rather than duplicated because the C exports (task A6) hand
5690    /// out owned duplicates, which a `Copy` descriptor cannot close; the
5691    /// cross-package protocol's `kind::D3D11_TEXTURE` reads this instead.
5692    pub(crate) fn d3d11_shared_handle_value(&self) -> Option<usize> {
5693        match &self.storage {
5694            TensorStorage::Dma(t) => Some(t.shared_handle_value()),
5695            _ => None,
5696        }
5697    }
5698}
5699
5700/// The recorded GPU write value, on every platform: the descriptor path and
5701/// the Python bindings forward it without a `cfg` of their own, and only
5702/// Windows can record one.
5703#[cfg(not(edgefirst_tensor_dynamic))]
5704impl<T> Tensor<T>
5705where
5706    T: Num + Clone + fmt::Debug + Send + Sync,
5707{
5708    /// The fence value of the newest GPU write recorded on this tensor, or 0
5709    /// when there is none: the `value` of `gpu_completion` without the
5710    /// duplicated fence handle, for a consumer that already holds the process
5711    /// fence. 0 for a tensor that is not a D3D11 texture, and on every
5712    /// platform other than Windows.
5713    pub fn gpu_write_value(&self) -> u64 {
5714        #[cfg(target_os = "windows")]
5715        {
5716            if let TensorStorage::Dma(t) = &self.storage {
5717                return t.last_gpu_write();
5718            }
5719        }
5720        0
5721    }
5722}
5723
5724/// Records the D3D11 backing's row pitch on `t` when it exceeds the natural
5725/// stride for its format and width.
5726///
5727/// The staging pitch is the row stride CPU consumers see, and for a
5728/// semi-planar tensor it is also the texture's own width, which the GL
5729/// backend's Path B shader takes as its `tex_width`. Three constructors need
5730/// it identically -- `Tensor::image`'s Windows arm and the two wrap
5731/// constructors below -- and a wrapped texture is the case that makes it
5732/// load-bearing: without it such a tensor reports the natural `even(width)`
5733/// stride while its texture is the driver's pitch wide, and the shader samples
5734/// a padded texture at the image width.
5735///
5736/// `t.format` must already be set: the natural stride comes from it.
5737#[cfg(all(not(edgefirst_tensor_dynamic), target_os = "windows"))]
5738fn record_d3d11_row_stride<T>(t: &mut Tensor<T>, format: PixelFormat, what: &str)
5739where
5740    T: Num + Clone + fmt::Debug + Send + Sync,
5741{
5742    let pitch = match &t.storage {
5743        TensorStorage::Dma(tex) => tex.image_backing_row_stride(),
5744        _ => None,
5745    };
5746    let (Some(pitch), Some(natural)) = (pitch, t.effective_row_stride()) else {
5747        return;
5748    };
5749    if pitch > natural {
5750        log::debug!(
5751            "{what}: the D3D11 backing pitch for the {format:?} texture is {pitch} bytes \
5752             (natural {natural}); recording row stride"
5753        );
5754        t.set_row_stride_unchecked(pitch);
5755    }
5756}
5757
5758/// The two texture-importing constructors. Separate from the accessors above
5759/// because `dtype_of::<T>()` needs `T: 'static`.
5760#[cfg(all(not(edgefirst_tensor_dynamic), target_os = "windows"))]
5761impl<T> Tensor<T>
5762where
5763    T: Num + Clone + fmt::Debug + Send + Sync + 'static,
5764{
5765    /// Wrap an existing `ID3D11Texture2D` as a tensor. The texture is checked
5766    /// against the layout the HAL would have chosen for `format`/`width`/
5767    /// `height` and must live on the HAL's process device; ownership stays
5768    /// with the caller, since this takes its own reference.
5769    ///
5770    /// The backing's row pitch is recorded as the tensor's
5771    /// [`row_stride`](Self::row_stride) when it exceeds the natural stride, so
5772    /// a wrapped semi-planar texture -- whose width *is* the pitch -- reports
5773    /// the geometry its consumers address it by.
5774    ///
5775    /// # Safety
5776    ///
5777    /// `texture` must be null or a live `ID3D11Texture2D` created on the HAL
5778    /// device ([`crate::d3d11::device()`]).
5779    pub unsafe fn from_d3d11_texture(
5780        texture: *mut std::ffi::c_void,
5781        width: usize,
5782        height: usize,
5783        format: PixelFormat,
5784        access: CpuAccess,
5785        name: Option<&str>,
5786    ) -> Result<Self> {
5787        let dtype = dtype_of::<T>().ok_or_else(|| {
5788            Error::InvalidArgument(format!(
5789                "from_d3d11_texture: element type {} has no DType",
5790                std::any::type_name::<T>()
5791            ))
5792        })?;
5793        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5794            Error::InvalidArgument(format!(
5795                "invalid dimensions {width}x{height} for format {format:?}"
5796            ))
5797        })?;
5798        // SAFETY: the caller guarantees `texture` is null or a live
5799        // `ID3D11Texture2D`; the storage takes its own reference.
5800        let storage = unsafe {
5801            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_texture(
5802                texture, width, height, format, dtype, &shape, access, name,
5803            )
5804        }?;
5805        let mut t = Self::wrap(TensorStorage::Dma(storage));
5806        t.format = Some(format);
5807        record_d3d11_row_stride(&mut t, format, "from_d3d11_texture");
5808        t.cpu_access = access;
5809        t.try_init_d3d11_cuda();
5810        Ok(t)
5811    }
5812
5813    /// Open a shared texture by its NT handle on the HAL's device. When
5814    /// `completion` is given, its fence value is waited for on the immediate
5815    /// context, so a same-device reader needs no further ordering. Both
5816    /// handles stay owned by the caller: this duplicates what it keeps.
5817    ///
5818    /// Records the backing's row pitch as
5819    /// [`row_stride`](Self::row_stride) on the same rule as
5820    /// [`from_d3d11_texture`](Self::from_d3d11_texture).
5821    ///
5822    /// # Safety
5823    ///
5824    /// `handle` must be an NT shared handle of a D3D11 texture, valid in this
5825    /// process, and `completion`'s handle a shared fence handle.
5826    pub unsafe fn from_d3d11_shared_handle(
5827        handle: std::os::windows::io::RawHandle,
5828        width: usize,
5829        height: usize,
5830        format: PixelFormat,
5831        access: CpuAccess,
5832        completion: Option<(std::os::windows::io::RawHandle, u64)>,
5833        name: Option<&str>,
5834    ) -> Result<Self> {
5835        let dtype = dtype_of::<T>().ok_or_else(|| {
5836            Error::InvalidArgument(format!(
5837                "from_d3d11_shared_handle: element type {} has no DType",
5838                std::any::type_name::<T>()
5839            ))
5840        })?;
5841        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5842            Error::InvalidArgument(format!(
5843                "invalid dimensions {width}x{height} for format {format:?}"
5844            ))
5845        })?;
5846        // SAFETY: the caller guarantees the handles are valid in this process.
5847        let storage = unsafe {
5848            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_shared_handle(
5849                handle, width, height, format, dtype, &shape, access, completion, name,
5850            )
5851        }?;
5852        let mut t = Self::wrap(TensorStorage::Dma(storage));
5853        t.format = Some(format);
5854        record_d3d11_row_stride(&mut t, format, "from_d3d11_shared_handle");
5855        t.cpu_access = access;
5856        t.try_init_d3d11_cuda();
5857        Ok(t)
5858    }
5859}
5860
5861// Quantization accessors — type-gated to integer element types via the
5862// sealed `IntegerType` trait. Calling `.quantization()` on a `Tensor<f32>`
5863// produces a compile error, not a runtime one.
5864#[cfg(not(edgefirst_tensor_dynamic))]
5865impl<T> Tensor<T>
5866where
5867    T: IntegerType + Num + Clone + fmt::Debug + Send + Sync,
5868{
5869    /// Quantization metadata for this tensor, if set.
5870    pub fn quantization(&self) -> Option<&Quantization> {
5871        self.quantization.as_ref()
5872    }
5873
5874    /// Attach quantization metadata to this tensor. Validates against the
5875    /// tensor's shape — returns [`Error::QuantizationInvalid`] on any
5876    /// inconsistency (mismatched scale/zp lengths, out-of-range axis, etc.).
5877    pub fn set_quantization(&mut self, q: Quantization) -> Result<()> {
5878        q.validate(self.shape())?;
5879        self.quantization = Some(q);
5880        Ok(())
5881    }
5882
5883    /// Builder-style variant of [`Self::set_quantization`]. Consumes `self`
5884    /// and returns `Result<Self>` — on success yields the tensor with the
5885    /// attached quantization; on validation failure returns
5886    /// [`Error::QuantizationInvalid`] and drops `self` (the tensor is not
5887    /// returned in the error arm).
5888    pub fn with_quantization(mut self, q: Quantization) -> Result<Self> {
5889        self.set_quantization(q)?;
5890        Ok(self)
5891    }
5892
5893    /// Clear any quantization metadata on this tensor.
5894    pub fn clear_quantization(&mut self) {
5895        self.quantization = None;
5896    }
5897}
5898
5899/// Rank of one image's shape in `layout`: `[H, W, C]` / `[C, H, W]` are 3-D,
5900/// semi-planar `[H*k, W]` is 2-D. A batched tensor has one more.
5901#[cfg(not(edgefirst_tensor_dynamic))]
5902fn image_rank(layout: PixelLayout) -> usize {
5903    match layout {
5904        PixelLayout::SemiPlanar => 2,
5905        PixelLayout::Packed | PixelLayout::Planar => 3,
5906    }
5907}
5908
5909#[cfg(not(edgefirst_tensor_dynamic))]
5910fn map_strided_host_view<'a, T>(
5911    tensor: &Tensor<T>,
5912    stride: usize,
5913    access: CpuAccess,
5914    non_blocking: bool,
5915) -> Result<crate::view::HostView<'a, T>>
5916where
5917    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5918{
5919    // Rows sit at `stride`-byte spacing; see `strided_rows` for the count.
5920    let rows = strided_rows(tensor)?;
5921    let total_bytes = stride.checked_mul(rows).ok_or_else(|| {
5922        Error::InvalidOperation(format!(
5923            "Tensor::map: row_stride {stride} × rows {rows} overflows usize"
5924        ))
5925    })?;
5926    map_strided_storage(tensor, stride, rows, total_bytes, access, non_blocking)
5927}
5928
5929/// Rows a strided map or flat copy of `tensor` spans at its row stride: a
5930/// raw tensor's leading dim, else every image row of every batch element --
5931/// `H` per packed image, `C * H` per planar one (C planes of H rows), and the
5932/// combined `H*k` per semi-planar one. No product here can overflow: each is
5933/// a factor of the shape's element count, which the allocation already holds.
5934#[cfg(not(edgefirst_tensor_dynamic))]
5935fn strided_rows<T>(tensor: &Tensor<T>) -> Result<usize>
5936where
5937    T: Num + Clone + fmt::Debug + Send + Sync,
5938{
5939    let shape = tensor.shape();
5940    let Some(fmt) = tensor.format else {
5941        return shape.first().copied().ok_or_else(|| {
5942            Error::InvalidOperation("strided access requires a non-empty shape".into())
5943        });
5944    };
5945    let (_, dims) = tensor.image_dims().ok_or_else(|| {
5946        Error::InvalidOperation(format!(
5947            "strided access to a {fmt:?} tensor requires its image shape, optionally \
5948             batched; got {shape:?}"
5949        ))
5950    })?;
5951    let per_image = match fmt.layout() {
5952        PixelLayout::Planar => dims[0] * dims[1],
5953        PixelLayout::Packed | PixelLayout::SemiPlanar => dims[0],
5954    };
5955    let batch: usize = shape[..shape.len() - dims.len()].iter().product();
5956    Ok(batch * per_image)
5957}
5958
5959#[cfg(not(edgefirst_tensor_dynamic))]
5960fn map_strided_storage<'a, T>(
5961    tensor: &Tensor<T>,
5962    _stride: usize,
5963    _rows: usize,
5964    total_bytes: usize,
5965    access: CpuAccess,
5966    _non_blocking: bool,
5967) -> Result<crate::view::HostView<'a, T>>
5968where
5969    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5970{
5971    match &tensor.storage {
5972        #[cfg(target_os = "linux")]
5973        TensorStorage::Dma(dma) => {
5974            // `set_row_stride()` only validates `stride >= min_stride`,
5975            // not that `stride × rows` fits the DMA-BUF, so re-check
5976            // here — mapping past `buf_size` would SIGBUS on access.
5977            // `buf_size` is real kernel truth either way, not the
5978            // importer's say-so: for a self-allocated tensor it is
5979            // the allocation size; for an imported one it is
5980            // `fstat`-derived when the kernel reports a usable size
5981            // (`DmaTensor::from_fd`), or the declared logical size in
5982            // the rare fallback -- either way an out-of-range mmap
5983            // against the real dma-buf is still rejected by the
5984            // kernel, so this bound stays sound regardless.
5985            let available_bytes = dma.buf_size.saturating_sub(dma.mmap_offset);
5986            if total_bytes > available_bytes {
5987                return Err(Error::InvalidOperation(format!(
5988                    "Tensor::map: strided mapping needs {total_bytes} bytes \
5989                     but DMA buffer only has {available_bytes} available \
5990                     (buf_size={}, mmap_offset={}, stride={_stride}, rows={_rows}); \
5991                     the row_stride was likely set larger than the original allocation",
5992                    dma.buf_size, dma.mmap_offset
5993                )));
5994            }
5995            dma.map_with_byte_size(total_bytes, access)
5996        }
5997        TensorStorage::Mem(mem) => {
5998            let capacity = tensor.storage.capacity_bytes();
5999            if total_bytes > capacity {
6000                return Err(Error::InsufficientCapacity {
6001                    needed: total_bytes,
6002                    capacity,
6003                });
6004            }
6005            mem.map_with_byte_size(total_bytes, access)
6006        }
6007        #[cfg(unix)]
6008        TensorStorage::Shm(shm) => {
6009            let capacity = tensor.storage.capacity_bytes();
6010            if total_bytes > capacity {
6011                return Err(Error::InsufficientCapacity {
6012                    needed: total_bytes,
6013                    capacity,
6014                });
6015            }
6016            shm.map_with_byte_size(total_bytes, access)
6017        }
6018        // macOS/iOS: `TensorStorage::Dma` is the IOSurface. The lock yields
6019        // the full surface base address, and the row pitch
6020        // (`IOSurfaceGetBytesPerRow`) is known from the API for both
6021        // self-allocated and imported surfaces — unlike a foreign
6022        // DMA-BUF — so a strided CPU view is sound and zero-copy.
6023        #[cfg(any(target_os = "macos", target_os = "ios"))]
6024        TensorStorage::Dma(io) => {
6025            // A sub-view's window is `buf_size − view_offset`; the strided
6026            // span must fit the window, not the whole surface.
6027            let available = io.buf_size.saturating_sub(io.view_offset);
6028            if total_bytes > available {
6029                return Err(Error::InsufficientCapacity {
6030                    needed: total_bytes,
6031                    capacity: available,
6032                });
6033            }
6034            io.map_with_byte_size(total_bytes, access)
6035        }
6036        // Android: `TensorStorage::Dma` is the AHardwareBuffer. The lock
6037        // yields the full buffer base address, and the row pitch is
6038        // known from the allocator-filled descriptor — so a strided CPU
6039        // view is sound and zero-copy, same as IOSurface.
6040        #[cfg(target_os = "android")]
6041        TensorStorage::Dma(ahb) => {
6042            // A sub-view's window is `buf_size − view_offset`; the strided
6043            // span must fit the window, not the whole buffer.
6044            let available = ahb.buf_size.saturating_sub(ahb.view_offset);
6045            if total_bytes > available {
6046                return Err(Error::InsufficientCapacity {
6047                    needed: total_bytes,
6048                    capacity: available,
6049                });
6050            }
6051            ahb.map_with_byte_size(total_bytes, access)
6052        }
6053        // Windows: `TensorStorage::Dma` is the D3D11 texture, whose CPU side
6054        // is the staging copy at the driver's pitch -- the very pitch
6055        // `Tensor::image` recorded as this tensor's row stride. Its
6056        // `map_with_byte_size` bounds-checks the span against this tensor's
6057        // own window, as the IOSurface arm above does. This is also the only
6058        // backing with a real non-blocking map, so it is the only arm that
6059        // reads `_non_blocking`.
6060        #[cfg(target_os = "windows")]
6061        TensorStorage::Dma(tex) => {
6062            if _non_blocking {
6063                tex.try_map_with_byte_size(total_bytes, access)
6064            } else {
6065                tex.map_with_byte_size(total_bytes, access)
6066            }
6067        }
6068        TensorStorage::Pbo(pbo) => {
6069            // PBO: the GPU-side allocation may have a padded row stride
6070            // (e.g. 64-byte aligned). Expose the full padded buffer so a
6071            // CPU producer (JPEG decoder) and a strided convert source
6072            // can iterate rows via `effective_row_stride()` without
6073            // running past the slice — the logical `pbo.map()` view would
6074            // stop after `shape.product()` and lose bytes past row 0.
6075            // A sub-view's window is `capacity − view_offset`.
6076            let available = pbo.capacity_bytes().saturating_sub(pbo.view_offset);
6077            if total_bytes > available {
6078                return Err(Error::InsufficientCapacity {
6079                    needed: total_bytes,
6080                    capacity: available,
6081                });
6082            }
6083            pbo.map_with_byte_size(total_bytes, access)
6084        }
6085        // Every `TensorStorage` variant this build can construct is
6086        // matched explicitly above (Mem/Shm/Dma/Pbo, each cfg-gated
6087        // to the platforms it exists on) -- this catch-all exists
6088        // only so the match stays exhaustive across every
6089        // OS/cfg combination without a matching arm on every one of
6090        // them, not because any variant is meant to fall through to
6091        // it. `#[allow(unreachable_patterns)]` because on any given
6092        // platform it genuinely is unreachable.
6093        #[allow(unreachable_patterns)]
6094        _ => Err(Error::InvalidOperation(
6095            "CPU mapping of strided tensors is supported only for HAL-allocated \
6096             Mem/Shm (any platform), DMA (Linux, self-allocated or imported), \
6097             IOSurface (macOS), AHardwareBuffer (Android), D3D11 textures \
6098             (Windows), and PBO"
6099                .into(),
6100        )),
6101    }
6102}
6103
6104#[cfg(not(edgefirst_tensor_dynamic))]
6105impl<T> Tensor<T>
6106where
6107    T: Num + Clone + fmt::Debug + Send + Sync,
6108{
6109    /// The shared body of [`TensorTrait::map_with`] and
6110    /// [`TensorTrait::try_map_with`]: the direction check, the
6111    /// declared-vs-requested telemetry, the strided and plane-offset routing,
6112    /// and finally the backing's own map. `non_blocking` chooses which of the
6113    /// backing's two maps that last step takes; everything before it is the
6114    /// same either way.
6115    fn map_impl<'a>(
6116        &self,
6117        access: CpuAccess,
6118        non_blocking: bool,
6119    ) -> Result<crate::view::HostView<'a, T>>
6120    where
6121        T: 'a,
6122    {
6123        let _span = tracing::trace_span!(
6124            "tensor.map",
6125            memory = ?self.storage.memory(),
6126            ?access,
6127            non_blocking,
6128        )
6129        .entered();
6130        self.cpu_access.require_direction(access, "map_with")?;
6131        // CPU mapping of a strided tensor exposes the full padded buffer
6132        // (`row_stride × rows`) so callers can iterate rows via
6133        // `effective_row_stride()` without running past the slice. This is sound
6134        // only when the HAL owns and can size-check the allocation:
6135        //
6136        //   * Self-allocated Mem / Shm tensors (any platform) — the backing
6137        //     `Vec` / shm segment is sized by `capacity_bytes()`, checked here.
6138        //   * Self-allocated DMA tensors (Linux) — pitch padding from
6139        //     `image_with_stride()`; checked against the DMA-BUF `buf_size`.
6140        //
6141        //   * Self-allocated PBO tensors (any platform with GL) — the GL buffer
6142        //     is sized by `capacity_bytes()` and may carry 64-byte row padding;
6143        //     the JPEG decoder mmaps it and convert() reads it, both iterating
6144        //     by `row_stride`. Checked against the PBO capacity below.
6145        //
6146        // A strided **imported** DMA-BUF is accepted here too (Task 10b): the
6147        // `stride × rows <= buf_size` check just below is exactly what a
6148        // self-allocated Dma tensor relies on for its own soundness, and
6149        // `buf_size` for an imported fd is `fstat`-derived when the kernel
6150        // reports a usable size (`stat.st_size > 0` and at least the
6151        // declared shape's logical size; `DmaTensor::from_fd`) -- kernel
6152        // truth, not a caller's claim, in that case. The rare fallback (a
6153        // kernel that reports `st_size <= 0` for a DMA-BUF fd) uses the
6154        // declared logical size instead; that is not a hole here either, since
6155        // an out-of-range mmap against the real dma-buf is still rejected by
6156        // the kernel regardless of what this crate believed `buf_size` was.
6157        // So the check is equally load-bearing regardless of who allocated
6158        // the buffer. What
6159        // changed is only *which* callers can now reach this path: the
6160        // cross-package capsule protocol reconstructs an imported alias of a
6161        // HAL-owned Dma tensor and then calls `configure_image`, which
6162        // computes its stride with the exact same alignment rule a
6163        // self-allocated `Tensor::image()` used — so the value being
6164        // bounds-checked here is HAL-computed and format/width-derived, not
6165        // read from an external driver. Rejecting it unconditionally used to
6166        // make every foreign-imported GPU-backed destination fail any CPU
6167        // write path (the JPEG decoder's MCU writer among them) even when the
6168        // write was provably in-bounds. `set_row_stride()` was already
6169        // "trust the caller, bounds-check the map" for every *other* backing
6170        // (Mem/Shm/self-allocated Dma/PBO) — singling out imported Dma out
6171        // for a stricter standard was the inconsistency, not the trust model
6172        // itself. IOSurface keeps its own always-allowed strided path
6173        // unconditionally (see its arm below): a live `IOSurfaceRef` reports
6174        // its real `bytesPerRow` from the OS regardless of who allocated it,
6175        // so there was never anything to distrust there.
6176        if let Some(stride) = self.row_stride {
6177            return map_strided_host_view(self, stride, access, non_blocking);
6178        }
6179        // A non-zero plane offset needs no check here: every storage applies
6180        // its own offset inside its map -- Mem/Shm adjust the slice base, the
6181        // Linux DMA-BUF and Android AHardwareBuffer the mapped base, IOSurface
6182        // and D3D11 textures add their `view_offset`, and PBO starts the
6183        // staged copy at it.
6184        if non_blocking {
6185            self.storage.try_map_with(access)
6186        } else {
6187            self.storage.map_with(access)
6188        }
6189    }
6190}
6191
6192#[cfg(all(
6193    not(edgefirst_tensor_dynamic),
6194    any(
6195        target_os = "macos",
6196        target_os = "ios",
6197        target_os = "android",
6198        target_os = "windows"
6199    )
6200))]
6201impl<T> Tensor<T>
6202where
6203    T: Num + Clone + fmt::Debug + Send + Sync,
6204{
6205    /// The pitched-backing half of [`TensorTrait::set_logical_shape`]: record
6206    /// the backing's own pitch as the row stride of the new `shape`.
6207    fn adopt_backing_pitch(&mut self, shape: &[usize]) {
6208        // Only a backing with a pitch of its own, and only while the shape
6209        // still has the rank the format's row is measured on: a caller may
6210        // flatten an image to one dimension and leave the format behind.
6211        let Some(pitch) = self.storage.backing_row_stride() else {
6212            return;
6213        };
6214        let Some((rank, rows)) = self.format.map(|f| match f.layout() {
6215            PixelLayout::Packed => (3, shape.first().copied()),
6216            PixelLayout::Planar => (3, shape.get(1).copied()),
6217            PixelLayout::SemiPlanar => (2, shape.first().copied()),
6218        }) else {
6219            return;
6220        };
6221        if rank != shape.len() {
6222            return;
6223        }
6224        // The natural stride of the new shape, not of the stride recorded for
6225        // the old one.
6226        let prior = self.row_stride.take();
6227        let Some(natural) = self.effective_row_stride() else {
6228            self.row_stride = prior;
6229            return;
6230        };
6231        if rows == Some(1) {
6232            self.set_row_stride_unchecked(natural);
6233        } else if pitch > natural {
6234            self.set_row_stride_unchecked(pitch);
6235        } else {
6236            self.row_stride = prior;
6237        }
6238    }
6239}
6240
6241#[cfg(not(edgefirst_tensor_dynamic))]
6242impl<T> TensorTrait<T> for Tensor<T>
6243where
6244    T: Num + Clone + fmt::Debug + Send + Sync,
6245{
6246    fn cpu_access(&self) -> CpuAccess {
6247        self.cpu_access
6248    }
6249
6250    fn view_origin(&self) -> Option<ViewOrigin> {
6251        self.view_origin
6252    }
6253
6254    fn is_view(&self) -> bool {
6255        Tensor::is_view(self)
6256    }
6257
6258    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
6259    where
6260        Self: Sized,
6261    {
6262        Self::new(shape, None, name)
6263    }
6264
6265    #[cfg(unix)]
6266    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
6267    where
6268        Self: Sized,
6269    {
6270        Self::from_fd(fd, shape, name)
6271    }
6272
6273    #[cfg(unix)]
6274    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
6275        self.storage.clone_fd()
6276    }
6277
6278    fn memory(&self) -> TensorMemory {
6279        self.storage.memory()
6280    }
6281
6282    fn name(&self) -> String {
6283        self.storage.name()
6284    }
6285
6286    fn shape(&self) -> &[usize] {
6287        self.storage.shape()
6288    }
6289
6290    /// Bytes available in the underlying allocation, from this tensor's
6291    /// window start -- may exceed [`TensorTrait::size`] when the backing
6292    /// carries spare room beyond the current logical shape (a pitch-aligned
6293    /// image, or a pool tensor sized for a larger image than it holds
6294    /// today). See [`crate::protocol::TensorDesc::capacity`], the
6295    /// cross-package descriptor field this backs.
6296    fn capacity_bytes(&self) -> usize {
6297        self.storage.capacity_bytes()
6298    }
6299
6300    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
6301        if self.chroma.is_some() {
6302            return Err(Error::InvalidOperation(
6303                "cannot reshape a multiplane tensor — decompose planes first".into(),
6304            ));
6305        }
6306        self.storage.reshape(shape)?;
6307        self.format = None;
6308        self.row_stride = None;
6309        self.plane_offset = None;
6310        match self.storage {
6311            TensorStorage::Mem(ref mut m) => m.set_offset(0),
6312            #[cfg(target_os = "linux")]
6313            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
6314            // No-op: `IoSurfaceTensor::reshape` (iosurface.rs) zeroes its
6315            // own `view_offset` before this match runs.
6316            #[cfg(any(target_os = "macos", target_os = "ios"))]
6317            TensorStorage::Dma(_) => {}
6318            // `D3d11TextureTensor::reshape` does not self-zero, so the clear
6319            // that pairs with `set_plane_offset`'s D3D11 arm lives here.
6320            #[cfg(target_os = "windows")]
6321            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
6322            // No-op: `AHardwareBufferTensor::reshape` (ahardwarebuffer.rs)
6323            // zeroes its own `view_offset` before this match runs, the same
6324            // as `IoSurfaceTensor`. Unlike `set_plane_offset`'s Android arm,
6325            // this one is a genuine self-zeroing no-op, not a still-
6326            // incomplete gap: `view_offset` never survives a reshape here
6327            // regardless of whether `set_plane_offset` can restore it.
6328            #[cfg(target_os = "android")]
6329            TensorStorage::Dma(_) => {}
6330            // No-op: `PboTensor::reshape` (pbo.rs) zeroes its own
6331            // `view_offset` before this match runs, the same as
6332            // `IoSurfaceTensor`/`AHardwareBufferTensor` above -- which is
6333            // why, unlike `set_format`'s clear block, this match never
6334            // needed a `Pbo` arm that writes anything.
6335            TensorStorage::Pbo(_) => {}
6336            // Unlike `PboTensor::reshape`, `ShmTensor::reshape` (shm.rs)
6337            // only revalidates the element count and swaps `self.shape` --
6338            // it never touches `self.offset` -- so this clear site needs
6339            // the arm `set_format`'s does. `cfg(unix)` mirrors the `Shm`
6340            // variant's own gate, not a per-OS multiplex.
6341            #[cfg(unix)]
6342            TensorStorage::Shm(ref mut s) => s.set_offset(0),
6343        }
6344        Ok(())
6345    }
6346
6347    /// Set the logical shape to any shape whose bytes fit the allocation,
6348    /// without [`reshape`](TensorTrait::reshape)'s equal-count constraint.
6349    ///
6350    /// Forwards to the storage's capacity-aware implementation -- the same
6351    /// one [`Tensor::configure_image`] uses for pool reuse, a decode into an
6352    /// oversized reusable buffer reconfiguring it to a smaller image without
6353    /// reallocating -- so the two agree. The pixel format is kept.
6354    ///
6355    /// A narrower image inside a pitched backing (an IOSurface, an
6356    /// AHardwareBuffer, a D3D11 texture) still advances rows by the backing's
6357    /// pitch, so that pitch is recorded as the row stride whenever it exceeds
6358    /// the new shape's natural stride -- the same adoption `configure_image`
6359    /// makes. Without it a texture narrowed from 64 to 16 texels wide reports
6360    /// a 64-byte stride over rows that are 256 bytes apart. A single-row
6361    /// shape records its own tight stride instead, by [`view`](Self::view)'s
6362    /// rule: a strided `map()` spans `stride * rows`, and one pitched row
6363    /// runs past the backing when the window sits in the last row.
6364    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
6365        self.storage.set_logical_shape(shape)?;
6366        #[cfg(any(
6367            target_os = "macos",
6368            target_os = "ios",
6369            target_os = "android",
6370            target_os = "windows"
6371        ))]
6372        self.adopt_backing_pitch(shape);
6373        Ok(())
6374    }
6375
6376    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6377    where
6378        T: 'a,
6379    {
6380        self.map_impl(access, false)
6381    }
6382
6383    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6384    where
6385        T: 'a,
6386    {
6387        self.map_impl(access, true)
6388    }
6389
6390    fn buffer_identity(&self) -> &BufferIdentity {
6391        self.storage.buffer_identity()
6392    }
6393}
6394
6395// ============================================================================
6396// Platform availability helpers
6397// ============================================================================
6398
6399/// Cached result of the Linux DMA-BUF availability probe.
6400#[cfg(target_os = "linux")]
6401static DMA_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6402/// Cached result of the macOS/iOS IOSurface availability probe.
6403#[cfg(any(target_os = "macos", target_os = "ios"))]
6404static IOSURFACE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6405
6406/// Check if Linux DMA-BUF allocation is available on this system.
6407///
6408/// Linux-specific availability check (typically requires `/dev/dma_heap`
6409/// access — running as root or membership in a video/render group). For
6410/// portable code that wants "any zero-copy GPU buffer", use
6411/// [`is_gpu_buffer_available`] which also covers IOSurface on macOS.
6412///
6413/// This function caches its result after the first call.
6414#[cfg(target_os = "linux")]
6415pub fn is_dma_available() -> bool {
6416    *DMA_AVAILABLE
6417        .get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok())
6418}
6419
6420/// Always returns `false` on non-Linux platforms.
6421#[cfg(not(target_os = "linux"))]
6422pub fn is_dma_available() -> bool {
6423    false
6424}
6425
6426/// Check if macOS/iOS IOSurface allocation is available on this system.
6427///
6428/// IOSurface is part of the macOS/iOS OS and is essentially always present;
6429/// this probe catches degraded scenarios such as memory pressure or
6430/// sandboxed contexts where `IOSurfaceCreate` fails. The result is
6431/// cached after the first call.
6432///
6433/// Always returns `false` on non-Apple platforms.
6434#[cfg(any(target_os = "macos", target_os = "ios"))]
6435pub fn is_iosurface_available() -> bool {
6436    *IOSURFACE_AVAILABLE.get_or_init(|| {
6437        // Probe via the same Dma path — on macOS/iOS this routes through
6438        // IoSurfaceTensor::new.
6439        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6440    })
6441}
6442
6443#[cfg(not(any(target_os = "macos", target_os = "ios")))]
6444pub fn is_iosurface_available() -> bool {
6445    false
6446}
6447
6448/// Cached result of the Android AHardwareBuffer availability probe.
6449#[cfg(target_os = "android")]
6450static AHARDWAREBUFFER_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6451
6452/// Check if Android AHardwareBuffer allocation is available on this system.
6453///
6454/// AHardwareBuffer is part of the Android OS (public NDK ABI since API
6455/// 26) and is essentially always present; this probe catches degraded
6456/// scenarios such as memory pressure or gralloc failures. The result is
6457/// cached after the first call.
6458#[cfg(target_os = "android")]
6459pub fn is_ahardwarebuffer_available() -> bool {
6460    *AHARDWAREBUFFER_AVAILABLE.get_or_init(|| {
6461        // Probe via the same Dma path — on Android this routes through
6462        // AHardwareBufferTensor::new.
6463        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6464    })
6465}
6466
6467/// Always returns `false` on non-Android platforms.
6468#[cfg(not(target_os = "android"))]
6469pub fn is_ahardwarebuffer_available() -> bool {
6470    false
6471}
6472
6473/// Portable probe for the platform's native zero-copy GPU buffer allocator
6474/// (DMA-BUF on Linux, IOSurface on macOS/iOS, AHardwareBuffer on Android,
6475/// D3D11 textures on Windows). Returns `false` on platforms with no
6476/// equivalent. Use this when writing cross-platform code that cares whether
6477/// the `Dma` tensor variant will work, not which underlying mechanism is used.
6478///
6479/// On Windows the `Dma` variant is image-formatted: `true` here means
6480/// [`Tensor::image`] can serve `TensorMemory::DmaBuf`, not [`Tensor::new`].
6481///
6482/// # Ordering on Windows
6483///
6484/// This probe **creates the process D3D11 device**, because the only honest
6485/// answer to "can this host allocate one" is to try. A host that must supply
6486/// its own device calls `d3d11::use_external_device` **before** this probe or
6487/// any allocation: the device is cached on first creation and installing one
6488/// afterwards is refused.
6489pub fn is_gpu_buffer_available() -> bool {
6490    #[cfg(target_os = "linux")]
6491    {
6492        is_dma_available()
6493    }
6494    #[cfg(any(target_os = "macos", target_os = "ios"))]
6495    {
6496        is_iosurface_available()
6497    }
6498    #[cfg(target_os = "android")]
6499    {
6500        is_ahardwarebuffer_available()
6501    }
6502    // No cached probe: `d3d11::device()` is itself a `OnceLock` over the
6503    // process device, so asking twice costs one atomic load.
6504    #[cfg(target_os = "windows")]
6505    {
6506        crate::d3d11::device().is_ok()
6507    }
6508    #[cfg(not(any(
6509        target_os = "linux",
6510        target_os = "macos",
6511        target_os = "ios",
6512        target_os = "android",
6513        target_os = "windows"
6514    )))]
6515    {
6516        false
6517    }
6518}
6519
6520/// Check if POSIX shared memory allocation is available on this system.
6521///
6522/// Returns `true` on Unix systems (Linux, macOS, BSD) where POSIX shared memory
6523/// is supported. Always returns `false` on non-Unix platforms (Windows).
6524///
6525/// This function caches its result after the first call for efficiency.
6526#[cfg(unix)]
6527static SHM_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6528
6529/// Check if POSIX shared memory allocation is available on this system.
6530#[cfg(unix)]
6531pub fn is_shm_available() -> bool {
6532    *SHM_AVAILABLE.get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok())
6533}
6534
6535/// Check if POSIX shared memory allocation is available on this system.
6536///
6537/// Always returns `false` on non-Unix platforms since POSIX SHM is Unix-specific.
6538#[cfg(not(unix))]
6539pub fn is_shm_available() -> bool {
6540    false
6541}
6542
6543#[cfg(test)]
6544mod dtype_tests {
6545    use super::*;
6546
6547    #[test]
6548    fn dtype_size() {
6549        assert_eq!(DType::U8.size(), 1);
6550        assert_eq!(DType::I8.size(), 1);
6551        assert_eq!(DType::U16.size(), 2);
6552        assert_eq!(DType::I16.size(), 2);
6553        assert_eq!(DType::U32.size(), 4);
6554        assert_eq!(DType::I32.size(), 4);
6555        assert_eq!(DType::U64.size(), 8);
6556        assert_eq!(DType::I64.size(), 8);
6557        assert_eq!(DType::F16.size(), 2);
6558        assert_eq!(DType::F32.size(), 4);
6559        assert_eq!(DType::F64.size(), 8);
6560    }
6561
6562    #[test]
6563    fn dtype_name() {
6564        assert_eq!(DType::U8.name(), "u8");
6565        assert_eq!(DType::F16.name(), "f16");
6566        assert_eq!(DType::F32.name(), "f32");
6567    }
6568
6569    #[test]
6570    fn dtype_serde_roundtrip() {
6571        use serde_json;
6572        let dt = DType::F16;
6573        let json = serde_json::to_string(&dt).unwrap();
6574        let back: DType = serde_json::from_str(&json).unwrap();
6575        assert_eq!(dt, back);
6576    }
6577}
6578
6579#[cfg(test)]
6580mod image_tests {
6581    use super::*;
6582
6583    #[test]
6584    fn image_dims_from_shape_inverts_allocation_shape() {
6585        for (format, w, h) in [
6586            (PixelFormat::Rgba, 640usize, 480usize),
6587            (PixelFormat::Rgb, 641, 3),
6588            (PixelFormat::Grey, 32, 32),
6589            (PixelFormat::PlanarRgb, 640, 480),
6590            (PixelFormat::PlanarRgba, 8, 5),
6591            (PixelFormat::Nv12, 640, 481),
6592            (PixelFormat::Nv16, 33, 17),
6593            (PixelFormat::Nv24, 16, 9),
6594        ] {
6595            let shape = format.allocation_shape(w, h).unwrap();
6596            assert_eq!(
6597                image_dims_from_shape(format, &shape).unwrap(),
6598                (w, h),
6599                "{format:?} {w}x{h} via {shape:?}"
6600            );
6601        }
6602    }
6603
6604    #[test]
6605    fn image_dims_from_shape_rejects_the_wrong_rank() {
6606        // Packed and planar want rank 3, semi-planar rank 2.
6607        for (format, shape) in [
6608            (PixelFormat::Rgba, vec![480usize, 640usize]),
6609            (PixelFormat::PlanarRgb, vec![3, 480]),
6610            (PixelFormat::Nv12, vec![720, 640, 1]),
6611        ] {
6612            let err = image_dims_from_shape(format, &shape).unwrap_err();
6613            assert!(
6614                matches!(err, Error::InvalidShape(ref m) if m.contains("rank")),
6615                "{format:?} {shape:?}: {err}"
6616            );
6617        }
6618    }
6619
6620    #[test]
6621    fn image_dims_from_shape_rejects_an_unproduced_combined_height() {
6622        // NV12 maps 480 -> 720 and 481 -> 722; nothing maps to 721, so the
6623        // shape is rejected rather than rounded to 480.
6624        let err = image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).unwrap_err();
6625        assert!(
6626            matches!(err, Error::InvalidShape(ref m) if m.contains("combined plane height")),
6627            "{err}"
6628        );
6629    }
6630
6631    #[test]
6632    fn allocation_shape_per_layout() {
6633        assert_eq!(
6634            PixelFormat::Rgb.allocation_shape(640, 480),
6635            Some(vec![480, 640, 3])
6636        );
6637        assert_eq!(
6638            PixelFormat::Grey.allocation_shape(640, 480),
6639            Some(vec![480, 640, 1])
6640        );
6641        assert_eq!(
6642            PixelFormat::Nv12.allocation_shape(640, 480),
6643            Some(vec![720, 640])
6644        );
6645        // Odd height: combined-plane height is `481 + ceil(481/2)` = 481 + 241
6646        // = 722 rows. Logical height is recovered as `722 * 2 / 3` = 481.
6647        assert_eq!(
6648            PixelFormat::Nv12.allocation_shape(640, 481),
6649            Some(vec![722, 640])
6650        );
6651        // Odd width: shape carries the LOGICAL width (641).
6652        // The 64-aligned stride (>= 642) is stored separately on the Tensor.
6653        assert_eq!(
6654            PixelFormat::Nv12.allocation_shape(641, 480),
6655            Some(vec![720, 641])
6656        );
6657        // NV16 odd width: same — logical width in shape, stride separate.
6658        assert_eq!(
6659            PixelFormat::Nv16.allocation_shape(641, 480),
6660            Some(vec![960, 641])
6661        );
6662        assert_eq!(
6663            PixelFormat::PlanarRgb.allocation_shape(640, 480),
6664            Some(vec![3, 480, 640])
6665        );
6666        assert_eq!(
6667            PixelFormat::Nv16.allocation_shape(640, 480),
6668            Some(vec![960, 640])
6669        );
6670    }
6671
6672    #[test]
6673    fn raw_tensor_has_no_format() {
6674        let t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6675        assert!(t.format().is_none());
6676        assert!(t.width().is_none());
6677        assert!(t.height().is_none());
6678        assert!(!t.is_multiplane());
6679        assert!(t.chroma().is_none());
6680    }
6681
6682    #[test]
6683    fn image_tensor_packed() {
6684        let t = Tensor::<u8>::image(
6685            640,
6686            480,
6687            PixelFormat::Rgba,
6688            None,
6689            crate::CpuAccess::ReadWrite,
6690        )
6691        .unwrap();
6692        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6693        assert_eq!(t.width(), Some(640));
6694        assert_eq!(t.height(), Some(480));
6695        assert_eq!(t.shape(), &[480, 640, 4]);
6696        assert!(!t.is_multiplane());
6697    }
6698
6699    #[test]
6700    fn image_tensor_planar() {
6701        let t = Tensor::<u8>::image(
6702            640,
6703            480,
6704            PixelFormat::PlanarRgb,
6705            None,
6706            crate::CpuAccess::ReadWrite,
6707        )
6708        .unwrap();
6709        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6710        assert_eq!(t.width(), Some(640));
6711        assert_eq!(t.height(), Some(480));
6712        assert_eq!(t.shape(), &[3, 480, 640]);
6713    }
6714
6715    #[test]
6716    #[cfg(target_os = "macos")]
6717    fn image_tensor_dma_non_aligned_packed_width_pads_zero_copy() {
6718        // Declares this test as an fd-opener; see FD_LOCK.
6719        let _lock = crate::tests::fd_lock_shared();
6720        // RGBA u8 at width=4 → 4*4 = 16 bytes/row, not 64-byte aligned. RGBA has
6721        // a real IOSurface FourCC, so an explicit `Some(TensorMemory::DmaBuf)`
6722        // request now allocates a padded image IOSurface (64-aligned
6723        // `bytes_per_row`) and records the stride — a fully zero-copy buffer GL
6724        // can bind and the CPU can map via the strided path. (Previously this
6725        // failed loudly to avoid an 'L008' byte-bag downgrade; with a real
6726        // FourCC surface that concern no longer applies.)
6727        let t = Tensor::<u8>::image(
6728            4,
6729            4,
6730            PixelFormat::Rgba,
6731            Some(TensorMemory::DmaBuf),
6732            crate::CpuAccess::ReadWrite,
6733        )
6734        .expect("padded RGBA IOSurface should allocate");
6735        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6736        assert_eq!(t.width(), Some(4));
6737        assert_eq!(t.height(), Some(4));
6738        let stride = t.effective_row_stride().expect("stride");
6739        assert_eq!(stride % 64, 0, "padded to 64-byte row alignment");
6740        assert!(stride >= 16);
6741        // A CPU map exposes the full padded surface for strided iteration.
6742        let m = t.map().expect("strided IOSurface map");
6743        assert_eq!(m.as_slice().len(), stride * 4);
6744    }
6745
6746    /// `per_pixel_bytes` that doesn't divide 64 evenly (e.g. RGB u8 with
6747    /// 3 B/pixel) makes a "Pad width to N" suggestion structurally
6748    /// impossible — there is no integer width whose `width * 3` is a
6749    /// multiple of 64. The error must still fire (no silent SHM
6750    /// fallback for explicit-DMA requests) and must spell out the
6751    /// alignment requirement; it just omits the misleading "pad to N"
6752    /// hint instead of printing a number whose row pitch still won't
6753    /// align.
6754    #[test]
6755    #[cfg(target_os = "macos")]
6756    fn image_tensor_dma_rejects_indivisible_pixel_pitch_without_pad_hint() {
6757        // Declares this test as an fd-opener; see FD_LOCK.
6758        let _lock = crate::tests::fd_lock_shared();
6759        // Width=10 RGB f32 → 120 B/row, not 64-byte aligned, and (Rgb,
6760        // F32) has no IOSurface mapping so the padded-stride tolerance
6761        // does not apply. The next 64-multiple (128 B) isn't an integer
6762        // multiple of 12 B/pixel, so the "pad width to N" hint can't
6763        // produce a valid number and must be omitted. (Rgb u8 used to be
6764        // this test's subject but now has a real RGBA8888 mapping with
6765        // padded-stride tolerance — see the test below.)
6766        let err = Tensor::<f32>::image(
6767            10,
6768            10,
6769            PixelFormat::Rgb,
6770            Some(TensorMemory::DmaBuf),
6771            crate::CpuAccess::ReadWrite,
6772        )
6773        .expect_err("RGB f32 with 12 B/pixel and non-aligned width must be rejected");
6774        match err {
6775            Error::InvalidArgument(msg) => {
6776                assert!(
6777                    msg.contains("64-byte aligned"),
6778                    "error must still name the alignment requirement: {msg}"
6779                );
6780                assert!(
6781                    !msg.contains("Pad width"),
6782                    "indivisible per-pixel pitch makes a width suggestion impossible; \
6783                     hint must be omitted, got: {msg}"
6784                );
6785                assert!(
6786                    msg.contains("memory=None") && msg.contains("TensorMemory::Mem"),
6787                    "error must still list the always-applicable alternatives: {msg}"
6788                );
6789            }
6790            other => panic!("expected InvalidArgument, got {other:?}"),
6791        }
6792    }
6793
6794    #[test]
6795    #[cfg(target_os = "macos")]
6796    fn image_tensor_dma_packed_rgb_u8_contract() {
6797        // Declares this test as an fd-opener; see FD_LOCK.
6798        let _lock = crate::tests::fd_lock_shared();
6799        // Packed RGB u8 @Dma is a designed RGBA8888 mapping at
6800        // (W*3/4, H) — the INT8 NPU input layout, shared with Android.
6801        // width%4 != 0 cannot form whole texels → loud InvalidArgument…
6802        let err = Tensor::<u8>::image(
6803            10,
6804            10,
6805            PixelFormat::Rgb,
6806            Some(TensorMemory::DmaBuf),
6807            crate::CpuAccess::ReadWrite,
6808        )
6809        .expect_err("Rgb u8 width%4!=0 must be rejected");
6810        assert!(
6811            matches!(&err, Error::InvalidArgument(m) if m.contains("width%4==0")),
6812            "got {err:?}"
6813        );
6814        // …width%4 == 0 with a non-64-aligned pitch allocates PADDED
6815        // (36 B rows → 64 B surface pitch, recorded on the tensor)…
6816        let t = Tensor::<u8>::image(
6817            12,
6818            4,
6819            PixelFormat::Rgb,
6820            Some(TensorMemory::DmaBuf),
6821            crate::CpuAccess::ReadWrite,
6822        )
6823        .expect("width 12 Rgb u8 must allocate padded");
6824        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6825        assert!(
6826            t.row_stride()
6827                .is_some_and(|s| s >= 64 && s.is_multiple_of(64)),
6828            "padded pitch must be recorded: {:?}",
6829            t.row_stride()
6830        );
6831        // …and the aligned model-input width stays flat (640*3 = 1920 is
6832        // 64-aligned → no recorded stride, the buffer IS [H, W, 3]).
6833        let t = Tensor::<u8>::image(
6834            640,
6835            8,
6836            PixelFormat::Rgb,
6837            Some(TensorMemory::DmaBuf),
6838            crate::CpuAccess::ReadWrite,
6839        )
6840        .expect("width 640 Rgb u8 must allocate flat");
6841        assert_eq!(t.row_stride(), None);
6842        // I8 shares the layout (INT8 shader bias, not a format change).
6843        let t = Tensor::<i8>::image(
6844            640,
6845            8,
6846            PixelFormat::Rgb,
6847            Some(TensorMemory::DmaBuf),
6848            crate::CpuAccess::ReadWrite,
6849        )
6850        .expect("Rgb i8 shares the RGBA8888 mapping");
6851        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6852    }
6853
6854    #[test]
6855    #[cfg(target_os = "macos")]
6856    fn image_tensor_dma_planar_f16_alignment() {
6857        // Declares this test as an fd-opener; see FD_LOCK.
6858        let _lock = crate::tests::fd_lock_shared();
6859        // PlanarRgb F16 uses single-channel row pitch (width * 2 bytes).
6860        // Width=16 → 32 bytes/row (not aligned); width=32 → 64 bytes/row (aligned).
6861        let err = Tensor::<half::f16>::image(
6862            16,
6863            16,
6864            PixelFormat::PlanarRgb,
6865            Some(TensorMemory::DmaBuf),
6866            crate::CpuAccess::ReadWrite,
6867        )
6868        .expect_err("width=16 PlanarRgb F16 is 32-byte row, must reject");
6869        assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}");
6870        // 32 wide should work.
6871        let t = Tensor::<half::f16>::image(
6872            32,
6873            8,
6874            PixelFormat::PlanarRgb,
6875            Some(TensorMemory::DmaBuf),
6876            crate::CpuAccess::ReadWrite,
6877        )
6878        .expect("width=32 PlanarRgb F16 is 64-byte row, must succeed");
6879        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6880    }
6881
6882    #[test]
6883    fn image_tensor_semi_planar_contiguous() {
6884        let t = Tensor::<u8>::image(
6885            640,
6886            480,
6887            PixelFormat::Nv12,
6888            None,
6889            crate::CpuAccess::ReadWrite,
6890        )
6891        .unwrap();
6892        assert_eq!(t.format(), Some(PixelFormat::Nv12));
6893        assert_eq!(t.width(), Some(640));
6894        assert_eq!(t.height(), Some(480));
6895        // NV12: H*3/2 = 720
6896        assert_eq!(t.shape(), &[720, 640]);
6897        assert!(!t.is_multiplane());
6898    }
6899
6900    #[test]
6901    #[cfg(target_os = "linux")]
6902    fn image_tensor_with_stride_preserves_logical_width() {
6903        // Declares this test as an fd-opener; see FD_LOCK.
6904        let _lock = crate::tests::fd_lock_shared();
6905        if !crate::test_support::dma_or_skip("image_tensor_with_stride_preserves_logical_width") {
6906            return;
6907        }
6908        // 3004×1688 RGBA8: natural pitch 12016, padded to 12032 (64-aligned).
6909        let stride = 12032;
6910        let t = Tensor::<u8>::image_with_stride(
6911            3004,
6912            1688,
6913            PixelFormat::Rgba,
6914            stride,
6915            Some(TensorMemory::DmaBuf),
6916            crate::CpuAccess::ReadWrite,
6917        )
6918        .unwrap();
6919        // Logical dimensions unchanged by padding — this is the contract.
6920        assert_eq!(t.width(), Some(3004));
6921        assert_eq!(t.height(), Some(1688));
6922        assert_eq!(t.shape(), &[1688, 3004, 4]);
6923        // Stride is carried separately and reports the padded pitch.
6924        assert_eq!(t.effective_row_stride(), Some(stride));
6925        // Buffer is sized to stride × height so the full padded layout fits,
6926        // and CPU map() works for self-allocated strided DMA tensors.
6927        use crate::TensorMapTrait;
6928        {
6929            let map = t.map().unwrap();
6930            assert!(
6931                map.as_slice().len() >= stride * 1688,
6932                "mapped buffer {} bytes < expected {}",
6933                map.as_slice().len(),
6934                stride * 1688
6935            );
6936        }
6937        // CPU write access works too — iterate rows using the padded stride,
6938        // touch only the active `width × bpp` region, verify it round-trips.
6939        {
6940            let mut map = t.map().unwrap();
6941            let slice = map.as_mut_slice();
6942            for y in 0..1688 {
6943                let row_start = y * stride;
6944                for x in 0..3004 {
6945                    let p = row_start + x * 4;
6946                    slice[p] = (y & 0xFF) as u8;
6947                    slice[p + 1] = (x & 0xFF) as u8;
6948                    slice[p + 2] = 0x42;
6949                    slice[p + 3] = 0xFF;
6950                }
6951            }
6952        }
6953        {
6954            let map = t.map().unwrap();
6955            let slice = map.as_slice();
6956            // Sample a few pixels to confirm the round-trip.
6957            assert_eq!(slice[0], 0x00);
6958            assert_eq!(slice[1], 0x00);
6959            assert_eq!(slice[2], 0x42);
6960            assert_eq!(slice[3], 0xFF);
6961            let mid = 100 * stride + 50 * 4;
6962            assert_eq!(slice[mid], 100);
6963            assert_eq!(slice[mid + 1], 50);
6964            assert_eq!(slice[mid + 2], 0x42);
6965        }
6966    }
6967
6968    #[test]
6969    #[cfg(target_os = "linux")]
6970    fn image_tensor_foreign_strided_map_is_bounds_checked_not_refused() {
6971        // An imported (foreign) DMA-BUF with a row stride used to be refused
6972        // outright. That changed deliberately: the cross-package capsule
6973        // protocol reconstructs an imported alias of a HAL-owned Dma tensor,
6974        // and refusing every strided CPU map broke the flagship
6975        // create_image() -> decode_file_into() -> convert() pipeline on Linux.
6976        //
6977        // What replaced the refusal is a bounds check against `buf_size`,
6978        // which for an imported fd is `fstat`-derived kernel truth rather
6979        // than the caller's claim. So the property worth testing is no longer
6980        // "foreign strided maps fail" but "they succeed exactly when they
6981        // fit" -- which is what actually keeps the mapping sound.
6982        //
6983        // This test previously asserted the old refusal and went red on every
6984        // board with a dma-heap when the behaviour changed, unnoticed because
6985        // it needs both Linux and a dma-heap: it skips on macOS and on Orin
6986        // (nvmap, no CONFIG_DMABUF_HEAPS).
6987        if !crate::test_support::dma_or_skip(
6988            "image_tensor_foreign_strided_map_is_bounds_checked_not_refused",
6989        ) {
6990            return;
6991        }
6992        let backing =
6993            Tensor::<u8>::new(&[240 * 320 * 4], Some(TensorMemory::DmaBuf), None).unwrap();
6994
6995        // In bounds: the natural stride for the declared image fits the
6996        // allocation, so the map must succeed.
6997        {
6998            let fd = backing.clone_fd().unwrap();
6999            let shape = [240usize, 320, 4];
7000            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
7001            let mut t = Tensor::<u8>::wrap(storage);
7002            t.set_format(PixelFormat::Bgra).unwrap();
7003            t.set_row_stride(320 * 4).unwrap();
7004            assert!(
7005                t.map().is_ok(),
7006                "an in-bounds strided map of an imported DMA-BUF must be \
7007                 accepted -- refusing it is what broke decode-into-GPU-tensor"
7008            );
7009        }
7010
7011        // Out of bounds: a stride the allocation cannot back must still be
7012        // refused, and the error must say so. This is the half that carries
7013        // the soundness.
7014        {
7015            let fd = backing.clone_fd().unwrap();
7016            let shape = [240usize, 320, 4];
7017            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
7018            let mut t = Tensor::<u8>::wrap(storage);
7019            t.set_format(PixelFormat::Bgra).unwrap();
7020            // Double the pitch: 240 rows x 2560 bytes is twice the allocation.
7021            t.set_row_stride(320 * 4 * 2).unwrap();
7022            let err = t.map();
7023            assert!(
7024                matches!(err, Err(Error::InvalidOperation(_))),
7025                "a strided map larger than the DMA allocation must be refused"
7026            );
7027        }
7028    }
7029
7030    #[test]
7031    #[cfg(target_os = "linux")]
7032    fn image_tensor_with_stride_map_rejects_tampered_stride() {
7033        // Declares this test as an fd-opener; see FD_LOCK.
7034        let _lock = crate::tests::fd_lock_shared();
7035        // Round-3 PR feedback (C1): `set_row_stride` is public and only
7036        // validates `stride >= min_stride`, not that the new stride × height
7037        // fits the underlying buffer. A caller that tampers with the stride
7038        // after allocation must not be able to coerce `Tensor::map()` into
7039        // returning a slice larger than the backing mmap (that would be UB
7040        // in `DmaMap::as_slice`).
7041        if !crate::test_support::dma_or_skip("image_tensor_with_stride_map_rejects_tampered_stride")
7042        {
7043            return;
7044        }
7045        // Allocate a 640×480 RGBA8 padded canvas (stride = 3072 = 768 px).
7046        // Backing buffer is 3072 × 480 = 1,474,560 bytes.
7047        let mut t = Tensor::<u8>::image_with_stride(
7048            640,
7049            480,
7050            PixelFormat::Rgba,
7051            3072,
7052            Some(TensorMemory::DmaBuf),
7053            crate::CpuAccess::ReadWrite,
7054        )
7055        .unwrap();
7056        // Tamper: push the stride up to 4 × the original. This is >=
7057        // min_stride (2560), so `set_row_stride` accepts it.
7058        t.set_row_stride(12288).unwrap();
7059        // Map must now refuse — 12288 × 480 = 5,898,240 > 1,474,560.
7060        let err = t.map();
7061        assert!(
7062            matches!(err, Err(Error::InvalidOperation(_))),
7063            "map() with oversized stride must return InvalidOperation"
7064        );
7065    }
7066
7067    #[test]
7068    fn dma_tensor_new_with_byte_size_rejects_shape_overflow() {
7069        // Round-3 PR feedback (C3): shape.product() * sizeof(T) must use
7070        // checked arithmetic so a pathological shape can't wrap usize and
7071        // make the byte_size-vs-logical-size comparison incorrect.
7072        //
7073        // This test only exercises the overflow rejection path, which is
7074        // pure-Rust and doesn't touch dma_heap — safe to run on any target.
7075        #[cfg(target_os = "linux")]
7076        {
7077            let err = crate::dma::DmaTensor::<u64>::new_with_byte_size(
7078                &[usize::MAX, 2, 2],
7079                usize::MAX,
7080                None,
7081                false,
7082            );
7083            assert!(
7084                matches!(err, Err(Error::InvalidArgument(_))),
7085                "new_with_byte_size must detect shape.product() overflow"
7086            );
7087        }
7088    }
7089
7090    #[test]
7091    #[cfg(target_os = "linux")]
7092    fn image_tensor_with_stride_rejects_too_small_stride() {
7093        // Declares this test as an fd-opener; see FD_LOCK.
7094        let _lock = crate::tests::fd_lock_shared();
7095        // 640×480 RGBA8 natural pitch = 2560, request 2400 → should error.
7096        let err = Tensor::<u8>::image_with_stride(
7097            640,
7098            480,
7099            PixelFormat::Rgba,
7100            2400,
7101            Some(TensorMemory::DmaBuf),
7102            crate::CpuAccess::ReadWrite,
7103        );
7104        assert!(matches!(err, Err(Error::InvalidArgument(_))));
7105    }
7106
7107    #[test]
7108    #[cfg(target_os = "linux")]
7109    fn image_tensor_with_stride_rejects_non_packed() {
7110        // Declares this test as an fd-opener; see FD_LOCK.
7111        let _lock = crate::tests::fd_lock_shared();
7112        // NV12 is SemiPlanar → not supported. (Linux-only because
7113        // `TensorMemory::DmaBuf` itself is a Linux-only enum variant.)
7114        let err = Tensor::<u8>::image_with_stride(
7115            640,
7116            480,
7117            PixelFormat::Nv12,
7118            640,
7119            Some(TensorMemory::DmaBuf),
7120            crate::CpuAccess::ReadWrite,
7121        );
7122        assert!(matches!(err, Err(Error::NotImplemented(_))));
7123    }
7124
7125    #[test]
7126    fn set_format_valid() {
7127        let mut t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
7128        assert!(t.format().is_none());
7129        t.set_format(PixelFormat::Rgb).unwrap();
7130        assert_eq!(t.format(), Some(PixelFormat::Rgb));
7131        assert_eq!(t.width(), Some(640));
7132        assert_eq!(t.height(), Some(480));
7133    }
7134
7135    #[test]
7136    fn set_format_invalid_shape() {
7137        let mut t = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
7138        // RGB expects 3 channels, not 4
7139        let err = t.set_format(PixelFormat::Rgb);
7140        assert!(err.is_err());
7141        // Original tensor is unmodified
7142        assert!(t.format().is_none());
7143    }
7144
7145    #[test]
7146    fn reshape_clears_format() {
7147        let mut t = Tensor::<u8>::image(
7148            640,
7149            480,
7150            PixelFormat::Rgba,
7151            None,
7152            crate::CpuAccess::ReadWrite,
7153        )
7154        .unwrap();
7155        assert_eq!(t.format(), Some(PixelFormat::Rgba));
7156        // Reshape to flat — format cleared
7157        t.reshape(&[480 * 640 * 4]).unwrap();
7158        assert!(t.format().is_none());
7159    }
7160
7161    #[test]
7162    fn from_planes_nv12() {
7163        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7164        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7165        let img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
7166        assert_eq!(img.format(), Some(PixelFormat::Nv12));
7167        assert!(img.is_multiplane());
7168        assert!(img.chroma().is_some());
7169        assert_eq!(img.width(), Some(640));
7170        assert_eq!(img.height(), Some(480));
7171    }
7172
7173    #[test]
7174    fn from_planes_rejects_non_semiplanar() {
7175        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7176        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7177        let err = Tensor::from_planes(y, uv, PixelFormat::Rgb);
7178        assert!(err.is_err());
7179    }
7180
7181    #[test]
7182    fn reshape_multiplane_errors() {
7183        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7184        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7185        let mut img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
7186        let err = img.reshape(&[480 * 640 + 240 * 640]);
7187        assert!(err.is_err());
7188    }
7189}
7190
7191#[cfg(test)]
7192mod compression_tests {
7193    use super::*;
7194
7195    #[test]
7196    fn desc_builder_roundtrips() {
7197        let desc = ImageDesc::new(640, 480, PixelFormat::Rgba, DType::U8)
7198            .with_memory(Some(TensorMemory::Mem))
7199            .with_access(CpuAccess::Read)
7200            .with_compression(Compression::Any);
7201        assert_eq!(desc.width(), 640);
7202        assert_eq!(desc.height(), 480);
7203        assert_eq!(desc.format(), PixelFormat::Rgba);
7204        assert_eq!(desc.dtype(), DType::U8);
7205        assert_eq!(desc.memory(), Some(TensorMemory::Mem));
7206        assert_eq!(desc.access(), CpuAccess::Read);
7207        assert_eq!(desc.compression(), Some(Compression::Any));
7208
7209        // Defaults: auto memory, CPU read/write, no request.
7210        let plain = ImageDesc::new(2, 2, PixelFormat::Grey, DType::U8);
7211        assert_eq!(plain.memory(), None);
7212        assert_eq!(plain.access(), CpuAccess::ReadWrite);
7213        assert_eq!(plain.compression(), None);
7214    }
7215
7216    #[test]
7217    fn desc_dtype_must_match_element_type() {
7218        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::F32);
7219        match Tensor::<u8>::image_desc(&desc) {
7220            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("dtype")),
7221            other => panic!("expected InvalidArgument, got {other:?}"),
7222        }
7223    }
7224
7225    #[test]
7226    fn compression_with_cpu_access_is_invalid() {
7227        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
7228            .with_access(CpuAccess::ReadWrite)
7229            .with_compression(Compression::Any);
7230        match Tensor::<u8>::image_desc(&desc) {
7231            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("CpuAccess::None")),
7232            other => panic!("expected InvalidArgument, got {other:?}"),
7233        }
7234    }
7235
7236    #[cfg(not(target_os = "android"))]
7237    #[test]
7238    fn scheme_request_off_android_is_not_implemented() {
7239        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
7240            .with_access(CpuAccess::None)
7241            .with_compression(Compression::Scheme(CompressionScheme::Ubwc));
7242        match Tensor::<u8>::image_desc(&desc) {
7243            Err(Error::NotImplemented(msg)) => assert!(msg.contains("Ubwc")),
7244            other => panic!("expected NotImplemented, got {other:?}"),
7245        }
7246    }
7247
7248    #[cfg(not(target_os = "android"))]
7249    #[test]
7250    fn any_request_off_android_resolves_linear_and_counts() {
7251        let before = compression_fallback_count();
7252        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
7253            .with_memory(Some(TensorMemory::Mem))
7254            .with_access(CpuAccess::None)
7255            .with_compression(Compression::Any);
7256        let t = Tensor::<u8>::image_desc(&desc).unwrap();
7257        assert_eq!(t.compression(), None);
7258        assert!(compression_fallback_count() > before);
7259    }
7260
7261    #[test]
7262    fn desc_without_request_matches_classic_constructor() {
7263        let desc = ImageDesc::new(32, 32, PixelFormat::Rgba, DType::U8)
7264            .with_memory(Some(TensorMemory::Mem))
7265            .with_access(CpuAccess::ReadWrite);
7266        let t = Tensor::<u8>::image_desc(&desc).unwrap();
7267        assert_eq!(t.compression(), None);
7268        assert_eq!(t.cpu_access(), CpuAccess::ReadWrite);
7269        assert_eq!(t.width(), Some(32));
7270        // Mappable exactly like the classic constructor's result.
7271        let m = t.map_read().unwrap();
7272        assert_eq!(m.as_slice().len(), 32 * 32 * 4);
7273    }
7274
7275    #[test]
7276    fn configure_image_preserves_compression_and_views_inherit() {
7277        // No host platform records a scheme, so emulate the recording to
7278        // pin the preserve/inherit semantics (the physical layout does
7279        // not change when the logical image is reconfigured).
7280        let mut t = Tensor::<u8>::image(
7281            64,
7282            64,
7283            PixelFormat::Rgba,
7284            Some(TensorMemory::Mem),
7285            CpuAccess::None,
7286        )
7287        .unwrap();
7288        t.compression = Some(CompressionScheme::Ubwc);
7289        t.configure_image(32, 32, PixelFormat::Rgba).unwrap();
7290        assert_eq!(t.compression(), Some(CompressionScheme::Ubwc));
7291        let view = t.subview(0, &[16, 32, 4]).unwrap();
7292        assert_eq!(view.compression(), Some(CompressionScheme::Ubwc));
7293    }
7294
7295    #[test]
7296    fn tensor_dyn_dispatches_desc_and_compression() {
7297        let desc = ImageDesc::new(16, 16, PixelFormat::Rgba, DType::U8)
7298            .with_memory(Some(TensorMemory::Mem))
7299            .with_access(CpuAccess::ReadWrite);
7300        let t = TensorDyn::image_desc(&desc).unwrap();
7301        assert_eq!(t.compression(), None);
7302        assert!(matches!(t, TensorDyn::U8(_)));
7303    }
7304}
7305
7306#[cfg(all(test, not(edgefirst_tensor_dynamic)))]
7307mod vocabulary_and_probe_tests {
7308    use super::*;
7309
7310    #[test]
7311    fn dtype_displays_its_wire_name() {
7312        for (dt, name) in [
7313            (DType::U8, "u8"),
7314            (DType::I8, "i8"),
7315            (DType::U16, "u16"),
7316            (DType::I16, "i16"),
7317            (DType::U32, "u32"),
7318            (DType::I32, "i32"),
7319            (DType::U64, "u64"),
7320            (DType::I64, "i64"),
7321            (DType::F16, "f16"),
7322            (DType::F32, "f32"),
7323            (DType::F64, "f64"),
7324        ] {
7325            assert_eq!(dt.to_string(), name);
7326        }
7327    }
7328
7329    #[test]
7330    fn dtype_of_maps_every_element_type_and_nothing_else() {
7331        assert_eq!(dtype_of::<u8>(), Some(DType::U8));
7332        assert_eq!(dtype_of::<i8>(), Some(DType::I8));
7333        assert_eq!(dtype_of::<u16>(), Some(DType::U16));
7334        assert_eq!(dtype_of::<i16>(), Some(DType::I16));
7335        assert_eq!(dtype_of::<u32>(), Some(DType::U32));
7336        assert_eq!(dtype_of::<i32>(), Some(DType::I32));
7337        assert_eq!(dtype_of::<u64>(), Some(DType::U64));
7338        assert_eq!(dtype_of::<i64>(), Some(DType::I64));
7339        assert_eq!(dtype_of::<half::f16>(), Some(DType::F16));
7340        assert_eq!(dtype_of::<f32>(), Some(DType::F32));
7341        assert_eq!(dtype_of::<f64>(), Some(DType::F64));
7342        assert_eq!(dtype_of::<bool>(), None);
7343    }
7344
7345    #[test]
7346    fn tensor_memory_converts_to_and_from_its_wire_string() {
7347        assert_eq!(String::from(TensorMemory::DmaBuf), "dmabuf");
7348        assert_eq!(String::from(TensorMemory::Shm), "shm");
7349        assert_eq!(
7350            TensorMemory::try_from("dmabuf").unwrap(),
7351            TensorMemory::DmaBuf
7352        );
7353        assert_eq!(
7354            TensorMemory::try_from("dma").unwrap(),
7355            TensorMemory::DmaBuf,
7356            "the legacy spelling still parses"
7357        );
7358        assert_eq!(TensorMemory::try_from("mem").unwrap(), TensorMemory::Mem);
7359        match TensorMemory::try_from("bogus") {
7360            Err(Error::InvalidMemoryType(s)) => assert_eq!(s, "bogus"),
7361            other => panic!("expected InvalidMemoryType, got {other:?}"),
7362        }
7363    }
7364
7365    #[test]
7366    fn tensor_memory_is_available_matches_a_real_allocation() {
7367        let _lock = crate::tests::fd_lock_shared();
7368        assert!(TensorMemory::Mem.is_available());
7369        assert!(!TensorMemory::IoSurface.is_available());
7370        assert!(!TensorMemory::Pbo.is_available());
7371        assert!(!TensorMemory::Cuda.is_available());
7372        assert!(!TensorMemory::D3d11Texture.is_available());
7373        #[cfg(unix)]
7374        assert_eq!(
7375            TensorMemory::Shm.is_available(),
7376            Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok()
7377        );
7378        #[cfg(not(target_os = "windows"))]
7379        assert_eq!(
7380            TensorMemory::DmaBuf.is_available(),
7381            Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
7382        );
7383    }
7384
7385    #[test]
7386    #[cfg(target_os = "linux")]
7387    fn linux_probes_report_what_linux_can_allocate() {
7388        let _lock = crate::tests::fd_lock_shared();
7389        let dma = DmaTensor::<u8>::new(&[64], None).is_ok();
7390        assert_eq!(is_dma_available(), dma);
7391        assert_eq!(is_gpu_buffer_available(), dma);
7392        assert!(!is_iosurface_available());
7393        assert!(!is_ahardwarebuffer_available());
7394        // Linux (unlike Android's bionic) always has POSIX shared memory.
7395        assert!(is_shm_available());
7396    }
7397}
7398
7399#[cfg(all(test, not(edgefirst_tensor_dynamic)))]
7400mod quantization_serde_tests {
7401    use super::*;
7402
7403    fn parse(json: &str) -> Quantization {
7404        serde_json::from_str(json).unwrap_or_else(|e| panic!("{json}: {e}"))
7405    }
7406
7407    #[test]
7408    fn scale_accepts_every_json_number_form() {
7409        assert_eq!(parse(r#"{"scale": 0.5}"#).scale(), &[0.5]);
7410        assert_eq!(parse(r#"{"scale": 2}"#).scale(), &[2.0]);
7411        assert_eq!(parse(r#"{"scale": -2}"#).scale(), &[-2.0]);
7412        assert_eq!(parse(r#"{"scale": [0.25, 4]}"#).scale(), &[0.25, 4.0]);
7413    }
7414
7415    #[test]
7416    fn zero_point_accepts_null_scalar_and_array() {
7417        assert_eq!(
7418            parse(r#"{"scale": 0.1, "zero_point": -5}"#).zero_point(),
7419            Some(&[-5][..])
7420        );
7421        assert_eq!(
7422            parse(r#"{"scale": 0.1, "zero_point": 3}"#).zero_point(),
7423            Some(&[3][..])
7424        );
7425        assert_eq!(
7426            parse(r#"{"scale": [0.1, 0.2], "zero_point": [1, -2], "axis": 0}"#).zero_point(),
7427            Some(&[1, -2][..])
7428        );
7429        assert_eq!(
7430            parse(r#"{"scale": 0.1, "zero_point": null}"#).zero_point(),
7431            None
7432        );
7433        assert_eq!(parse(r#"{"scale": 0.1}"#).zero_point(), None);
7434    }
7435
7436    /// Deserializers whose `deserialize_option` forwards to
7437    /// `deserialize_any` (serde's value deserializers among them) reach the
7438    /// zero-point visitor's scalar and sequence arms directly, not through
7439    /// `visit_some`.
7440    #[test]
7441    fn zero_point_visitor_accepts_values_without_an_option_wrapper() {
7442        use serde::de::value::{
7443            Error as ValueError, I64Deserializer, SeqDeserializer, U64Deserializer,
7444        };
7445        assert_eq!(
7446            deserialize_opt_scalar_or_vec_i32(I64Deserializer::<ValueError>::new(-7)).unwrap(),
7447            Some(vec![-7])
7448        );
7449        assert_eq!(
7450            deserialize_opt_scalar_or_vec_i32(U64Deserializer::<ValueError>::new(9)).unwrap(),
7451            Some(vec![9])
7452        );
7453        let seq = SeqDeserializer::<_, ValueError>::new(vec![4i32, -4].into_iter());
7454        assert_eq!(
7455            deserialize_opt_scalar_or_vec_i32(seq).unwrap(),
7456            Some(vec![4, -4])
7457        );
7458    }
7459
7460    #[test]
7461    fn per_channel_is_not_per_tensor() {
7462        let q = Quantization::per_channel(vec![0.1, 0.2], vec![0, 1], 0).unwrap();
7463        assert!(!q.is_per_tensor());
7464        assert!(Quantization::per_tensor(0.1, 0).is_per_tensor());
7465    }
7466
7467    #[test]
7468    fn validate_accepts_matching_per_channel_and_names_the_failing_field() {
7469        let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
7470        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3, 0.4], vec![0, 1, 2, 3], 2).unwrap();
7471        t.set_quantization(q)
7472            .expect("four channels on axis 2 of [1, 1, 4]");
7473        assert_eq!(
7474            t.quantization().and_then(Quantization::zero_point),
7475            Some(&[0, 1, 2, 3][..])
7476        );
7477
7478        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3, 0.4], vec![0, 1, 2, 3], 3).unwrap();
7479        match t.set_quantization(q) {
7480            Err(Error::QuantizationInvalid { field, got, .. }) => {
7481                assert_eq!(field, "axis");
7482                assert_eq!(got, "axis=3");
7483            }
7484            other => panic!("axis 3 of a rank-3 tensor must be refused, got {other:?}"),
7485        }
7486
7487        let q = Quantization::per_channel_symmetric(vec![0.1, 0.2, 0.3, 0.4], 1).unwrap();
7488        match t.set_quantization(q) {
7489            Err(Error::QuantizationInvalid { field, got, .. }) => {
7490                assert_eq!(field, "scale.len");
7491                assert_eq!(got, "length 4");
7492            }
7493            other => panic!("four scales on a length-1 axis must be refused, got {other:?}"),
7494        }
7495    }
7496}
7497
7498#[cfg(all(test, not(edgefirst_tensor_dynamic)))]
7499mod image_geometry_tests {
7500    use super::*;
7501
7502    fn mem_image<T>(width: usize, height: usize, format: PixelFormat) -> Tensor<T>
7503    where
7504        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7505    {
7506        Tensor::<T>::image(
7507            width,
7508            height,
7509            format,
7510            Some(TensorMemory::Mem),
7511            CpuAccess::ReadWrite,
7512        )
7513        .unwrap_or_else(|e| panic!("Mem {format:?} {width}x{height}: {e}"))
7514    }
7515
7516    fn raw_mem<T>(shape: &[usize]) -> Tensor<T>
7517    where
7518        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7519    {
7520        Tensor::<T>::new(shape, Some(TensorMemory::Mem), None).unwrap()
7521    }
7522
7523    #[test]
7524    fn semi_planar_image_stride_scales_with_the_element_size() {
7525        // u16 NV12 at width 100: 200 B rows, aligned to 256.
7526        let t = mem_image::<u16>(100, 64, PixelFormat::Nv12);
7527        assert_eq!(t.row_stride(), Some(256));
7528        assert_eq!(t.capacity_bytes(), 256 * 96);
7529    }
7530
7531    #[test]
7532    fn host_planar_image_is_tight_at_every_element_size() {
7533        let t = mem_image::<u8>(320, 240, PixelFormat::PlanarRgb);
7534        assert_eq!(t.row_stride(), None);
7535        assert_eq!(t.effective_row_stride(), Some(320));
7536        assert_eq!(t.capacity_bytes(), 320 * 240 * 3);
7537
7538        let t = mem_image::<f32>(100, 40, PixelFormat::PlanarRgb);
7539        assert_eq!(t.row_stride(), None);
7540        assert_eq!(t.effective_row_stride(), Some(400));
7541        assert_eq!(t.capacity_bytes(), 400 * 120);
7542    }
7543
7544    #[test]
7545    #[cfg(target_os = "linux")]
7546    fn dma_packed_image_records_the_aligned_pitch_only_when_padded() {
7547        let _lock = crate::tests::fd_lock_shared();
7548        if !crate::test_support::dma_or_skip(
7549            "dma_packed_image_records_the_aligned_pitch_only_when_padded",
7550        ) {
7551            return;
7552        }
7553        let t = Tensor::<u8>::image(
7554            100,
7555            10,
7556            PixelFormat::Rgba,
7557            Some(TensorMemory::DmaBuf),
7558            CpuAccess::ReadWrite,
7559        )
7560        .unwrap();
7561        assert_eq!(t.row_stride(), Some(448), "400 B rows pad to 448");
7562        let t = Tensor::<u8>::image(
7563            64,
7564            10,
7565            PixelFormat::Rgba,
7566            Some(TensorMemory::DmaBuf),
7567            CpuAccess::ReadWrite,
7568        )
7569        .unwrap();
7570        assert_eq!(t.row_stride(), None, "256 B rows are already aligned");
7571    }
7572
7573    #[test]
7574    #[cfg(target_os = "linux")]
7575    fn image_with_stride_validates_the_stride_before_the_memory() {
7576        match Tensor::<u8>::image_with_stride(
7577            640,
7578            480,
7579            PixelFormat::Rgba,
7580            2559,
7581            Some(TensorMemory::Mem),
7582            CpuAccess::ReadWrite,
7583        ) {
7584            Err(Error::InvalidArgument(msg)) => {
7585                assert!(msg.contains("2559 < minimum 2560"), "{msg}")
7586            }
7587            other => panic!("expected InvalidArgument, got {other:?}"),
7588        }
7589        match Tensor::<u8>::image_with_stride(
7590            640,
7591            480,
7592            PixelFormat::Rgba,
7593            2560,
7594            Some(TensorMemory::Mem),
7595            CpuAccess::ReadWrite,
7596        ) {
7597            Err(Error::NotImplemented(msg)) => assert!(msg.contains("only TensorMemory::DmaBuf")),
7598            other => panic!("a valid stride on Mem must reach the memory check, got {other:?}"),
7599        }
7600    }
7601
7602    #[test]
7603    fn set_format_checks_the_planar_and_semi_planar_shape() {
7604        let mut t = raw_mem::<u8>(&[480, 640, 3]);
7605        match t.set_format(PixelFormat::PlanarRgb) {
7606            Err(Error::InvalidShape(msg)) => assert!(msg.contains("[3, H, W]"), "{msg}"),
7607            other => panic!("[480, 640, 3] is not planar, got {other:?}"),
7608        }
7609        assert!(raw_mem::<u8>(&[3, 480, 640])
7610            .set_format(PixelFormat::PlanarRgb)
7611            .is_ok());
7612
7613        for (format, bad, good) in [
7614            (PixelFormat::Nv12, 721, [720usize, 722]),
7615            (PixelFormat::Nv16, 481, [480, 482]),
7616            (PixelFormat::Nv24, 1441, [1440, 1443]),
7617        ] {
7618            match raw_mem::<u8>(&[bad, 640]).set_format(format) {
7619                Err(Error::InvalidShape(msg)) => {
7620                    assert!(msg.contains(&bad.to_string()), "{format:?}: {msg}")
7621                }
7622                other => panic!("{format:?} [{bad}, 640] must be refused, got {other:?}"),
7623            }
7624            for rows in good {
7625                raw_mem::<u8>(&[rows, 640])
7626                    .set_format(format)
7627                    .unwrap_or_else(|e| panic!("{format:?} [{rows}, 640]: {e}"));
7628            }
7629        }
7630    }
7631
7632    #[test]
7633    fn contiguous_semi_planar_height_divides_the_combined_rows() {
7634        assert_eq!(
7635            mem_image::<u8>(320, 240, PixelFormat::Nv24).height(),
7636            Some(240)
7637        );
7638        assert_eq!(
7639            mem_image::<u8>(320, 240, PixelFormat::Nv16).height(),
7640            Some(240)
7641        );
7642        assert_eq!(
7643            mem_image::<u8>(320, 241, PixelFormat::Nv12).height(),
7644            Some(241)
7645        );
7646    }
7647
7648    #[test]
7649    fn effective_row_stride_scales_with_the_element_size() {
7650        assert_eq!(
7651            mem_image::<f32>(10, 4, PixelFormat::Rgb).effective_row_stride(),
7652            Some(120)
7653        );
7654        assert_eq!(
7655            mem_image::<f32>(10, 4, PixelFormat::PlanarRgb).effective_row_stride(),
7656            Some(40)
7657        );
7658        let mut t = raw_mem::<u16>(&[360, 320]);
7659        t.set_format(PixelFormat::Nv12).unwrap();
7660        assert_eq!(t.effective_row_stride(), Some(640));
7661        let mut t = raw_mem::<u16>(&[360, 321]);
7662        t.set_format(PixelFormat::Nv12).unwrap();
7663        assert_eq!(
7664            t.effective_row_stride(),
7665            Some(644),
7666            "odd width rounds up to even"
7667        );
7668    }
7669
7670    #[test]
7671    fn set_row_stride_minimum_scales_with_the_element_size() {
7672        let refuse = |mut t: Tensor<f32>, stride: usize, min: usize| match t.set_row_stride(stride)
7673        {
7674            Err(Error::InvalidArgument(msg)) => {
7675                assert!(msg.contains(&format!("{stride} < minimum {min}")), "{msg}")
7676            }
7677            other => panic!("stride {stride} under {min} must be refused, got {other:?}"),
7678        };
7679        refuse(mem_image::<f32>(10, 4, PixelFormat::Rgb), 119, 120);
7680        refuse(mem_image::<f32>(10, 4, PixelFormat::PlanarRgb), 39, 40);
7681        let mut t = mem_image::<f32>(10, 4, PixelFormat::Rgb);
7682        t.set_row_stride(120).unwrap();
7683        assert_eq!(t.row_stride(), Some(120));
7684        let mut t = mem_image::<f32>(10, 4, PixelFormat::PlanarRgb);
7685        t.set_row_stride(40).unwrap();
7686        assert_eq!(t.row_stride(), Some(40));
7687
7688        let mut t = mem_image::<u16>(101, 64, PixelFormat::Nv12);
7689        match t.set_row_stride(203) {
7690            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("203 < minimum 204"), "{msg}"),
7691            other => panic!("expected InvalidArgument, got {other:?}"),
7692        }
7693        t.set_row_stride(204).unwrap();
7694    }
7695
7696    #[test]
7697    fn configure_image_semi_planar_stride_scales_with_the_element_size() {
7698        let mut t = mem_image::<u16>(640, 480, PixelFormat::Rgba);
7699        t.configure_image(100, 64, PixelFormat::Nv12).unwrap();
7700        assert_eq!(t.row_stride(), Some(256));
7701    }
7702
7703    #[test]
7704    fn configure_image_keeps_host_packed_odd_widths_tight() {
7705        let mut t = mem_image::<u8>(640, 480, PixelFormat::Rgb);
7706        t.configure_image(321, 240, PixelFormat::Rgb).unwrap();
7707        assert_eq!(t.row_stride(), None);
7708        assert_eq!(t.effective_row_stride(), Some(963));
7709    }
7710
7711    #[test]
7712    fn configure_image_does_not_inherit_a_stride_under_the_same_format() {
7713        // Host packed: a stride set for one geometry must not survive into
7714        // the next, where it is below the new minimum row.
7715        let mut t = mem_image::<u8>(640, 480, PixelFormat::Rgb);
7716        t.configure_image(100, 100, PixelFormat::Rgb).unwrap();
7717        t.set_row_stride(400).unwrap();
7718        t.configure_image(300, 100, PixelFormat::Rgb).unwrap();
7719        assert_eq!(t.row_stride(), None);
7720        assert_eq!(t.effective_row_stride(), Some(900));
7721
7722        // Semi-planar whose aligned stride does not fit: the tight row does,
7723        // and must be what the tensor reports -- not the old 64.
7724        let mut t = mem_image::<u8>(64, 1000, PixelFormat::Nv12);
7725        assert_eq!(t.row_stride(), Some(64));
7726        t.configure_image(200, 300, PixelFormat::Nv12).unwrap();
7727        assert_eq!(t.row_stride(), None);
7728        assert_eq!(t.effective_row_stride(), Some(200));
7729    }
7730
7731    #[test]
7732    fn configure_image_pool_reuse_picks_the_stride_that_fits() {
7733        // A prior stride narrower than the new row is replaced by the
7734        // aligned one.
7735        let mut t = mem_image::<u8>(64, 1024, PixelFormat::Nv12);
7736        t.configure_image(200, 100, PixelFormat::Nv12).unwrap();
7737        assert_eq!(t.row_stride(), Some(256));
7738
7739        // An unaligned prior stride is replaced too.
7740        let mut t = mem_image::<u8>(64, 64, PixelFormat::Nv12);
7741        t.set_row_stride(100).unwrap();
7742        t.configure_image(64, 64, PixelFormat::Nv12).unwrap();
7743        assert_eq!(t.row_stride(), Some(64));
7744
7745        // A wide prior stride whose rows would overrun the allocation gives
7746        // way to the aligned stride of the new width.
7747        let mut t = mem_image::<u8>(640, 480, PixelFormat::Nv12);
7748        t.configure_image(320, 900, PixelFormat::Nv12).unwrap();
7749        assert_eq!(t.row_stride(), Some(320));
7750
7751        // Neither the aligned stride fits nor is it needed: the tight row
7752        // exactly fills a raw allocation.
7753        let mut t = raw_mem::<u8>(&[150 * 100]);
7754        t.configure_image(100, 100, PixelFormat::Nv12).unwrap();
7755        assert_eq!(t.row_stride(), None);
7756        assert_eq!(t.effective_row_stride(), Some(100));
7757    }
7758
7759    #[test]
7760    fn configure_image_refuses_rows_that_overrun_the_allocation() {
7761        // Odd width 99: the shape [150, 99] fits 14850 bytes, but NV12 rows
7762        // are at least the even width, 100 * 150 = 15000 bytes.
7763        let mut t = raw_mem::<u8>(&[150 * 99]);
7764        match t.configure_image(99, 100, PixelFormat::Nv12) {
7765            Err(Error::InsufficientCapacity { needed, capacity }) => {
7766                assert_eq!((needed, capacity), (15000, 14850))
7767            }
7768            other => panic!("expected InsufficientCapacity, got {other:?}"),
7769        }
7770    }
7771
7772    #[test]
7773    #[cfg(target_os = "linux")]
7774    fn configure_image_on_dma_aligns_packed_and_planar_rows() {
7775        let _lock = crate::tests::fd_lock_shared();
7776        if !crate::test_support::dma_or_skip("configure_image_on_dma_aligns_packed_and_planar_rows")
7777        {
7778            return;
7779        }
7780        // Packed u16 Rgb at width 100: 600 B rows align to 640.
7781        let mut t = Tensor::<u16>::image(
7782            640,
7783            480,
7784            PixelFormat::Rgba,
7785            Some(TensorMemory::DmaBuf),
7786            CpuAccess::ReadWrite,
7787        )
7788        .unwrap();
7789        t.configure_image(100, 50, PixelFormat::Rgb).unwrap();
7790        assert_eq!(t.row_stride(), Some(640));
7791
7792        // Planar u16 at width 100: 200 B rows would align to 256, but 150
7793        // rows of 256 B overrun the 32 KiB buffer while 150 rows of 200 B fit.
7794        let mut t = Tensor::<u16>::new(&[16 * 1024], Some(TensorMemory::DmaBuf), None).unwrap();
7795        assert_eq!(t.capacity_bytes(), 32 * 1024);
7796        t.configure_image(100, 50, PixelFormat::PlanarRgb).unwrap();
7797        assert_eq!(t.row_stride(), None);
7798        assert_eq!(t.effective_row_stride(), Some(200));
7799        t.configure_image(100, 40, PixelFormat::PlanarRgb).unwrap();
7800        assert_eq!(t.row_stride(), Some(256), "120 rows of 256 B fit");
7801    }
7802
7803    #[test]
7804    fn from_planes_requires_a_raw_two_dimensional_chroma() {
7805        let formatted_chroma = mem_image::<u8>(640, 240, PixelFormat::Grey);
7806        match Tensor::from_planes(
7807            raw_mem::<u8>(&[480, 640]),
7808            formatted_chroma,
7809            PixelFormat::Nv12,
7810        ) {
7811            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("raw tensor"), "{msg}"),
7812            other => panic!("a formatted chroma plane must be refused, got {other:?}"),
7813        }
7814        match Tensor::from_planes(
7815            raw_mem::<u8>(&[480, 640, 1]),
7816            raw_mem::<u8>(&[240, 640]),
7817            PixelFormat::Nv12,
7818        ) {
7819            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("2D shapes"), "{msg}"),
7820            other => panic!("a 3-D luma plane must be refused, got {other:?}"),
7821        }
7822    }
7823
7824    #[test]
7825    fn from_planes_nv16_needs_full_height_chroma() {
7826        let img = Tensor::from_planes(
7827            raw_mem::<u8>(&[480, 640]),
7828            raw_mem::<u8>(&[480, 640]),
7829            PixelFormat::Nv16,
7830        )
7831        .expect("NV16 chroma is full height");
7832        assert_eq!(img.format(), Some(PixelFormat::Nv16));
7833        assert_eq!(img.height(), Some(480));
7834
7835        match Tensor::from_planes(
7836            raw_mem::<u8>(&[480, 640]),
7837            raw_mem::<u8>(&[240, 640]),
7838            PixelFormat::Nv16,
7839        ) {
7840            Err(Error::InvalidArgument(msg)) => {
7841                assert!(msg.contains("NV16 chroma height"), "{msg}")
7842            }
7843            other => panic!("half-height NV16 chroma must be refused, got {other:?}"),
7844        }
7845    }
7846
7847    #[test]
7848    fn chroma_mut_writes_the_chroma_plane() {
7849        let mut img = Tensor::from_planes(
7850            raw_mem::<u8>(&[4, 8]),
7851            raw_mem::<u8>(&[2, 8]),
7852            PixelFormat::Nv12,
7853        )
7854        .unwrap();
7855        let chroma = img.chroma_mut().expect("a multiplane image has chroma");
7856        chroma.map().unwrap().as_mut_slice().fill(0x80);
7857        let chroma = img.chroma().unwrap();
7858        assert!(chroma.map().unwrap().as_slice().iter().all(|&b| b == 0x80));
7859    }
7860
7861    #[test]
7862    fn view_offset_and_stride_scale_with_the_element_size() {
7863        let t = mem_image::<u8>(100, 80, PixelFormat::Rgba);
7864        let v = t.view(Region::new(10, 20, 30, 40)).unwrap();
7865        assert_eq!(v.plane_offset(), Some(20 * 400 + 10 * 4));
7866        assert_eq!(v.row_stride(), Some(400));
7867
7868        let t = mem_image::<u16>(100, 80, PixelFormat::Rgba);
7869        let v = t.view(Region::new(10, 20, 30, 40)).unwrap();
7870        assert_eq!(v.plane_offset(), Some(20 * 800 + 10 * 8));
7871        assert_eq!(v.row_stride(), Some(800));
7872        assert_eq!(v.view_origin().map(|o| o.parent_row_stride), Some(800));
7873    }
7874
7875    #[test]
7876    fn single_row_view_keeps_a_tight_stride() {
7877        let t = mem_image::<u8>(8, 4, PixelFormat::Rgba);
7878        let row = t.view(Region::new(2, 3, 4, 1)).unwrap();
7879        assert_eq!(row.effective_row_stride(), Some(16));
7880        assert_eq!(row.map().unwrap().as_slice().len(), 16);
7881        assert_eq!(row.view_origin().map(|o| o.parent_row_stride), Some(32));
7882
7883        let two_rows = t.view(Region::new(2, 2, 4, 2)).unwrap();
7884        assert_eq!(two_rows.effective_row_stride(), Some(32));
7885    }
7886
7887    #[test]
7888    fn view_refuses_a_region_past_either_edge() {
7889        let t = mem_image::<u8>(64, 40, PixelFormat::Rgba);
7890        for region in [Region::new(0, 30, 10, 20), Region::new(60, 0, 10, 1)] {
7891            match t.view(region) {
7892                Err(Error::RegionOutOfBounds { region: r, bounds }) => {
7893                    assert_eq!((r, bounds), (region, (64, 40)))
7894                }
7895                other => panic!("{region:?} must be refused, got {other:?}"),
7896            }
7897        }
7898        assert!(Region::new(0, 0, 64, 40).fits_within(64, 40));
7899        assert!(!Region::new(1, 0, 64, 40).fits_within(64, 40));
7900        assert!(!Region::new(0, 1, 64, 40).fits_within(64, 40));
7901    }
7902}
7903
7904#[cfg(all(test, not(edgefirst_tensor_dynamic)))]
7905mod strided_access_tests {
7906    use super::*;
7907
7908    /// A patterned byte view of `t`'s whole mapping: byte `i` is `i % 251`.
7909    fn fill_pattern<T>(t: &Tensor<T>)
7910    where
7911        T: Num + Clone + fmt::Debug + Send + Sync,
7912    {
7913        let mut m = t.map().unwrap();
7914        let s = m.as_mut_slice();
7915        // SAFETY: plain numeric elements; every byte pattern is a valid value
7916        // for the integer and float types these tests use.
7917        let bytes = unsafe {
7918            std::slice::from_raw_parts_mut(s.as_mut_ptr() as *mut u8, std::mem::size_of_val(s))
7919        };
7920        for (i, b) in bytes.iter_mut().enumerate() {
7921            *b = (i % 251) as u8;
7922        }
7923    }
7924
7925    fn expected_rows(rows: usize, stride: usize, row_bytes: usize) -> Vec<u8> {
7926        (0..rows)
7927            .flat_map(|r| (r * stride..r * stride + row_bytes).map(|i| (i % 251) as u8))
7928            .collect()
7929    }
7930
7931    fn padded<T>(format: PixelFormat, stride: usize) -> Tensor<T>
7932    where
7933        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7934    {
7935        let mut t = Tensor::<T>::image_with_capacity(
7936            16,
7937            4,
7938            format,
7939            Some(TensorMemory::Mem),
7940            CpuAccess::ReadWrite,
7941        )
7942        .unwrap();
7943        t.configure_image(8, 4, format).unwrap();
7944        t.set_row_stride(stride).unwrap();
7945        t
7946    }
7947
7948    #[test]
7949    fn copy_to_flat_compacts_padded_packed_rows() {
7950        // 8 px * 3 B = 24 B rows at a 32 B pitch.
7951        let t = padded::<u8>(PixelFormat::Rgb, 32);
7952        fill_pattern(&t);
7953        let mut dst = vec![0u8; 96];
7954        t.copy_to_flat(&mut dst).unwrap();
7955        assert_eq!(dst, expected_rows(4, 32, 24));
7956    }
7957
7958    #[test]
7959    fn copy_to_flat_compacts_padded_planar_rows() {
7960        // f32 planar: 3 planes x 4 rows of 32 B at a 64 B pitch.
7961        let t = padded::<f32>(PixelFormat::PlanarRgb, 64);
7962        fill_pattern(&t);
7963        let mut dst = vec![0u8; 384];
7964        t.copy_to_flat(&mut dst).unwrap();
7965        assert_eq!(dst, expected_rows(12, 64, 32));
7966    }
7967
7968    #[test]
7969    fn copy_to_flat_of_a_tight_stride_is_the_mapping() {
7970        let t = padded::<u8>(PixelFormat::Rgb, 24);
7971        fill_pattern(&t);
7972        let mut dst = vec![0u8; 96];
7973        t.copy_to_flat(&mut dst).unwrap();
7974        assert_eq!(dst, expected_rows(1, 96, 96));
7975
7976        let t = Tensor::<u8>::image(
7977            8,
7978            4,
7979            PixelFormat::Rgb,
7980            Some(TensorMemory::Mem),
7981            CpuAccess::ReadWrite,
7982        )
7983        .unwrap();
7984        fill_pattern(&t);
7985        let mut dst = vec![0u8; 96];
7986        t.copy_to_flat(&mut dst).unwrap();
7987        assert_eq!(dst, expected_rows(1, 96, 96));
7988    }
7989
7990    #[test]
7991    fn copy_to_flat_needs_a_tight_sized_destination() {
7992        let t = padded::<u8>(PixelFormat::Rgb, 32);
7993        let mut dst = vec![0u8; 95];
7994        match t.copy_to_flat(&mut dst) {
7995            Err(Error::InvalidArgument(msg)) => {
7996                assert!(
7997                    msg.contains("dst is 95 bytes") && msg.contains("96 bytes"),
7998                    "{msg}"
7999                )
8000            }
8001            other => panic!("expected InvalidArgument, got {other:?}"),
8002        }
8003    }
8004
8005    #[test]
8006    fn copy_to_flat_compacts_a_padded_batch() {
8007        // Two 8x2 RGB tiles at a 32 B pitch: four rows in all.
8008        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
8009        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
8010        fill_pattern(&t);
8011        let mut dst = vec![0u8; 96];
8012        t.copy_to_flat(&mut dst).unwrap();
8013        assert_eq!(dst, expected_rows(4, 32, 24));
8014    }
8015
8016    #[test]
8017    fn strided_planar_map_spans_every_plane_row() {
8018        let t = padded::<u8>(PixelFormat::PlanarRgb, 16);
8019        assert_eq!(t.map().unwrap().as_slice().len(), 16 * 12);
8020    }
8021
8022    #[test]
8023    fn batch_of_a_padded_batch_starts_at_its_padded_tile() {
8024        // Tile 1 starts after two 32 B rows, not after 48 tight bytes.
8025        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
8026        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
8027        fill_pattern(&t);
8028        let e = t.batch(1).unwrap();
8029        assert_eq!(e.plane_offset(), Some(64));
8030        let mut dst = vec![0u8; 48];
8031        e.copy_to_flat(&mut dst).unwrap();
8032        let tile1: Vec<u8> = expected_rows(4, 32, 24).split_off(48);
8033        assert_eq!(dst, tile1);
8034    }
8035
8036    #[test]
8037    fn strided_packed_batch_map_spans_every_tile_row() {
8038        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
8039        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
8040        assert_eq!(t.map().unwrap().as_slice().len(), 32 * 4);
8041    }
8042
8043    #[test]
8044    fn strided_access_to_a_flattened_image_is_refused() {
8045        let mut t = padded::<u8>(PixelFormat::PlanarRgb, 16);
8046        t.set_logical_shape(&[96]).unwrap();
8047        match t.map() {
8048            Err(Error::InvalidOperation(msg)) => assert!(msg.contains("[96]"), "{msg}"),
8049            other => panic!("expected InvalidOperation, got {other:?}"),
8050        }
8051        let mut dst = vec![0u8; 96];
8052        match t.copy_to_flat(&mut dst) {
8053            Err(Error::InvalidOperation(msg)) => assert!(msg.contains("[96]"), "{msg}"),
8054            other => panic!("expected InvalidOperation, got {other:?}"),
8055        }
8056    }
8057
8058    #[test]
8059    #[cfg(target_os = "linux")]
8060    fn strided_shm_map_is_bounded_by_the_segment() {
8061        let _lock = crate::tests::fd_lock_shared();
8062        let mut t = Tensor::<u8>::image(
8063            640,
8064            480,
8065            PixelFormat::Nv12,
8066            Some(TensorMemory::Shm),
8067            CpuAccess::ReadWrite,
8068        )
8069        .expect("Linux has POSIX shared memory");
8070        assert_eq!(t.capacity_bytes(), 640 * 720);
8071        assert_eq!(t.map().unwrap().as_slice().len(), 640 * 720);
8072
8073        t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
8074        assert_eq!(t.row_stride(), Some(640), "the pool pitch fits and is kept");
8075        assert_eq!(t.map().unwrap().as_slice().len(), 640 * 360);
8076
8077        t.configure_image(640, 480, PixelFormat::Nv12).unwrap();
8078        t.set_row_stride(704).unwrap();
8079        match t.map() {
8080            Err(Error::InsufficientCapacity { needed, capacity }) => {
8081                assert_eq!((needed, capacity), (704 * 720, 640 * 720))
8082            }
8083            other => panic!("expected InsufficientCapacity, got {other:?}"),
8084        }
8085    }
8086
8087    /// Minimal host-memory `PboOps`: a fixed buffer, never resized.
8088    struct HostPbo(Vec<u8>);
8089
8090    // SAFETY: the pointer addresses `self.0`, allocated once and never
8091    // resized, and the tensor holds the `Arc` to `self` for every map's life.
8092    unsafe impl PboOps for HostPbo {
8093        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
8094            assert!(size <= self.0.len());
8095            Ok(PboMapping {
8096                ptr: self.0.as_ptr() as *mut u8,
8097                size,
8098            })
8099        }
8100        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
8101            Ok(())
8102        }
8103        fn delete_buffer(&self, _buffer_id: u32) {}
8104    }
8105
8106    fn pbo_rgba(width: usize, height: usize) -> Tensor<u8> {
8107        let bytes = width * height * 4;
8108        let pbo = PboTensor::<u8>::from_pbo(
8109            7,
8110            bytes,
8111            &[height, width, 4],
8112            None,
8113            Arc::new(HostPbo(vec![0; bytes])),
8114        )
8115        .unwrap();
8116        let mut t = Tensor::from_pbo(pbo).unwrap();
8117        t.set_format(PixelFormat::Rgba).unwrap();
8118        t
8119    }
8120
8121    #[test]
8122    fn strided_pbo_map_is_bounded_by_the_buffer() {
8123        let mut t = pbo_rgba(8, 4);
8124        t.set_row_stride(32).unwrap();
8125        assert_eq!(t.map().unwrap().as_slice().len(), 128);
8126
8127        t.set_row_stride(64).unwrap();
8128        match t.map() {
8129            Err(Error::InsufficientCapacity { needed, capacity }) => {
8130                assert_eq!((needed, capacity), (256, 128))
8131            }
8132            other => panic!("expected InsufficientCapacity, got {other:?}"),
8133        }
8134    }
8135
8136    #[test]
8137    fn pbo_accessors_answer_only_for_a_pbo_tensor() {
8138        let mem = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
8139        assert_eq!(mem.pbo_is_mapped(), None);
8140        assert!(mem.pbo_keepalive().is_none());
8141
8142        let t = pbo_rgba(8, 4);
8143        assert_eq!(t.pbo_is_mapped(), Some(false));
8144        assert!(t.pbo_keepalive().is_some());
8145        let m = t.map().unwrap();
8146        assert_eq!(t.pbo_is_mapped(), Some(true));
8147        drop(m);
8148        assert_eq!(t.pbo_is_mapped(), Some(false));
8149    }
8150}
8151
8152#[cfg(all(test, not(edgefirst_tensor_dynamic)))]
8153mod accessor_tests {
8154    use super::*;
8155    use std::sync::atomic::{AtomicUsize, Ordering};
8156
8157    #[test]
8158    #[cfg(unix)]
8159    fn plane_descriptor_carries_its_stride_and_offset() {
8160        use std::os::fd::AsFd;
8161        let _lock = crate::tests::fd_lock_shared();
8162        let file = std::fs::File::open("/dev/null").unwrap();
8163        let pd = PlaneDescriptor::new(file.as_fd()).unwrap();
8164        assert_eq!((pd.stride(), pd.offset()), (None, None));
8165        let pd = pd.with_stride(832).with_offset(4096);
8166        assert_eq!((pd.stride(), pd.offset()), (Some(832), Some(4096)));
8167    }
8168
8169    #[test]
8170    #[cfg(target_os = "linux")]
8171    fn as_dma_answers_only_for_a_dma_buffer() {
8172        use std::os::fd::AsRawFd;
8173        let _lock = crate::tests::fd_lock_shared();
8174        let mem = Tensor::<u8>::new(&[64], Some(TensorMemory::Mem), None).unwrap();
8175        assert!(mem.as_dma().is_none());
8176        if !crate::test_support::dma_or_skip("as_dma_answers_only_for_a_dma_buffer") {
8177            return;
8178        }
8179        let t = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).unwrap();
8180        let dma = t.as_dma().expect("a DMA tensor downcasts");
8181        assert_eq!(
8182            dma.fd.as_raw_fd(),
8183            t.dmabuf().unwrap().as_raw_fd(),
8184            "the downcast is this tensor's own buffer"
8185        );
8186    }
8187
8188    #[test]
8189    fn derived_identity_mixes_the_kind_into_the_key() {
8190        let id = BufferIdentity::derived(IdentityKind::Shm, u64::MAX);
8191        assert_eq!(id.id(), ((IdentityKind::Shm as u64) << 56) ^ u64::MAX);
8192        assert_ne!(
8193            id.id(),
8194            BufferIdentity::derived(IdentityKind::DmaBuf, u64::MAX).id()
8195        );
8196    }
8197
8198    #[test]
8199    fn empty_is_a_zero_element_shape() {
8200        let mut t = Tensor::<f32>::new(&[2, 3], Some(TensorMemory::Mem), None).unwrap();
8201        assert!(!TensorTrait::is_empty(&t));
8202        {
8203            let m = t.map().unwrap();
8204            assert_eq!(TensorMapTrait::len(&m), 6);
8205            assert_eq!(TensorMapTrait::size(&m), 24);
8206            assert!(!TensorMapTrait::is_empty(&m));
8207        }
8208        t.set_logical_shape(&[0]).unwrap();
8209        assert!(TensorTrait::is_empty(&t));
8210        let m = t.map().unwrap();
8211        assert_eq!(TensorMapTrait::size(&m), 0);
8212        assert!(TensorMapTrait::is_empty(&m));
8213    }
8214
8215    struct CountingCudaOps {
8216        maps: AtomicUsize,
8217    }
8218
8219    impl CudaGlOps for CountingCudaOps {
8220        fn map(&self, _r: crate::cuda::GraphicsResource) -> Option<(*mut std::ffi::c_void, usize)> {
8221            self.maps.fetch_add(1, Ordering::SeqCst);
8222            Some((0x1000usize as *mut _, 64))
8223        }
8224        fn unmap(&self, _r: crate::cuda::GraphicsResource) {}
8225        fn unregister(&self, _r: crate::cuda::GraphicsResource) {}
8226    }
8227
8228    #[test]
8229    fn cuda_accessors_follow_the_attached_handle() {
8230        let mut t = Tensor::<f32>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
8231        assert!(!t.is_cuda_attached());
8232        assert!(t.cuda().is_none());
8233        assert!(t.cuda_map().is_none());
8234        assert!(t.cuda_map_mut().is_none());
8235
8236        let ops = Arc::new(CountingCudaOps {
8237            maps: AtomicUsize::new(0),
8238        });
8239        t.set_cuda_handle(CudaHandle::new_gl(
8240            0x1usize as crate::cuda::GraphicsResource,
8241            64,
8242            ops.clone(),
8243        ));
8244        assert!(t.is_cuda_attached());
8245        assert!(t.cuda().is_some());
8246        assert_eq!(t.cuda_map().map(|m| m.device_ptr() as usize), Some(0x1000));
8247        assert_eq!(t.cuda_map_mut().map(|m| m.len()), Some(64));
8248        assert_eq!(ops.maps.load(Ordering::SeqCst), 2);
8249    }
8250
8251    #[test]
8252    fn from_slice_copies_values_of_a_matching_shape() {
8253        let t = Tensor::<u16>::from_slice(&[1, 2, 3, 4, 5, 6], &[2, 3]).unwrap();
8254        assert_eq!(t.shape(), &[2, 3]);
8255        assert_eq!(t.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6]);
8256        match Tensor::<u16>::from_slice(&[1, 2, 3, 4, 5], &[2, 3]) {
8257            Err(Error::InvalidShape(msg)) => assert!(msg.contains("values.len()=5"), "{msg}"),
8258            other => panic!("expected InvalidShape, got {other:?}"),
8259        }
8260    }
8261
8262    /// An `Any` compression request with no pinned memory tries the
8263    /// platform's zero-copy image first and falls back when it cannot serve
8264    /// the format (packed RGB f32 has no IOSurface layout, for one).
8265    #[cfg(not(target_os = "android"))]
8266    #[test]
8267    fn any_compression_request_falls_back_when_zero_copy_cannot_serve() {
8268        let _lock = crate::tests::fd_lock_shared();
8269        let desc = ImageDesc::new(10, 10, PixelFormat::Rgb, DType::F32)
8270            .with_access(CpuAccess::None)
8271            .with_compression(Compression::Any);
8272        let t = Tensor::<f32>::image_desc(&desc).expect("Any never fails for compression reasons");
8273        assert_eq!(t.shape(), &[10, 10, 3]);
8274        assert_eq!(t.compression(), None);
8275    }
8276}
8277
8278#[cfg(test)]
8279mod cpu_access_tests {
8280    use super::*;
8281
8282    #[test]
8283    fn covers_matrix() {
8284        use CpuAccess::*;
8285        // Every declaration covers a narrower or equal request…
8286        for a in [None, Read, Write, ReadWrite] {
8287            assert!(a.covers(None), "{a:?} must cover None");
8288            assert!(ReadWrite.covers(a), "ReadWrite must cover {a:?}");
8289        }
8290        assert!(Read.covers(Read));
8291        assert!(Write.covers(Write));
8292        // …and never a wider one.
8293        assert!(!None.covers(Read));
8294        assert!(!None.covers(Write));
8295        assert!(!Read.covers(Write));
8296        assert!(!Read.covers(ReadWrite));
8297        assert!(!Write.covers(Read));
8298        assert!(!Write.covers(ReadWrite));
8299    }
8300
8301    #[test]
8302    fn union_covers_both_and_require_names_both() {
8303        use CpuAccess::*;
8304        for a in [None, Read, Write, ReadWrite] {
8305            for b in [None, Read, Write, ReadWrite] {
8306                let u = a.union(b);
8307                assert!(u.covers(a) && u.covers(b), "{a:?} | {b:?} = {u:?}");
8308                assert_eq!(a.require(b).is_ok(), a.covers(b));
8309            }
8310        }
8311        match Read.require(Write) {
8312            Err(Error::CpuAccessDenied {
8313                declared: Read,
8314                requested: Write,
8315            }) => {}
8316            other => panic!("expected CpuAccessDenied, got {other:?}"),
8317        }
8318    }
8319
8320    #[test]
8321    fn map_with_none_is_invalid() {
8322        let t = Tensor::<u8>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
8323        match t.map_with(CpuAccess::None) {
8324            Err(Error::InvalidArgument(_)) => {}
8325            Err(other) => panic!("expected InvalidArgument, got {other:?}"),
8326            Ok(_) => panic!("map_with(CpuAccess::None) must not succeed"),
8327        }
8328    }
8329
8330    #[test]
8331    fn read_map_rejects_mutation_uniformly() {
8332        // Mem backend: map_read yields a working read view whose mutable
8333        // accessor panics (the uniform cross-backend contract).
8334        let t = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8335        t.map_mut().unwrap().as_mut_slice().copy_from_slice(&[7; 8]);
8336        let ro = t.map_read().unwrap();
8337        assert_eq!(ro.as_slice(), &[7; 8]);
8338        drop(ro);
8339        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8340            let mut ro = t.map_read().unwrap();
8341            let _ = ro.as_mut_slice();
8342        }));
8343        assert!(result.is_err(), "as_mut_slice through map_read must panic");
8344    }
8345
8346    #[test]
8347    fn write_and_rw_maps_stay_mutable() {
8348        let t = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
8349        t.map_write()
8350            .unwrap()
8351            .as_mut_slice()
8352            .copy_from_slice(&[1; 4]);
8353        t.map().unwrap().as_mut_slice().copy_from_slice(&[2; 4]);
8354        assert_eq!(t.map_read().unwrap().as_slice(), &[2; 4]);
8355    }
8356
8357    /// The read-only IOSurface lock path: a `map_read` must observe data
8358    /// written through a prior read-write lock, and its unlock (which
8359    /// skips the cache flush) must not disturb subsequent reads.
8360    #[test]
8361    #[cfg(target_os = "macos")]
8362    fn iosurface_read_only_lock_roundtrip() {
8363        // Declares this test as an fd-opener; see FD_LOCK.
8364        let _lock = crate::tests::fd_lock_shared();
8365        let expect = if crate::is_iosurface_available() {
8366            crate::test_support::Expect::Succeeds
8367        } else {
8368            crate::test_support::Expect::MayFail("the IOSurface probe failed on this host")
8369        };
8370        let Some(t) = crate::test_support::allocated(
8371            Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None),
8372            "IOSurface allocation",
8373            expect,
8374        ) else {
8375            return;
8376        };
8377        {
8378            let mut m = t.map_mut().unwrap();
8379            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
8380                *b = (i * 3) as u8;
8381            }
8382        }
8383        for _ in 0..2 {
8384            let ro = t.map_read().unwrap();
8385            for (i, b) in ro.as_slice().iter().enumerate() {
8386                assert_eq!(*b, (i * 3) as u8, "byte {i} through read-only lock");
8387            }
8388        }
8389        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8390            let mut ro = t.map_read().unwrap();
8391            let _ = ro.as_mut_slice();
8392        }));
8393        assert!(result.is_err(), "IOSurface read map must reject mutation");
8394    }
8395}
8396
8397#[cfg(test)]
8398mod tests {
8399    use std::sync::{PoisonError, RwLock};
8400
8401    use super::*;
8402
8403    #[ctor::ctor(unsafe)]
8404    fn init() {
8405        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
8406    }
8407
8408    /// Macro to get the current function name for logging in tests.
8409    #[cfg(target_os = "linux")]
8410    macro_rules! function {
8411        () => {{
8412            fn f() {}
8413            fn type_name_of<T>(_: T) -> &'static str {
8414                std::any::type_name::<T>()
8415            }
8416            let name = type_name_of(f);
8417
8418            // Find and cut the rest of the path
8419            match &name[..name.len() - 3].rfind(':') {
8420                Some(pos) => &name[pos + 1..name.len() - 3],
8421                None => &name[..name.len() - 3],
8422            }
8423        }};
8424    }
8425
8426    #[test]
8427    #[cfg(target_os = "linux")]
8428    fn test_tensor() {
8429        let _lock = crate::tests::fd_lock_shared();
8430        let shape = vec![1];
8431        let tensor = DmaTensor::<f32>::new(&shape, Some("dma_tensor"));
8432        let dma_enabled = tensor.is_ok();
8433
8434        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8435        // Auto-select priority is Dma > Mem; Shm is never auto-selected.
8436        match dma_enabled {
8437            true => assert_eq!(tensor.memory(), TensorMemory::DmaBuf),
8438            false => assert_eq!(tensor.memory(), TensorMemory::Mem),
8439        }
8440    }
8441
8442    #[test]
8443    #[cfg(any(target_os = "macos", target_os = "ios"))]
8444    fn test_tensor() {
8445        let shape = vec![1];
8446        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8447        // macOS/iOS auto-fallback chain: IOSurface (Dma) → Mem. Healthy systems
8448        // return Dma; Mem only appears under memory pressure or sandboxed
8449        // contexts where IOSurfaceCreate fails. Shm is never auto-selected.
8450        let m = tensor.memory();
8451        assert!(
8452            matches!(m, TensorMemory::DmaBuf | TensorMemory::Mem),
8453            "Unexpected auto-fallback result on macOS/iOS: {m:?}"
8454        );
8455    }
8456
8457    #[test]
8458    #[cfg(all(
8459        unix,
8460        not(any(target_os = "linux", target_os = "macos", target_os = "ios"))
8461    ))]
8462    fn test_tensor() {
8463        let shape = vec![1];
8464        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8465        // Other Unix (BSD): no DMA, so auto-selection is Mem (Shm is
8466        // explicit-only, never auto-selected).
8467        assert_eq!(tensor.memory(), TensorMemory::Mem);
8468    }
8469
8470    #[test]
8471    #[cfg(not(unix))]
8472    fn test_tensor() {
8473        let shape = vec![1];
8474        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8475        assert_eq!(tensor.memory(), TensorMemory::Mem);
8476    }
8477
8478    #[test]
8479    #[cfg(target_os = "linux")]
8480    fn test_dma_tensor() {
8481        let _lock = crate::tests::fd_lock_shared();
8482        if !crate::test_support::dma_or_skip("test_dma_tensor") {
8483            return;
8484        }
8485
8486        let shape = vec![2, 3, 4];
8487        let tensor =
8488            DmaTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
8489
8490        const DUMMY_VALUE: f32 = 12.34;
8491
8492        assert_eq!(tensor.memory(), TensorMemory::DmaBuf);
8493        assert_eq!(tensor.name(), "test_tensor");
8494        assert_eq!(tensor.shape(), &shape);
8495        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
8496        assert_eq!(tensor.len(), 2 * 3 * 4);
8497
8498        {
8499            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8500            tensor_map.fill(42.0);
8501            assert!(tensor_map.iter().all(|&x| x == 42.0));
8502        }
8503
8504        {
8505            let shared = Tensor::<f32>::from_fd(
8506                tensor
8507                    .clone_fd()
8508                    .expect("Failed to duplicate tensor file descriptor"),
8509                &shape,
8510                Some("test_tensor_shared"),
8511            )
8512            .expect("Failed to create tensor from fd");
8513
8514            assert_eq!(shared.memory(), TensorMemory::DmaBuf);
8515            assert_eq!(shared.name(), "test_tensor_shared");
8516            assert_eq!(shared.shape(), &shape);
8517
8518            let mut tensor_map = shared.map().expect("Failed to map DMA memory from fd");
8519            tensor_map.fill(DUMMY_VALUE);
8520            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8521        }
8522
8523        {
8524            let tensor_map = tensor.map().expect("Failed to map DMA memory");
8525            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8526        }
8527
8528        let mut tensor = DmaTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
8529        assert_eq!(tensor.shape(), &shape);
8530        let new_shape = vec![3, 4, 4];
8531        assert!(
8532            tensor.reshape(&new_shape).is_err(),
8533            "Reshape should fail due to size mismatch"
8534        );
8535        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
8536
8537        let new_shape = vec![2, 3, 4];
8538        tensor.reshape(&new_shape).expect("Reshape should succeed");
8539        assert_eq!(
8540            tensor.shape(),
8541            &new_shape,
8542            "Shape should be updated after successful reshape"
8543        );
8544
8545        {
8546            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8547            tensor_map.fill(1);
8548            assert!(tensor_map.iter().all(|&x| x == 1));
8549        }
8550
8551        {
8552            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8553            tensor_map[2] = 42;
8554            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
8555            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
8556        }
8557    }
8558
8559    #[test]
8560    #[cfg(unix)]
8561    fn test_shm_tensor() {
8562        let _lock = crate::tests::fd_lock_shared();
8563        if !is_shm_available() {
8564            crate::test_support::report_skip(
8565                "test_shm_tensor - SHM allocation unavailable on this platform",
8566            );
8567            return;
8568        }
8569        let shape = vec![2, 3, 4];
8570        let tensor =
8571            ShmTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
8572        assert_eq!(tensor.shape(), &shape);
8573        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
8574        assert_eq!(tensor.name(), "test_tensor");
8575
8576        const DUMMY_VALUE: f32 = 12.34;
8577        {
8578            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8579            tensor_map.fill(42.0);
8580            assert!(tensor_map.iter().all(|&x| x == 42.0));
8581        }
8582
8583        {
8584            let shared = Tensor::<f32>::from_fd(
8585                tensor
8586                    .clone_fd()
8587                    .expect("Failed to duplicate tensor file descriptor"),
8588                &shape,
8589                Some("test_tensor_shared"),
8590            )
8591            .expect("Failed to create tensor from fd");
8592
8593            assert_eq!(shared.memory(), TensorMemory::Shm);
8594            assert_eq!(shared.name(), "test_tensor_shared");
8595            assert_eq!(shared.shape(), &shape);
8596
8597            let mut tensor_map = shared.map().expect("Failed to map shared memory from fd");
8598            tensor_map.fill(DUMMY_VALUE);
8599            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8600        }
8601
8602        {
8603            let tensor_map = tensor.map().expect("Failed to map shared memory");
8604            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8605        }
8606
8607        let mut tensor = ShmTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
8608        assert_eq!(tensor.shape(), &shape);
8609        let new_shape = vec![3, 4, 4];
8610        assert!(
8611            tensor.reshape(&new_shape).is_err(),
8612            "Reshape should fail due to size mismatch"
8613        );
8614        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
8615
8616        let new_shape = vec![2, 3, 4];
8617        tensor.reshape(&new_shape).expect("Reshape should succeed");
8618        assert_eq!(
8619            tensor.shape(),
8620            &new_shape,
8621            "Shape should be updated after successful reshape"
8622        );
8623
8624        {
8625            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8626            tensor_map.fill(1);
8627            assert!(tensor_map.iter().all(|&x| x == 1));
8628        }
8629
8630        {
8631            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8632            tensor_map[2] = 42;
8633            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
8634            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
8635        }
8636    }
8637
8638    #[test]
8639    fn mem_subview_partitions_parent_buffer() {
8640        // One heap [2,4] u8 parent (8 bytes). Two [1,4] sub-views at byte
8641        // offsets 0 and 4 must share the parent allocation (zero-copy) and be
8642        // independently writable: view 0 owns bytes [0,4), view 1 owns [4,8).
8643        // Today this is impossible — heap offset is rejected and there is no
8644        // shared sub-view constructor.
8645        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Mem), None).unwrap();
8646        let view0 = parent.subview(0, &[1, 4]).expect("subview at offset 0");
8647        let view1 = parent.subview(4, &[1, 4]).expect("subview at offset 4");
8648
8649        view1
8650            .map()
8651            .unwrap()
8652            .as_mut_slice()
8653            .copy_from_slice(&[10, 20, 30, 40]);
8654        view0
8655            .map()
8656            .unwrap()
8657            .as_mut_slice()
8658            .copy_from_slice(&[1, 2, 3, 4]);
8659
8660        // Each view sees only its own window.
8661        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
8662        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
8663        // The parent buffer is correctly partitioned (shared, zero-copy).
8664        assert_eq!(
8665            parent.map().unwrap().as_slice(),
8666            &[1, 2, 3, 4, 10, 20, 30, 40]
8667        );
8668    }
8669
8670    #[test]
8671    fn batch_partitions_leading_dim() {
8672        // Raw [4,2,2,3] u8 batched tensor: 4 elements of 12 bytes each. batch(n)
8673        // yields element n at offset n*12, sharing the parent buffer (zero-copy).
8674        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8675        for i in 0..4u8 {
8676            let e = parent.batch(i as usize).expect("batch element");
8677            assert_eq!(e.shape(), &[2, 2, 3]);
8678            // A batch element shares the parent's BufferIdentity.
8679            assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
8680            for b in e.map().unwrap().as_mut_slice() {
8681                *b = i + 1;
8682            }
8683        }
8684        // Each element occupies its own 12-byte band of the parent.
8685        let whole = parent.map().unwrap();
8686        let s = whole.as_slice();
8687        for i in 0..4usize {
8688            assert!(
8689                s[i * 12..(i + 1) * 12].iter().all(|&b| b == (i as u8 + 1)),
8690                "band {i} not partitioned: {:?}",
8691                &s[i * 12..(i + 1) * 12]
8692            );
8693        }
8694    }
8695
8696    #[test]
8697    fn view_origin_snapshots_parent_and_composes() {
8698        // view() on a whole image snapshots the parent dims + the view's origin.
8699        let parent = Tensor::<u8>::image(
8700            100,
8701            80,
8702            PixelFormat::Rgba,
8703            Some(TensorMemory::Mem),
8704            crate::CpuAccess::ReadWrite,
8705        )
8706        .unwrap();
8707        assert_eq!(
8708            parent.view_origin(),
8709            None,
8710            "whole tensor has no view_origin"
8711        );
8712        let v = parent.view(Region::new(10, 20, 30, 40)).unwrap();
8713        assert_eq!(
8714            v.view_origin(),
8715            Some(ViewOrigin {
8716                parent_width: 100,
8717                parent_height: 80,
8718                parent_row_stride: 100 * 4, // tight RGBA pitch
8719                x: 10,
8720                y: 20
8721            })
8722        );
8723        // A view of a view keeps the ROOT parent and accumulates the origin.
8724        let v2 = v.view(Region::new(5, 5, 10, 10)).unwrap();
8725        assert_eq!(
8726            v2.view_origin(),
8727            Some(ViewOrigin {
8728                parent_width: 100,
8729                parent_height: 80,
8730                parent_row_stride: 100 * 4,
8731                x: 15,
8732                y: 25
8733            }),
8734            "nested view composes onto the root parent"
8735        );
8736    }
8737
8738    #[test]
8739    fn view_origin_none_for_raw_batch() {
8740        // A raw (unformatted) batched tensor has no pixel geometry, so batch()
8741        // leaves view_origin None (the per-slot path, not the one-import pivot).
8742        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8743        assert_eq!(parent.batch(2).unwrap().view_origin(), None);
8744    }
8745
8746    #[test]
8747    fn batch_rejects_out_of_bounds_index() {
8748        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8749        match parent.batch(4) {
8750            Err(Error::BatchIndexOutOfBounds { index, batch }) => {
8751                assert_eq!((index, batch), (4, 4));
8752            }
8753            other => panic!("expected BatchIndexOutOfBounds, got {other:?}"),
8754        }
8755    }
8756
8757    #[test]
8758    fn batch_zero_on_unit_n_is_whole() {
8759        // N == 1: batch(0) is the whole per-element block at offset 0 (no plane_offset).
8760        let parent = Tensor::<u8>::new(&[1, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8761        let e = parent.batch(0).unwrap();
8762        assert_eq!(e.shape(), &[2, 2, 3]);
8763        assert_eq!(e.plane_offset(), None);
8764        assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
8765    }
8766
8767    #[test]
8768    fn mem_subview_rejects_unaligned_offset() {
8769        // f32 has align 4; a byte offset of 2 cannot back a valid `*const f32`.
8770        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8771        assert!(parent.subview(2, &[1]).is_err());
8772        // A correctly aligned offset is accepted.
8773        assert!(parent.subview(4, &[1]).is_ok());
8774    }
8775
8776    #[test]
8777    fn mem_subview_rejects_out_of_bounds() {
8778        let parent = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8779        // offset 6 + 4 bytes = 10 exceeds the 8-byte allocation.
8780        assert!(parent.subview(6, &[4]).is_err());
8781    }
8782
8783    /// Regression guard for the `TensorTrait::view` promotion (R2): a `subview`
8784    /// must share the parent's `BufferIdentity` on **every** backend, not mint a
8785    /// fresh one. Identity-keyed caches (the GL EGLImage import) rely on this to
8786    /// treat offset-distinct windows of one buffer as a single import; a fresh
8787    /// identity would silently break that and regress zero-copy import reuse.
8788    ///
8789    /// Runs each backend that can be allocated without a GPU/GL context on the
8790    /// test host: `Mem` (always); `Shm` (when POSIX shm is available); the
8791    /// platform-native zero-copy buffer `Dma` (DMA-BUF on Linux / IOSurface on
8792    /// macOS, when available). `Pbo` shares its identity the same way (see
8793    /// `pbo.rs` `view`) but needs a live GL context, so it is exercised by the
8794    /// image-crate GL tests rather than here.
8795    #[test]
8796    fn subview_shares_buffer_identity_all_backends() {
8797        // Declares this test as an fd-opener; see FD_LOCK.
8798        let _lock = crate::tests::fd_lock_shared();
8799        // u8 has align 1, so every byte offset is valid for the alignment check;
8800        // this isolates the identity-sharing contract from alignment concerns.
8801        // `window` is the view shape; a formatted parent validates it against
8802        // its own format, so an image parent needs an image-shaped window.
8803        let assert_parent_shares = |parent: Tensor<u8>, window: &[usize], label: &str| {
8804            let parent_id = parent.buffer_identity().id();
8805            // Two offset-distinct windows must both carry the parent's identity.
8806            let v0 = parent
8807                .subview(0, window)
8808                .unwrap_or_else(|e| panic!("{label}: subview(0) failed: {e:?}"));
8809            let v1 = parent
8810                .subview(16, window)
8811                .unwrap_or_else(|e| panic!("{label}: subview(16) failed: {e:?}"));
8812            assert_eq!(
8813                v0.buffer_identity().id(),
8814                parent_id,
8815                "{label}: subview(0) minted a fresh BufferIdentity"
8816            );
8817            assert_eq!(
8818                v1.buffer_identity().id(),
8819                parent_id,
8820                "{label}: subview(16) minted a fresh BufferIdentity"
8821            );
8822        };
8823        let assert_shares = |memory: TensorMemory, label: &str| {
8824            let parent = Tensor::<u8>::new(&[64], Some(memory), None)
8825                .unwrap_or_else(|e| panic!("{label}: parent alloc failed: {e:?}"));
8826            assert_parent_shares(parent, &[16], label);
8827        };
8828
8829        assert_shares(TensorMemory::Mem, "Mem");
8830
8831        #[cfg(unix)]
8832        if crate::is_shm_available() {
8833            assert_shares(TensorMemory::Shm, "Shm");
8834        }
8835
8836        // Dma == DMA-BUF on Linux, IOSurface on macOS, AHardwareBuffer on
8837        // Android; same public variant.
8838        #[cfg(target_os = "linux")]
8839        if crate::test_support::dma_or_skip("subview_shares_buffer_identity_all_backends (Dma)") {
8840            assert_shares(TensorMemory::DmaBuf, "Dma");
8841        }
8842        #[cfg(not(any(target_os = "linux", target_os = "windows")))]
8843        if crate::is_gpu_buffer_available() {
8844            assert_shares(TensorMemory::DmaBuf, "Dma");
8845        }
8846        // Windows' Dma is an image-formatted D3D11 texture, which
8847        // `Tensor::new` deliberately refuses -- allocate it the only way it
8848        // can be allocated and hold it to the same contract.
8849        #[cfg(target_os = "windows")]
8850        if crate::is_gpu_buffer_available() {
8851            let parent = Tensor::<u8>::image(
8852                64,
8853                1,
8854                PixelFormat::Grey,
8855                Some(TensorMemory::DmaBuf),
8856                CpuAccess::ReadWrite,
8857            )
8858            .expect("D3D11 texture alloc failed");
8859            assert_parent_shares(parent, &[1, 16, 1], "Dma");
8860        }
8861    }
8862
8863    /// A sub-view records a plane offset, and on Windows the D3D11 storage
8864    /// already applied that offset itself when the view was made -- so the
8865    /// map's plane-offset gate must accept it, exactly as it does for the
8866    /// other zero-copy backings. It used to refuse, with a message that named
8867    /// every backend but this one.
8868    #[cfg(target_os = "windows")]
8869    #[test]
8870    fn subview_of_a_texture_tensor_maps_at_its_offset() {
8871        if !crate::is_gpu_buffer_available() {
8872            crate::test_support::report_skip(
8873                "subview_of_a_texture_tensor_maps_at_its_offset - no D3D11 device on this host",
8874            );
8875            return;
8876        }
8877        let t = Tensor::<u8>::image(
8878            32,
8879            8,
8880            PixelFormat::Rgba,
8881            Some(TensorMemory::DmaBuf),
8882            CpuAccess::ReadWrite,
8883        )
8884        .unwrap();
8885        // For a texture tensor this is the backing pitch either way: the
8886        // allocation records the staging pitch when it exceeds the tight row,
8887        // and the two are equal when it does not.
8888        let stride = t.effective_row_stride().unwrap();
8889        let v = t.subview(stride * 2, &[2, 32, 4]).unwrap();
8890        assert!(
8891            v.plane_offset().is_some_and(|o| o > 0),
8892            "a sub-view at a row offset must carry one"
8893        );
8894        let m = v
8895            .map_read()
8896            .expect("a sub-view of a texture tensor must map");
8897        assert_eq!(m.as_slice().len(), 2 * stride, "two rows of the window");
8898    }
8899
8900    #[test]
8901    fn mem_subview_four_views_no_aliasing() {
8902        // One [4,3] f32 parent; four [1,3] views at 12-byte strides, each
8903        // written independently. Exercises a multi-byte element type (offsets
8904        // must stay element-aligned) and N-way zero-copy sharing.
8905        let parent = Tensor::<f32>::new(&[4, 3], Some(TensorMemory::Mem), None).unwrap();
8906        let frame = 3 * std::mem::size_of::<f32>();
8907        for i in 0..4 {
8908            let v = parent.subview(i * frame, &[1, 3]).unwrap();
8909            let val = i as f32 + 1.0;
8910            v.map()
8911                .unwrap()
8912                .as_mut_slice()
8913                .copy_from_slice(&[val, val, val]);
8914        }
8915        assert_eq!(
8916            parent.map().unwrap().as_slice(),
8917            &[1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0]
8918        );
8919    }
8920
8921    #[test]
8922    fn mem_subview_inherits_format_and_row_stride() {
8923        // A sub-view is a ready-to-use sub-image: it inherits the parent's
8924        // pixel format and (crucially) its padded row stride, so a strided
8925        // parent yields strided windows. Set a stride wider than the tight row
8926        // to exercise the row_stride inheritance path specifically.
8927        let mut parent = Tensor::<u8>::image(
8928            100,
8929            100,
8930            PixelFormat::Rgba,
8931            Some(TensorMemory::Mem),
8932            crate::CpuAccess::ReadWrite,
8933        )
8934        .unwrap();
8935        parent.set_row_stride_unchecked(512); // padded stride (> 100*4)
8936        let view = parent.subview(4096, &[10, 10, 4]).unwrap();
8937        assert_eq!(view.format(), Some(PixelFormat::Rgba), "format inherited");
8938        assert_eq!(view.row_stride(), Some(512), "row_stride inherited");
8939    }
8940
8941    #[test]
8942    fn mem_strided_subview_maps_offset_and_byte_size() {
8943        // Integration of the sub-region offset (PR #89) and the strided-map
8944        // `byte_size_override` (PR #90): a strided sub-view exposes its full
8945        // padded window (`row_stride × rows`) starting at the view's byte
8946        // offset, mapped zero-copy into the parent.
8947        let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::Mem), None).unwrap();
8948        let mut view = parent.subview(128, &[8, 16]).unwrap(); // 8 rows × 16 @ off 128
8949        assert_eq!(view.plane_offset(), Some(128));
8950        view.set_row_stride_unchecked(32); // padded stride (> 16)
8951
8952        {
8953            let mut m = view.map().unwrap();
8954            let s = m.as_mut_slice();
8955            // Strided map exposes the padded window: stride(32) × rows(8) = 256.
8956            assert_eq!(
8957                s.len(),
8958                256,
8959                "strided map exposes the full padded byte window"
8960            );
8961            s[0] = 0xAA; // row 0, col 0
8962            s[32] = 0xBB; // row 1, col 0 (one stride in)
8963        }
8964
8965        // Zero-copy: the writes land in the parent at the view's offset.
8966        let p = parent.map().unwrap();
8967        let pb = p.as_slice();
8968        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
8969        assert_eq!(
8970            pb[128 + 32],
8971            0xBB,
8972            "row 1 writes at parent offset 128 + stride"
8973        );
8974    }
8975
8976    #[test]
8977    #[cfg(unix)]
8978    fn shm_subview_partitions_parent_buffer() {
8979        // Declares this test as an fd-opener; see FD_LOCK.
8980        let _lock = crate::tests::fd_lock_shared();
8981        // Mirrors `mem_subview_partitions_parent_buffer` for Shm: one [2,4] u8
8982        // parent shared segment (8 bytes); two [1,4] sub-views at byte offsets 0
8983        // and 4 must share the segment (zero-copy, via cloned fd) and be
8984        // independently writable — view 0 owns [0,4), view 1 owns [4,8).
8985        if !crate::is_shm_available() {
8986            crate::test_support::report_skip("shm not available");
8987            return;
8988        }
8989        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Shm), None).unwrap();
8990        let view0 = parent.subview(0, &[1, 4]).expect("shm subview at offset 0");
8991        let view1 = parent.subview(4, &[1, 4]).expect("shm subview at offset 4");
8992
8993        view1
8994            .map()
8995            .unwrap()
8996            .as_mut_slice()
8997            .copy_from_slice(&[10, 20, 30, 40]);
8998        view0
8999            .map()
9000            .unwrap()
9001            .as_mut_slice()
9002            .copy_from_slice(&[1, 2, 3, 4]);
9003
9004        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
9005        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
9006        // The parent sees the full partitioned segment (shared, zero-copy).
9007        assert_eq!(
9008            parent.map().unwrap().as_slice(),
9009            &[1, 2, 3, 4, 10, 20, 30, 40]
9010        );
9011        // A sub-view of a sub-view composes the offset.
9012        let nested = view1.subview(2, &[1, 2]).expect("nested shm subview");
9013        assert_eq!(nested.map().unwrap().as_slice(), &[30, 40]);
9014    }
9015
9016    #[test]
9017    #[cfg(unix)]
9018    fn shm_subview_rejects_unaligned_and_oob() {
9019        // Declares this test as an fd-opener; see FD_LOCK.
9020        let _lock = crate::tests::fd_lock_shared();
9021        if !crate::is_shm_available() {
9022            crate::test_support::report_skip("shm not available");
9023            return;
9024        }
9025        // f32 align 4: a 2-byte offset cannot back a valid `*const f32`.
9026        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
9027        assert!(parent.subview(2, &[1]).is_err());
9028        assert!(parent.subview(4, &[1]).is_ok());
9029        // Out of bounds: offset 6 + 4 bytes = 10 > 8-byte (u8) segment.
9030        let p2 = Tensor::<u8>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
9031        assert!(p2.subview(6, &[4]).is_err());
9032    }
9033
9034    #[test]
9035    #[cfg(target_os = "linux")]
9036    fn dma_subview_matches_mem_subview() {
9037        // Serialize against the fd-leak tests: this test opens DMA fds (alloc +
9038        // clone_fd), which would otherwise perturb their fd counts.
9039        let _lock = crate::tests::fd_lock_shared();
9040        // Identical sub-view semantics across Dma (shared fd) and Mem (shared
9041        // Arc): same offsets → same logical windows → same partition.
9042        if !crate::test_support::dma_or_skip("dma_subview_matches_mem_subview") {
9043            return;
9044        }
9045        let dma = Tensor::<u8>::new(&[8], Some(TensorMemory::DmaBuf), None).unwrap();
9046        let mem = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
9047        for parent in [&dma, &mem] {
9048            let v0 = parent.subview(0, &[4]).unwrap();
9049            let v1 = parent.subview(4, &[4]).unwrap();
9050            v0.map()
9051                .unwrap()
9052                .as_mut_slice()
9053                .copy_from_slice(&[1, 2, 3, 4]);
9054            v1.map()
9055                .unwrap()
9056                .as_mut_slice()
9057                .copy_from_slice(&[5, 6, 7, 8]);
9058            assert_eq!(parent.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
9059        }
9060    }
9061
9062    #[test]
9063    #[cfg(target_os = "linux")]
9064    fn dma_strided_subview_maps_padded_window() {
9065        // The strided-map path differs by backing: DMA maps through
9066        // `mmap_offset` + the `byte_size_override`, not the Mem `Arc` slice. A
9067        // padded sub-view of a DMA buffer must still expose its full
9068        // `row_stride × rows` window zero-copy at the view's offset (the GPU
9069        // batched-render-to-DMA case). Mirrors
9070        // `mem_strided_subview_maps_offset_and_byte_size` on a Dma parent.
9071        let _lock = crate::tests::fd_lock_shared();
9072        if !crate::test_support::dma_or_skip("dma_strided_subview_maps_padded_window") {
9073            return;
9074        }
9075        let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::DmaBuf), None).unwrap();
9076        let mut view = parent.subview(128, &[8, 16]).unwrap();
9077        assert_eq!(view.plane_offset(), Some(128));
9078        view.set_row_stride_unchecked(32); // padded stride (> 16)
9079
9080        {
9081            let mut m = view.map().unwrap();
9082            let s = m.as_mut_slice();
9083            assert_eq!(s.len(), 256, "strided DMA map exposes stride(32) × rows(8)");
9084            s[0] = 0xAA; // row 0, col 0
9085            s[32] = 0xBB; // row 1, col 0 (one stride in)
9086        }
9087
9088        let p = parent.map().unwrap();
9089        let pb = p.as_slice();
9090        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
9091        assert_eq!(
9092            pb[128 + 32],
9093            0xBB,
9094            "row 1 writes at parent offset 128 + stride"
9095        );
9096    }
9097
9098    #[test]
9099    #[cfg(target_os = "linux")]
9100    fn view_single_row_snapshots_parent_stride() {
9101        // A single-row `view()` keeps a TIGHT `row_stride` for map-span safety,
9102        // but its `view_origin` snapshots the PARENT row stride — the GL backend
9103        // keys its EGLImage import/pitch on that snapshot (not the view's tight
9104        // stride), so single-row and multi-row sibling views collapse onto the
9105        // same parent import.
9106        let _lock = crate::tests::fd_lock_shared();
9107        // 8x4 RGBA with a padded 64-byte row stride (tight row = 8*4 = 32).
9108        if !crate::test_support::dma_or_skip("view_single_row_snapshots_parent_stride") {
9109            return;
9110        }
9111        let parent = Tensor::<u8>::image_with_stride(
9112            8,
9113            4,
9114            PixelFormat::Rgba,
9115            64,
9116            Some(TensorMemory::DmaBuf),
9117            crate::CpuAccess::ReadWrite,
9118        )
9119        .unwrap();
9120        assert_eq!(parent.effective_row_stride(), Some(64));
9121        // Bottom row (y=3) at x>0 — the case the tight single-row stride guards.
9122        let row = parent.view(Region::new(2, 3, 4, 1)).unwrap();
9123        // The view's own stride is tight (4*4 = 16) so its strided map stays in
9124        // bounds; the GL-facing parent pitch (64) lives in `view_origin`.
9125        assert_eq!(row.effective_row_stride(), Some(16));
9126        let vo = row.view_origin().expect("a view carries a view_origin");
9127        assert_eq!(
9128            vo.parent_row_stride, 64,
9129            "GL keys/pitches a view on the parent stride, not its tight one"
9130        );
9131        // The tight stride keeps map() in-bounds for the bottom / x>0 single row.
9132        assert_eq!(row.map().unwrap().as_slice().len(), 16);
9133    }
9134
9135    #[test]
9136    fn test_mem_tensor() {
9137        let shape = vec![2, 3, 4];
9138        let tensor =
9139            MemTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
9140        assert_eq!(tensor.shape(), &shape);
9141        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
9142        assert_eq!(tensor.name(), "test_tensor");
9143
9144        {
9145            let mut tensor_map = tensor.map().expect("Failed to map memory");
9146            tensor_map.fill(42.0);
9147            assert!(tensor_map.iter().all(|&x| x == 42.0));
9148        }
9149
9150        let mut tensor = MemTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
9151        assert_eq!(tensor.shape(), &shape);
9152        let new_shape = vec![3, 4, 4];
9153        assert!(
9154            tensor.reshape(&new_shape).is_err(),
9155            "Reshape should fail due to size mismatch"
9156        );
9157        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
9158
9159        let new_shape = vec![2, 3, 4];
9160        tensor.reshape(&new_shape).expect("Reshape should succeed");
9161        assert_eq!(
9162            tensor.shape(),
9163            &new_shape,
9164            "Shape should be updated after successful reshape"
9165        );
9166
9167        {
9168            let mut tensor_map = tensor.map().expect("Failed to map memory");
9169            tensor_map.fill(1);
9170            assert!(tensor_map.iter().all(|&x| x == 1));
9171        }
9172
9173        {
9174            let mut tensor_map = tensor.map().expect("Failed to map memory");
9175            tensor_map[2] = 42;
9176            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
9177            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
9178        }
9179    }
9180
9181    #[test]
9182    #[cfg(target_os = "linux")]
9183    fn test_dma_no_fd_leaks() {
9184        let _lock = fd_lock_exclusive();
9185        if !crate::test_support::dma_or_skip(function!()) {
9186            return;
9187        }
9188
9189        let baseline = open_fd_count(DMA_FD_TARGETS);
9190
9191        // Self-check — see `test_shm_no_fd_leaks`.
9192        {
9193            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
9194                .expect("Failed to create tensor");
9195            assert!(
9196                open_fd_count(DMA_FD_TARGETS) > baseline,
9197                "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
9198                 otherwise this test cannot detect a leak"
9199            );
9200            drop(probe);
9201        }
9202
9203        for _ in 0..100 {
9204            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
9205                .expect("Failed to create tensor");
9206            let mut map = tensor.map().unwrap();
9207            map.as_mut_slice().fill(233);
9208        }
9209
9210        let after = open_fd_count(DMA_FD_TARGETS);
9211        assert_eq!(
9212            baseline, after,
9213            "DMA-BUF file descriptor leak detected: {baseline} -> {after}"
9214        );
9215    }
9216
9217    #[test]
9218    #[cfg(target_os = "linux")]
9219    fn test_dma_from_fd_no_fd_leaks() {
9220        let _lock = fd_lock_exclusive();
9221        if !crate::test_support::dma_or_skip(function!()) {
9222            return;
9223        }
9224
9225        let baseline = open_fd_count(DMA_FD_TARGETS);
9226        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None).unwrap();
9227
9228        // Self-check — see `test_shm_no_fd_leaks`.
9229        let held = open_fd_count(DMA_FD_TARGETS);
9230        assert!(
9231            held > baseline,
9232            "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
9233             otherwise this test cannot detect a leak"
9234        );
9235
9236        for _ in 0..100 {
9237            let tensor =
9238                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9239            assert_eq!(
9240                tensor.memory(),
9241                TensorMemory::DmaBuf,
9242                "DMA-BUF fd must import as Dma, not be silently downgraded"
9243            );
9244            let mut map = tensor.map().unwrap();
9245            map.as_mut_slice().fill(233);
9246        }
9247
9248        let after_loop = open_fd_count(DMA_FD_TARGETS);
9249        assert_eq!(
9250            held, after_loop,
9251            "DMA-BUF file descriptor leak detected: {held} -> {after_loop}"
9252        );
9253
9254        // And the source tensor's own fd goes back on drop.
9255        drop(orig);
9256        let after_drop = open_fd_count(DMA_FD_TARGETS);
9257        assert_eq!(
9258            baseline, after_drop,
9259            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
9260        );
9261    }
9262
9263    /// A filesystem magic must report its true 32-bit value regardless of
9264    /// how wide, and how signed, `fstatfs`'s `f_type` is on the target.
9265    ///
9266    /// `fs_type_t` is `__fsword_t` on Linux/gnu — `i64` on 64-bit but `i32`
9267    /// on 32-bit (armv7, i686, aarch64-ilp32) — `c_int` on uclibc, and
9268    /// unsigned on musl and s390x. Widening a *signed* 32-bit `f_type`
9269    /// sign-extends every magic with bit 31 set, so a naive widening cast
9270    /// reports e.g. `0xffffffff958458f6` for hugetlbfs. The docs tell
9271    /// callers to look the reported value up in `include/uapi/linux/magic.h`,
9272    /// so a sign-extended value is actively misleading.
9273    ///
9274    /// This cannot be reproduced on a 64-bit host — the defect only exists
9275    /// where `f_type` is a signed 32-bit type — so the test drives the
9276    /// conversion directly with the value such a target would produce.
9277    #[test]
9278    #[cfg(target_os = "linux")]
9279    fn test_fs_magic_normalizes_sign_extended_values() {
9280        // Declares this test as an fd-opener; see FD_LOCK.
9281        let _lock = crate::tests::fd_lock_shared();
9282        // Magics whose bit 31 is set. HUGETLBFS is the one that matters
9283        // most in practice: a MFD_HUGETLB memfd is the likeliest fd to land
9284        // in the UnknownBufferType arm.
9285        const HUGETLBFS_MAGIC: u32 = 0x9584_58f6;
9286        const F2FS_MAGIC: u32 = 0xf2f5_2010;
9287        const BTRFS_MAGIC: u32 = 0x9123_683e;
9288
9289        for magic in [HUGETLBFS_MAGIC, F2FS_MAGIC, BTRFS_MAGIC] {
9290            // 64-bit gnu: f_type is i64 and already holds the true value.
9291            assert_eq!(fs_magic(i64::from(magic)), magic);
9292
9293            // 32-bit gnu / uclibc: f_type is i32, so the value arrives
9294            // sign-extended once widened. It must still report its true
9295            // 32 bits.
9296            let sign_extended = i64::from(magic as i32);
9297            assert!(sign_extended < 0, "{magic:#x} should have bit 31 set");
9298            assert_eq!(
9299                fs_magic(sign_extended),
9300                magic,
9301                "{magic:#x} must survive a signed 32-bit f_type"
9302            );
9303        }
9304
9305        // The two magics we actually classify on are below 2^31, so they are
9306        // unaffected by signedness either way — this is why the DMA-vs-SHM
9307        // decision is correct on 32-bit even without this normalization.
9308        for magic in [DMA_BUF_MAGIC, TMPFS_MAGIC] {
9309            assert!(magic < 0x8000_0000);
9310            assert_eq!(fs_magic(i64::from(magic as i32)), magic);
9311        }
9312    }
9313
9314    /// A DMA-BUF fd must import as [`TensorMemory::DmaBuf`].
9315    ///
9316    /// Regression test for the `st_dev` minor-number classifier, which
9317    /// hardcoded `9 | 10` as "this is DMA". Those minors come from
9318    /// `get_anon_bdev()` and are assigned first-come-first-served at boot,
9319    /// so they vary by kernel build and boot order — a real DMA-BUF is
9320    /// minor 12 on x86 desktop and minor 8 on the ADIS Verdin. Both fell
9321    /// into the `_` arm and imported as SHM, which "works" (a DMA-BUF is
9322    /// mmap-able) but silently forfeits zero-copy.
9323    #[test]
9324    #[cfg(target_os = "linux")]
9325    fn test_from_fd_dma_imports_as_dma() {
9326        let _lock = crate::tests::fd_lock_shared();
9327        if !crate::test_support::dma_or_skip(function!()) {
9328            return;
9329        }
9330
9331        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::DmaBuf), None).unwrap();
9332        assert_eq!(orig.memory(), TensorMemory::DmaBuf);
9333
9334        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9335
9336        assert_eq!(
9337            imported.memory(),
9338            TensorMemory::DmaBuf,
9339            "a DMA-BUF fd must import as Dma"
9340        );
9341    }
9342
9343    /// A tmpfs/SHM fd must import as [`TensorMemory::Shm`].
9344    ///
9345    /// The companion to `test_from_fd_dma_imports_as_dma`: confirms the
9346    /// magic-based classifier identifies SHM positively rather than by
9347    /// falling through.
9348    #[test]
9349    #[cfg(target_os = "linux")]
9350    fn test_from_fd_shm_imports_as_shm() {
9351        let _lock = crate::tests::fd_lock_shared();
9352        if !is_shm_available() {
9353            crate::test_support::report_skip(&format!(
9354                "{} - SHM memory not available",
9355                function!()
9356            ));
9357            return;
9358        }
9359
9360        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::Shm), None).unwrap();
9361        assert_eq!(orig.memory(), TensorMemory::Shm);
9362
9363        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9364
9365        assert_eq!(
9366            imported.memory(),
9367            TensorMemory::Shm,
9368            "a tmpfs fd must import as Shm"
9369        );
9370    }
9371
9372    /// An fd that is neither a DMA-BUF nor tmpfs must be rejected.
9373    ///
9374    /// A pipe is the convenient probe: it lives on `pipefs`, another
9375    /// `get_anon_bdev()` pseudo-filesystem, so it shares major 0 with the
9376    /// buffer types we do support and is only distinguishable by magic.
9377    /// Importing one as SHM is meaningless — `mmap` on a pipe fails — so
9378    /// the classifier must say "unknown" rather than guess.
9379    #[test]
9380    #[cfg(target_os = "linux")]
9381    fn test_from_fd_rejects_unknown_filesystem() {
9382        let _lock = crate::tests::fd_lock_shared();
9383
9384        let (read_end, _write_end) = nix::unistd::pipe().unwrap();
9385
9386        let result = Tensor::<u8>::from_fd(read_end, &[64], None);
9387
9388        match result {
9389            Err(Error::UnknownBufferType(magic)) => {
9390                // PIPEFS_MAGIC, from include/uapi/linux/magic.h
9391                assert_eq!(magic, 0x5049_5045, "expected PIPEFS_MAGIC");
9392            }
9393            other => panic!("expected UnknownBufferType for a pipe fd, got {other:?}"),
9394        }
9395    }
9396
9397    #[test]
9398    #[cfg(target_os = "linux")]
9399    fn test_shm_no_fd_leaks() {
9400        let _lock = fd_lock_exclusive();
9401        if !is_shm_available() {
9402            crate::test_support::report_skip(&format!(
9403                "{} - SHM memory allocation not available (permission denied or no SHM support)",
9404                function!()
9405            ));
9406            return;
9407        }
9408
9409        let baseline = open_fd_count(SHM_FD_TARGETS);
9410
9411        // Self-check: the counter must actually see a live shm tensor. If
9412        // it does not — a changed backing object, a different `/proc`
9413        // rendering — the leak assertion below would hold no matter how
9414        // much leaked, so fail loudly here instead.
9415        {
9416            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9417                .expect("Failed to create tensor");
9418            assert!(
9419                open_fd_count(SHM_FD_TARGETS) > baseline,
9420                "a live shm tensor must be visible to the shm fd counter, \
9421                 otherwise this test cannot detect a leak"
9422            );
9423            drop(probe);
9424        }
9425
9426        for _ in 0..100 {
9427            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9428                .expect("Failed to create tensor");
9429            let mut map = tensor.map().unwrap();
9430            map.as_mut_slice().fill(233);
9431        }
9432
9433        let after = open_fd_count(SHM_FD_TARGETS);
9434        assert_eq!(
9435            baseline, after,
9436            "shm file descriptor leak detected: {baseline} -> {after}"
9437        );
9438    }
9439
9440    #[test]
9441    #[cfg(target_os = "linux")]
9442    fn test_shm_from_fd_no_fd_leaks() {
9443        let _lock = fd_lock_exclusive();
9444        if !is_shm_available() {
9445            crate::test_support::report_skip(&format!(
9446                "{} - SHM memory allocation not available (permission denied or no SHM support)",
9447                function!()
9448            ));
9449            return;
9450        }
9451
9452        let baseline = open_fd_count(SHM_FD_TARGETS);
9453        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None).unwrap();
9454
9455        // Self-check — see `test_shm_no_fd_leaks`.
9456        let held = open_fd_count(SHM_FD_TARGETS);
9457        assert!(
9458            held > baseline,
9459            "a live shm tensor must be visible to the shm fd counter, \
9460             otherwise this test cannot detect a leak"
9461        );
9462
9463        for _ in 0..100 {
9464            let tensor =
9465                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9466            let mut map = tensor.map().unwrap();
9467            map.as_mut_slice().fill(233);
9468        }
9469
9470        let after_loop = open_fd_count(SHM_FD_TARGETS);
9471        assert_eq!(
9472            held, after_loop,
9473            "shm file descriptor leak detected: {held} -> {after_loop}"
9474        );
9475
9476        // And the source tensor's own fd goes back on drop.
9477        drop(orig);
9478        let after_drop = open_fd_count(SHM_FD_TARGETS);
9479        assert_eq!(
9480            baseline, after_drop,
9481            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
9482        );
9483    }
9484
9485    #[cfg(feature = "ndarray")]
9486    #[test]
9487    fn test_ndarray() {
9488        let _lock = crate::tests::fd_lock_shared();
9489        let shape = vec![2, 3, 4];
9490        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
9491
9492        let mut tensor_map = tensor.map().expect("Failed to map tensor memory");
9493        tensor_map.fill(1.0);
9494
9495        let view = tensor_map.view().expect("Failed to get ndarray view");
9496        assert_eq!(view.shape(), &[2, 3, 4]);
9497        assert!(view.iter().all(|&x| x == 1.0));
9498
9499        let mut view_mut = tensor_map
9500            .view_mut()
9501            .expect("Failed to get mutable ndarray view");
9502        view_mut[[0, 0, 0]] = 42.0;
9503        assert_eq!(view_mut[[0, 0, 0]], 42.0);
9504        assert_eq!(tensor_map[0], 42.0, "Value at index 0 should be 42");
9505    }
9506
9507    #[test]
9508    fn test_buffer_identity_derived_equality() {
9509        // Two identities derived from the same kind and key must be equal —
9510        // that is the whole point of `derived`: two independently-linked
9511        // copies of this crate, each importing the same underlying buffer,
9512        // have to agree on its id without talking to each other.
9513        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9514        let id2 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9515        assert_eq!(
9516            id1.id(),
9517            id2.id(),
9518            "Identities derived from the same kind and key should match"
9519        );
9520
9521        // A different key still yields a different id.
9522        let id3 = BufferIdentity::derived(IdentityKind::DmaBuf, 4243);
9523        assert_ne!(
9524            id1.id(),
9525            id3.id(),
9526            "Identities derived from different keys should differ"
9527        );
9528    }
9529
9530    #[test]
9531    fn test_buffer_identity_clone_shares_guard() {
9532        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9533        let weak = id1.weak();
9534        assert!(
9535            weak.upgrade().is_some(),
9536            "Weak should be alive while original exists"
9537        );
9538
9539        let id2 = id1.clone();
9540        assert_eq!(id1.id(), id2.id(), "Cloned identity should have same id");
9541
9542        drop(id1);
9543        assert!(
9544            weak.upgrade().is_some(),
9545            "Weak should still be alive (clone holds Arc)"
9546        );
9547
9548        drop(id2);
9549        assert!(
9550            weak.upgrade().is_none(),
9551            "Weak should be dead after all clones dropped"
9552        );
9553    }
9554
9555    #[test]
9556    fn test_tensor_buffer_identity() {
9557        let t1 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t1")).unwrap();
9558        let t2 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t2")).unwrap();
9559        assert_ne!(
9560            t1.buffer_identity().id(),
9561            t2.buffer_identity().id(),
9562            "Different tensors should have different buffer ids"
9563        );
9564    }
9565
9566    // ------------------------------------------------------------------------
9567    // Quantization — constructor validation + accessor correctness.
9568    // ------------------------------------------------------------------------
9569
9570    #[test]
9571    fn test_quantization_per_tensor_constructors() {
9572        let q = Quantization::per_tensor(0.1, -5);
9573        assert!(q.is_per_tensor());
9574        assert!(!q.is_per_channel());
9575        assert!(!q.is_symmetric());
9576        assert_eq!(q.scale(), &[0.1]);
9577        assert_eq!(q.zero_point(), Some(&[-5][..]));
9578
9579        let qs = Quantization::per_tensor_symmetric(0.05);
9580        assert!(qs.is_per_tensor());
9581        assert!(qs.is_symmetric());
9582        assert_eq!(qs.zero_point(), None);
9583    }
9584
9585    #[test]
9586    fn test_quantization_per_channel_constructors() {
9587        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3], vec![0, -1, 1], 2).unwrap();
9588        assert!(q.is_per_channel());
9589        assert!(!q.is_symmetric());
9590        assert_eq!(q.axis(), Some(2));
9591        assert_eq!(q.scale().len(), 3);
9592
9593        let qs = Quantization::per_channel_symmetric(vec![0.054, 0.089, 0.195], 0).unwrap();
9594        assert!(qs.is_per_channel());
9595        assert!(qs.is_symmetric());
9596        assert_eq!(qs.axis(), Some(0));
9597    }
9598
9599    #[test]
9600    fn test_quantization_per_channel_length_mismatch_rejected() {
9601        // len(scales) != len(zero_points) → rejected at construction.
9602        let err = Quantization::per_channel(vec![0.1, 0.2], vec![0, 0, 0], 0).unwrap_err();
9603        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9604    }
9605
9606    #[test]
9607    fn test_quantization_per_channel_empty_rejected() {
9608        let err = Quantization::per_channel_symmetric(vec![], 0).unwrap_err();
9609        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9610    }
9611
9612    /// Constructors guard scale/zero_point length invariants, but
9613    /// `Quantization` is `Deserialize`, so malformed JSON (e.g. an
9614    /// empty `scale` array, or `zero_point` length that disagrees with
9615    /// `scale`) bypasses the constructor checks. `set_quantization`
9616    /// must reject these via `validate()` so they don't poison
9617    /// downstream `mode()` selection or per-channel kernel indexing.
9618    #[test]
9619    fn test_quantization_validate_rejects_malformed_deserialize() {
9620        let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
9621
9622        // Empty scale array: must be rejected.
9623        let q: Quantization = serde_json::from_str(r#"{"scale": []}"#).unwrap();
9624        assert!(matches!(
9625            t.set_quantization(q).unwrap_err(),
9626            Error::QuantizationInvalid { .. }
9627        ));
9628
9629        // Per-tensor with multi-element zero_point: must be rejected.
9630        let q: Quantization =
9631            serde_json::from_str(r#"{"scale": 0.1, "zero_point": [0, 0, 0]}"#).unwrap();
9632        assert!(matches!(
9633            t.set_quantization(q).unwrap_err(),
9634            Error::QuantizationInvalid { .. }
9635        ));
9636
9637        // Per-channel zero_point length != scale length: must be rejected.
9638        let q: Quantization = serde_json::from_str(
9639            r#"{"scale": [0.1, 0.2, 0.3, 0.4], "zero_point": [0, 0], "axis": 2}"#,
9640        )
9641        .unwrap();
9642        assert!(matches!(
9643            t.set_quantization(q).unwrap_err(),
9644            Error::QuantizationInvalid { .. }
9645        ));
9646    }
9647
9648    #[test]
9649    fn test_quantization_mode_dispatch() {
9650        let pt = Quantization::per_tensor(0.1, -5);
9651        assert!(matches!(
9652            pt.mode(),
9653            QuantMode::PerTensor { scale, zero_point } if scale == 0.1 && zero_point == -5
9654        ));
9655
9656        let pts = Quantization::per_tensor_symmetric(0.05);
9657        assert!(matches!(
9658            pts.mode(),
9659            QuantMode::PerTensorSymmetric { scale } if scale == 0.05
9660        ));
9661
9662        let pc = Quantization::per_channel(vec![0.1, 0.2], vec![0, -1], 2).unwrap();
9663        assert!(matches!(pc.mode(), QuantMode::PerChannel { axis: 2, .. }));
9664
9665        let pcs = Quantization::per_channel_symmetric(vec![0.1, 0.2], 0).unwrap();
9666        assert!(matches!(
9667            pcs.mode(),
9668            QuantMode::PerChannelSymmetric { axis: 0, .. }
9669        ));
9670    }
9671
9672    #[test]
9673    fn test_tensor_quantization_roundtrip_integer() {
9674        let mut t = Tensor::<i8>::new(&[2, 3, 4], Some(TensorMemory::Mem), None).unwrap();
9675        assert!(t.quantization().is_none());
9676        t.set_quantization(Quantization::per_tensor(0.1, -5))
9677            .unwrap();
9678        let q = t.quantization().unwrap();
9679        assert_eq!(q.scale(), &[0.1]);
9680        t.clear_quantization();
9681        assert!(t.quantization().is_none());
9682    }
9683
9684    #[test]
9685    fn test_tensor_with_quantization_builder() {
9686        let t = Tensor::<i8>::new(&[4, 4], Some(TensorMemory::Mem), None)
9687            .unwrap()
9688            .with_quantization(Quantization::per_tensor_symmetric(0.05))
9689            .unwrap();
9690        assert!(t.quantization().is_some());
9691    }
9692
9693    #[test]
9694    fn test_tensor_dyn_quantization_float_arm_returns_none() {
9695        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9696        let td = TensorDyn::F32(t);
9697        assert!(td.quantization().is_none());
9698    }
9699
9700    #[test]
9701    fn test_tensor_dyn_set_quantization_float_arm_errors() {
9702        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9703        let mut td = TensorDyn::F32(t);
9704        let err = td
9705            .set_quantization(Quantization::per_tensor(0.1, 0))
9706            .unwrap_err();
9707        // float path returns a QuantizationInvalid error.
9708        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9709    }
9710
9711    /// Compile-time type gate — calling `Tensor::<f32>::quantization()` must
9712    /// fail to compile (the `IntegerType` trait bound is not satisfied by
9713    /// `f32`). This doctest anchors the invariant.
9714    ///
9715    /// ```compile_fail
9716    /// use edgefirst_tensor::{Tensor, TensorMemory};
9717    /// let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9718    /// let _ = t.quantization(); // compile error: f32 not IntegerType
9719    /// ```
9720    fn _compile_fail_doctest_anchor() {}
9721
9722    // Any test that cares about the fd count must grab it exclusively.
9723    // Any tests which modifies the fd count by opening or closing fds must grab it
9724    // shared.
9725    pub static FD_LOCK: RwLock<()> = RwLock::new(());
9726
9727    /// Take [`FD_LOCK`] shared, tolerating poisoning.
9728    ///
9729    /// A test that panics while holding the lock poisons it, and every later
9730    /// `.unwrap()` on it panics too — one real failure turns into a handful
9731    /// of unrelated ones that bury it. The guarded data is `()`, so a panic
9732    /// cannot have left an invariant broken and there is nothing to protect
9733    /// against by refusing the lock.
9734    pub fn fd_lock_shared() -> std::sync::RwLockReadGuard<'static, ()> {
9735        FD_LOCK.read().unwrap_or_else(PoisonError::into_inner)
9736    }
9737
9738    /// Take [`FD_LOCK`] exclusively. Poison-tolerant, see [`fd_lock_shared`].
9739    ///
9740    /// Linux-only, like all four of its callers: the fd-leak tests read fd
9741    /// state out of `/proc`, which no other platform provides. Ungated, it
9742    /// is dead code on macOS and the lint denies that.
9743    /// [`fd_lock_shared`] stays available everywhere — macOS tests declare
9744    /// fd activity too.
9745    #[cfg(target_os = "linux")]
9746    pub fn fd_lock_exclusive() -> std::sync::RwLockWriteGuard<'static, ()> {
9747        FD_LOCK.write().unwrap_or_else(PoisonError::into_inner)
9748    }
9749
9750    /// `/proc` fd-target fragments identifying a POSIX shared memory
9751    /// segment. `shm_open` then `shm_unlink` leaves the target rendered as
9752    /// `/dev/shm/<name> (deleted)`.
9753    #[cfg(target_os = "linux")]
9754    const SHM_FD_TARGETS: &[&str] = &["/dev/shm/"];
9755
9756    /// `/proc` fd-target fragments identifying the fds a DMA tensor owns.
9757    ///
9758    /// Both the exported buffer and the heap it came from are listed, so a
9759    /// leak of either is still caught — narrowing this to the buffer alone
9760    /// would silently stop covering leaked heap handles.
9761    #[cfg(target_os = "linux")]
9762    const DMA_FD_TARGETS: &[&str] = &["dmabuf", "dma_heap"];
9763
9764    /// How many of this process's open fds point at an object whose `/proc`
9765    /// target contains `needle`.
9766    ///
9767    /// The fd-leak tests deliberately do NOT compare total fd counts. Under
9768    /// `cargo test` every test in this crate runs as a thread in one
9769    /// process, and roughly twenty of them create shm or DMA tensors —
9770    /// while anything else may open a file, a socket, or a pipe — so an
9771    /// unrelated test opening or closing an fd inside the measurement
9772    /// window moved the total and failed the assertion. Worse, the panic
9773    /// poisoned [`FD_LOCK`] and took four more tests down with it, so the
9774    /// visible failure had little to do with the cause.
9775    ///
9776    /// Counting only fds that name the object under test makes the
9777    /// measurement immune to every unrelated fd, while still catching the
9778    /// leak each test exists to catch. (CI runs nextest, one process per
9779    /// test, which is why this only ever flaked when run locally.)
9780    #[cfg(target_os = "linux")]
9781    fn open_fd_count(needles: &[&str]) -> usize {
9782        let proc = procfs::process::Process::myself()
9783            .expect("Failed to get current process using /proc/self");
9784        proc.fd()
9785            .expect("Failed to list open file descriptors")
9786            .filter_map(Result::ok)
9787            .filter(|entry| {
9788                let target = format!("{:?}", entry.target);
9789                needles.iter().any(|needle| target.contains(needle))
9790            })
9791            .count()
9792    }
9793
9794    /// Test that DMA is NOT available on non-Linux platforms.
9795    /// This verifies the cross-platform behavior of is_dma_available().
9796    #[test]
9797    #[cfg(not(target_os = "linux"))]
9798    fn test_dma_not_available_on_non_linux() {
9799        assert!(
9800            !is_dma_available(),
9801            "DMA memory allocation should NOT be available on non-Linux platforms"
9802        );
9803    }
9804
9805    #[test]
9806    fn colorimetry_defaults_none_and_roundtrips_without_auto_fill() {
9807        use crate::{ColorEncoding, ColorRange, Colorimetry, PixelFormat, TensorMemory};
9808        let mut t = Tensor::<u8>::image(
9809            1280,
9810            720,
9811            PixelFormat::Nv12,
9812            Some(TensorMemory::Mem),
9813            crate::CpuAccess::ReadWrite,
9814        )
9815        .unwrap();
9816        assert_eq!(t.colorimetry(), None); // default undefined
9817        let c = Colorimetry::default()
9818            .with_encoding(ColorEncoding::Bt709)
9819            .with_range(ColorRange::Limited);
9820        t.set_colorimetry(Some(c));
9821        assert_eq!(t.colorimetry(), Some(c));
9822        // configure_image must NOT touch colorimetry
9823        t.configure_image(640, 480, PixelFormat::Grey).unwrap();
9824        assert_eq!(t.colorimetry(), Some(c));
9825    }
9826
9827    #[test]
9828    fn configure_image_within_capacity() {
9829        let mut t = Tensor::<u8>::image_with_capacity(
9830            640,
9831            480,
9832            PixelFormat::Rgb,
9833            None,
9834            crate::CpuAccess::ReadWrite,
9835        )
9836        .unwrap();
9837        t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
9838        assert_eq!(t.format(), Some(PixelFormat::Nv12));
9839        assert_eq!(t.width(), Some(320));
9840        assert_eq!(t.height(), Some(240));
9841        assert_eq!(t.shape(), &[360, 320]); // 240*3/2
9842    }
9843
9844    #[test]
9845    fn configure_image_too_large_errors() {
9846        let mut t = Tensor::<u8>::image_with_capacity(
9847            64,
9848            64,
9849            PixelFormat::Grey,
9850            None,
9851            crate::CpuAccess::ReadWrite,
9852        )
9853        .unwrap();
9854        let err = t
9855            .configure_image(1920, 1080, PixelFormat::Nv12)
9856            .unwrap_err();
9857        assert!(matches!(err, Error::InsufficientCapacity { .. }));
9858    }
9859
9860    /// A reused max-sized IOSurface pool keeps its physical `bytesPerRow` when
9861    /// reconfigured to a smaller logical image (physical-grid / logical-ROI
9862    /// decoupling), instead of collapsing to the frame's natural row stride.
9863    #[test]
9864    #[cfg(target_os = "macos")]
9865    fn configure_image_preserves_iosurface_physical_stride() {
9866        // Declares this test as an fd-opener; see FD_LOCK.
9867        let _lock = crate::tests::fd_lock_shared();
9868        // Pool: GREY/R8 IOSurface 100 wide → bytesPerRow padded to 128.
9869        let mut pool = Tensor::<u8>::image(
9870            100,
9871            64,
9872            PixelFormat::Grey,
9873            Some(TensorMemory::DmaBuf),
9874            crate::CpuAccess::ReadWrite,
9875        )
9876        .unwrap();
9877        let pitch = pool.effective_row_stride().unwrap();
9878        assert!(
9879            pitch >= 128 && pitch.is_multiple_of(64),
9880            "padded bytesPerRow, got {pitch}"
9881        );
9882
9883        // Reconfigure to a smaller NV12 frame; the physical pitch must survive
9884        // (natural would be 32, but the surface stride is the 128-padded pitch).
9885        pool.configure_image(32, 16, PixelFormat::Nv12).unwrap();
9886        assert_eq!(pool.format(), Some(PixelFormat::Nv12));
9887        assert_eq!(pool.width(), Some(32));
9888        assert_eq!(pool.height(), Some(16));
9889        assert_eq!(
9890            pool.effective_row_stride(),
9891            Some(pitch),
9892            "configure_image must preserve the IOSurface physical bytesPerRow"
9893        );
9894
9895        // Reconfigure again to NV24 — pitch still preserved.
9896        pool.configure_image(32, 16, PixelFormat::Nv24).unwrap();
9897        assert_eq!(pool.effective_row_stride(), Some(pitch));
9898    }
9899
9900    /// `configure_image` on a Mem backing reconfigures to the format's
9901    /// **64-byte-aligned** row stride (the odd-dim contract: every image tensor
9902    /// carries a 64-aligned `row_stride`). For NV12 32×16 the minimum is
9903    /// `even(32)=32`, rounded up to the 64-byte alignment → 64. The capacity
9904    /// (64×64×4 RGBA = 16 KiB) easily holds the 24×64 = 1.5 KiB NV12 layout.
9905    #[test]
9906    fn configure_image_mem_aligns_stride() {
9907        let mut t = Tensor::<u8>::image_with_capacity(
9908            64,
9909            64,
9910            PixelFormat::Rgba,
9911            Some(TensorMemory::Mem),
9912            crate::CpuAccess::ReadWrite,
9913        )
9914        .unwrap();
9915        t.configure_image(32, 16, PixelFormat::Nv12).unwrap();
9916        let s = t.effective_row_stride().unwrap();
9917        assert_eq!(s % 64, 0, "stride must be 64-aligned");
9918        assert!(s >= 32, "stride must cover the even-width minimum");
9919        assert_eq!(s, 64);
9920    }
9921
9922    #[test]
9923    fn strided_mem_tensor_cpu_maps_full_padded_buffer() {
9924        // A packed RGBA image with row padding (GPU-pitch style): logical width
9925        // 8 px (32 B/row) but a 48-byte row stride. Over-allocate capacity (for
9926        // 16 px), narrow the logical width, then record the padded stride.
9927        // Previously `map()` rejected this on non-Linux with
9928        // "DMA backing is Linux-only"; HAL-owned Mem is now mappable.
9929        let mut t = Tensor::<u8>::image_with_capacity(
9930            16,
9931            3,
9932            PixelFormat::Rgba,
9933            Some(TensorMemory::Mem),
9934            crate::CpuAccess::ReadWrite,
9935        )
9936        .unwrap(); // capacity 3 × 16 × 4 = 192 B
9937        t.configure_image(8, 3, PixelFormat::Rgba).unwrap(); // logical [3, 8, 4] = 96 B
9938        t.set_row_stride(48).unwrap(); // padded stride (>= 32 B min)
9939
9940        let map = t.map().expect("strided Mem tensor should CPU-map");
9941        // Full padded buffer (stride 48 × 3 rows = 144 B), not the 96 B logical
9942        // view — callers iterate rows via `effective_row_stride()`.
9943        assert_eq!(map.as_slice().len(), 144);
9944        // Logical shape is still reported for shape-aware consumers.
9945        assert_eq!(map.shape(), &[3, 8, 4]);
9946    }
9947
9948    #[test]
9949    fn strided_mem_tensor_over_capacity_errors() {
9950        // Stride larger than the allocation: 64 B × 3 rows = 192 B > 96 B cap.
9951        let mut t = Tensor::<u8>::new(&[3, 8, 4], Some(TensorMemory::Mem), None).unwrap();
9952        t.set_format(PixelFormat::Rgba).unwrap();
9953        t.set_row_stride(64).unwrap();
9954        assert!(matches!(t.map(), Err(Error::InsufficientCapacity { .. })));
9955    }
9956
9957    /// Test that SHM memory allocation is available and usable on Unix systems.
9958    /// This is a basic functional test; Linux has additional FD leak tests using procfs.
9959    #[test]
9960    #[cfg(unix)]
9961    fn test_shm_available_and_usable() {
9962        // Declares this test as an fd-opener; see FD_LOCK.
9963        let _lock = crate::tests::fd_lock_shared();
9964        // "Unix" is not one platform here: Android is unix but its bionic libc
9965        // has no POSIX shared memory, so allocation legitimately reports
9966        // NotImplemented (see shm.rs module docs). Asserting availability
9967        // outright fails there for a documented reason.
9968        if !is_shm_available() {
9969            crate::test_support::report_skip(
9970                "test_shm_available_and_usable - SHM allocation \
9971                 unavailable on this platform",
9972            );
9973            return;
9974        }
9975
9976        // Create a tensor with SHM backing
9977        let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9978            .expect("Failed to create SHM tensor");
9979
9980        // Verify we can map and write to it
9981        let mut map = tensor.map().expect("Failed to map SHM tensor");
9982        map.as_mut_slice().fill(0xAB);
9983
9984        // Verify the data was written correctly
9985        assert!(
9986            map.as_slice().iter().all(|&b| b == 0xAB),
9987            "SHM tensor data should be writable and readable"
9988        );
9989    }
9990
9991    // =========================================================================
9992    // packed_rgba16f_layout — host-runnable geometry unit tests (TDD)
9993    // =========================================================================
9994
9995    #[test]
9996    fn packed_rgba16f_layout_planar_rgb_f16() {
9997        let layout =
9998            packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 640).expect("Some");
9999        assert_eq!(layout.surface_w, 160);
10000        assert_eq!(layout.surface_h, 1920);
10001        assert_eq!(layout.bytes_per_texel, 8);
10002        assert_eq!(layout.pitch, 1280);
10003    }
10004
10005    #[test]
10006    fn packed_rgba16f_layout_planar_rgba_f16() {
10007        let layout =
10008            packed_rgba16f_layout(PixelFormat::PlanarRgba, DType::F16, 640, 640).expect("Some");
10009        assert_eq!(layout.surface_w, 160);
10010        assert_eq!(layout.surface_h, 2560); // 4 planes
10011        assert_eq!(layout.bytes_per_texel, 8);
10012        assert_eq!(layout.pitch, 1280);
10013    }
10014
10015    #[test]
10016    fn packed_rgba16f_layout_rejects_misaligned() {
10017        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 642, 640).is_none());
10018    }
10019
10020    #[test]
10021    fn packed_rgba16f_layout_rejects_non_f16() {
10022        // Non-F16 dtype with planar RGB
10023        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::U8, 640, 640).is_none());
10024        // Non-planar format with F32
10025        assert!(packed_rgba16f_layout(PixelFormat::Rgb, DType::F32, 640, 640).is_none());
10026        // Packed Rgba with F16 is not a planar format → None
10027        assert!(packed_rgba16f_layout(PixelFormat::Rgba, DType::F16, 640, 640).is_none());
10028    }
10029
10030    #[test]
10031    fn cuda_map_fast_fails_to_none_without_handle() {
10032        let t = Tensor::<f32>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
10033        assert!(t.cuda().is_none());
10034        assert!(t.cuda_map().is_none()); // pure local check, no GL routing
10035    }
10036
10037    #[test]
10038    fn cuda_returns_none_without_handle() {
10039        // A plain Mem-backed tensor has no CUDA handle attached.
10040        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
10041        assert!(t.cuda().is_none(), "no CUDA handle on a Mem tensor");
10042        assert!(t.cuda_map().is_none(), "fast-fail map → None");
10043    }
10044
10045    #[test]
10046    fn cuda_map_then_host_map_fallback() {
10047        // The documented client pattern: try cuda_map() first; when it is None
10048        // (no CUDA handle — the case for a plain Mem tensor), fall back to map().
10049        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
10050        // Bind to a named variable so the CudaMap guard (and its borrow of `t`)
10051        // is dropped at the end of this statement, before the else branch borrows `t` again.
10052        let cuda = t.cuda_map();
10053        if let Some(_c) = cuda {
10054            // On a CUDA-registered tensor we'd use the device ptr here.
10055            unreachable!("a Mem tensor has no CUDA handle");
10056        } else {
10057            let host = t.map().expect("host map fallback must succeed");
10058            // TensorMapTrait::len() returns the element count (not bytes).
10059            assert_eq!(host.len(), 4); // 2*2 f32 elements
10060        }
10061    }
10062
10063    // -------------------------------------------------------------------------
10064    // Tensor::from_foreign — public API tests at the Tensor<T> layer.
10065    //
10066    // The low-level MemTensor::from_foreign mechanics (owner-drop, view sharing)
10067    // are covered in mem.rs.  These tests exercise the Tensor<T> guard paths
10068    // (null ptr, empty shape, size overflow) and the basic wrap+readback
10069    // contract, confirming the public unsafe API wires through correctly.
10070    // -------------------------------------------------------------------------
10071
10072    #[test]
10073    fn from_foreign_valid_wrap_and_readback() {
10074        // The canonical CUDA zero-copy shape: wrap a caller allocation as a
10075        // Mem tensor and verify the tensor reads the exact same bytes.
10076        let mut buf: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
10077        let ptr = buf.as_mut_ptr();
10078        let t = unsafe { Tensor::<f32>::from_foreign(ptr, &[2, 3], None, Some("test_foreign")) }
10079            .expect("valid from_foreign must succeed");
10080        assert_eq!(t.shape(), &[2, 3]);
10081        assert_eq!(t.memory(), TensorMemory::Mem);
10082        assert_eq!(t.name(), "test_foreign");
10083        let m = t.map().unwrap();
10084        // The tensor is a zero-copy borrow — it sees the caller's data.
10085        assert_eq!(m.as_slice(), &[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]);
10086    }
10087
10088    #[test]
10089    fn from_foreign_write_visible_in_caller_allocation() {
10090        // Writes through the tensor's map land in the caller's buffer (zero-copy).
10091        let mut buf: Vec<u8> = vec![0u8; 6];
10092        let ptr = buf.as_mut_ptr();
10093        let t = unsafe { Tensor::<u8>::from_foreign(ptr, &[2, 3], None, None) }.unwrap();
10094        {
10095            let mut m = t.map().unwrap();
10096            m.as_mut_slice().copy_from_slice(&[10, 20, 30, 40, 50, 60]);
10097        }
10098        drop(t);
10099        // Mutations are visible in the original Vec — same physical buffer.
10100        assert_eq!(buf, vec![10, 20, 30, 40, 50, 60]);
10101    }
10102
10103    #[test]
10104    fn from_foreign_rejects_null_ptr() {
10105        let err = unsafe { Tensor::<u8>::from_foreign(std::ptr::null_mut(), &[4], None, None) }
10106            .unwrap_err();
10107        assert!(
10108            matches!(err, Error::InvalidArgument(ref m) if m.contains("non-null")),
10109            "expected InvalidArgument(non-null), got {err:?}"
10110        );
10111    }
10112
10113    #[test]
10114    fn from_foreign_rejects_empty_shape() {
10115        let mut dummy: u8 = 0;
10116        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &[], None, None) }.unwrap_err();
10117        assert!(
10118            matches!(err, Error::InvalidSize(0)),
10119            "expected InvalidSize(0) for empty shape, got {err:?}"
10120        );
10121    }
10122
10123    #[test]
10124    fn from_foreign_rejects_overflow_shape() {
10125        // Two dimensions whose product overflows usize — the overflow guard must
10126        // fire before any pointer arithmetic is attempted.
10127        let mut dummy: u8 = 0;
10128        let huge = [usize::MAX / 2 + 1, 2];
10129        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &huge, None, None) }.unwrap_err();
10130        assert!(
10131            matches!(err, Error::InvalidArgument(ref m) if m.contains("overflow")),
10132            "expected InvalidArgument(overflow), got {err:?}"
10133        );
10134    }
10135
10136    #[test]
10137    fn from_foreign_owner_keeps_allocation_alive() {
10138        // When `owner` is `Some`, dropping the Tensor must not free the backing
10139        // before the owner is also gone — the owner's Drop fires on last ref.
10140        use std::sync::atomic::{AtomicBool, Ordering};
10141        let flag = std::sync::Arc::new(AtomicBool::new(false));
10142        let flag2 = flag.clone();
10143        struct Guard(std::sync::Arc<AtomicBool>);
10144        impl Drop for Guard {
10145            fn drop(&mut self) {
10146                self.0.store(true, Ordering::SeqCst);
10147            }
10148        }
10149        let mut buf: Vec<u32> = vec![42u32; 4];
10150        let ptr = buf.as_mut_ptr();
10151        let owner: ForeignOwner = Box::new(Guard(flag2));
10152        let t = unsafe { Tensor::<u32>::from_foreign(ptr, &[4], Some(owner), None) }.unwrap();
10153        // Map co-owns the backing Arc; the owner must stay alive while the map lives.
10154        let m = t.map().unwrap();
10155        assert_eq!(m.as_slice()[0], 42);
10156        drop(t); // tensor dropped while map is still live
10157        assert!(
10158            !flag.load(Ordering::SeqCst),
10159            "owner must not drop while a map shares the backing"
10160        );
10161        drop(m);
10162        assert!(
10163            flag.load(Ordering::SeqCst),
10164            "owner Drop must fire when the last Arc reference is released"
10165        );
10166    }
10167}