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 required [`CpuAccess`] declaration. Hardware access — GPU, NPU, ISP, codec — is always
40  implied; what the CPU intends is the one thing the allocator cannot guess. Mapping beyond the
41  declaration is best-effort and always counted by [`unplanned_cpu_access_count`].
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#[cfg(all(feature = "static", feature = "dynamic"))]
47compile_error!(
48    "edgefirst-tensor: `static` and `dynamic` are mutually exclusive. \
49     A consumer building the C-API leaves wants `--no-default-features \
50     --features dynamic`; everything else wants the default."
51);
52
53// The workspace declares `default-features = false` for this crate, so
54// nothing arrives here by accident anymore -- every consumer names a
55// backend explicitly. Before that change `static` was always a default and
56// this state was unreachable; now a manifest that forgot to ask for either
57// backend back builds an empty `tensor_dyn` module and fails on confusing
58// `E0432: unresolved import` errors instead of a message that names the
59// actual problem.
60#[cfg(not(any(feature = "static", feature = "dynamic")))]
61compile_error!(
62    "edgefirst-tensor: exactly one backend must be enabled. This crate has \
63     `default-features = false` at the workspace level, so every consumer \
64     must name one explicitly: `features = [\"static\"]` for Rust consumers, \
65     or `[\"dynamic\"]` for the C-API leaves."
66);
67
68// `static`-only: every constructor/accessor here is reached exclusively
69// through `TensorStorage`'s `AHardwareBuffer` variant (`impl<T> Tensor<T>`,
70// gated `#[cfg(feature = "static")]`); under `dynamic` nothing calls into
71// it, which clippy's dead-code lint on Android correctly flags without this
72// gate. Not `#[allow]`-ed: an `allow` here would hide the next real
73// dead-code signal in this module, the opposite of what a gate does.
74#[cfg(all(target_os = "android", feature = "static"))]
75mod ahardwarebuffer;
76// Pure AHardwareBuffer layout logic (format table, descriptor geometry,
77// overflow-checked shape math) — cfg-free so it compiles and unit-tests
78// on every host; the android module above consumes it.
79#[allow(dead_code)]
80mod ahardwarebuffer_layout;
81pub mod colorimetry;
82#[cfg(feature = "tracing")]
83pub mod trace;
84
85pub mod covguard;
86mod cuda;
87mod detect;
88// `static`-gated as well as Linux-gated. `DmaTensor` is storage, and only
89// the `static` backend has storage -- `TensorStorage` itself is already
90// `#[cfg(feature = "static")]`, and the `dynamic` backend reaches a
91// DMA-BUF's fd through `ef_tensor_plane_at` instead of ever constructing
92// one. Until task P2b's F3, this module was kept alive under `dynamic` by a
93// single always-`None` `Tensor::as_dma` whose signature named `DmaTensor`;
94// removing that stub revealed the whole module (the tensor, the sync
95// ioctls, the DRM PRIME helpers, the mmap owner) as dead code there --
96// twenty-six dead-code warnings' worth. It was always dead; one method
97// hid it.
98#[cfg(all(target_os = "linux", feature = "static"))]
99mod dma;
100#[cfg(all(target_os = "linux", feature = "static"))]
101mod dmabuf;
102mod error;
103mod format;
104// Layout table is backend-agnostic (pure DXGI-format math), same reasoning
105// as `iosurface_layout` below: compiled on every platform so it is
106// unit-tested on every CI lane. The allocator that consumes it
107// (`crate::d3d11`) is Windows-only.
108#[cfg(target_os = "windows")]
109pub mod d3d11;
110pub mod d3d11_layout;
111// Layout table is backend-agnostic (pure FourCC math). Storage
112// (`IoSurfaceTensor`) is `static`-only; `dynamic` reads IOSurface handles
113// through `ef_tensor_*`.
114#[cfg(any(target_os = "macos", target_os = "ios"))]
115mod iosurface_layout;
116// `static`-only, same reasoning as `ahardwarebuffer` above: reached only
117// through `TensorStorage`'s `IoSurface` variant.
118#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
119mod iosurface;
120pub mod lens;
121// `static`-only: `MemTensor` backs `TensorStorage::Mem`, `impl<T> Tensor<T>`
122// only. `dynamic`'s host-memory allocation goes through `ef_tensor_new`/
123// `ef_tensor_builder_alloc` in `libedgefirst_tensor.so` instead -- there is
124// no local `MemTensor` under `dynamic` to be dead code, so the whole module
125// gates rather than an internal `#[allow]`.
126#[cfg(feature = "static")]
127mod mem;
128mod pbo;
129pub mod pin;
130pub mod protocol;
131// `static`-only, same reasoning as `mem` above: `ShmTensor` backs
132// `TensorStorage::Shm`, `impl<T> Tensor<T>` only.
133#[cfg(all(unix, feature = "static"))]
134mod shm;
135mod tensor_dyn;
136#[cfg(test)]
137mod test_support;
138pub mod view;
139mod vocabulary;
140pub use colorimetry::{
141    ColorEncoding, ColorRange, ColorSpace, ColorTransfer, Colorimetry, MatrixWeights, RangeScaling,
142};
143
144/// Retained constructor: installs the coverage flush-on-abort handler for this
145/// crate's instrumented test binary. See `covguard`. Only present under
146/// coverage on Linux (`.init_array` is ELF-only; the i.MX flush is Linux-only).
147#[cfg(all(coverage, target_os = "linux"))]
148#[used]
149#[link_section = ".init_array"]
150static __EDGEFIRST_COV_INSTALL: extern "C" fn() = {
151    extern "C" fn ctor() {
152        crate::covguard::install();
153    }
154    ctor
155};
156
157// Backing tensor/map types are internal implementation details: callers
158// allocate `Tensor<T>` / `TensorDyn` and map them, never naming the per-memory
159// backing types directly. They are `pub(crate)` so they stay nameable for the
160// `TensorStorage` / `TensorMap` enums without leaking into the public API.
161// Exceptions kept public: `Pbo*` is a GL extension point implemented by the
162// image crate, and `image_iosurface_layout` is a public helper.
163#[cfg(all(target_os = "android", feature = "static"))]
164pub use crate::ahardwarebuffer::image_ahardwarebuffer_layout;
165#[cfg(all(target_os = "android", feature = "static"))]
166pub(crate) use crate::ahardwarebuffer::AHardwareBufferTensor;
167#[cfg(all(target_os = "linux", feature = "static"))]
168pub(crate) use crate::dma::DmaTensor;
169#[cfg(all(any(target_os = "macos", target_os = "ios"), feature = "static"))]
170pub(crate) use crate::iosurface::IoSurfaceTensor;
171// The two geometry helpers are compiled for both backends: three of the four
172// surfaces that read a raw texture's geometry (the Python bindings, and the
173// protocol and blob imports) link `dynamic`, and only the C ABI links
174// `static`. See `crate::d3d11::geometry`.
175#[cfg(target_os = "windows")]
176pub use crate::d3d11::geometry::{d3d11_shared_handle_geometry, d3d11_texture_geometry};
177#[cfg(any(target_os = "macos", target_os = "ios"))]
178pub use crate::iosurface_layout::image_iosurface_layout;
179#[cfg(feature = "static")]
180pub(crate) use crate::mem::MemTensor;
181pub use crate::pbo::{
182    client_state_pbo_ops, read_pbo_vtable_parts, EfClientState, EfPboMapFn, EfPboUnmapFn,
183    PboClientParts, PboMapping, PboOps, PboOpsVtableParts, PboTensor,
184};
185#[cfg(all(unix, feature = "static"))]
186pub(crate) use crate::shm::ShmTensor;
187pub use cuda::{
188    client_state_cuda_ops, gl_map_resource, gl_register_buffer, gl_unmap_resource,
189    gl_unregister_resource, is_cuda_available, memcpy_device_to_host, memcpy_host_to_device,
190    runtime_path as cuda_runtime_path, stream_create, stream_destroy, stream_synchronize,
191    CudaGlOps, CudaHandle, CudaMap, CudaStream,
192};
193pub use detect::{unletter_norm, BoundingBox, DetectBox, ProtoData, ProtoLayout, Segmentation};
194pub use error::{Error, Result};
195// Static-backend-implementation code: backs `tensor-capi`'s own
196// `ef_tensor_export`/`ef_tensor_import` (the static cdylib's `TensorDyn`
197// serialization), the one caller in the whole workspace. It calls
198// `TensorDyn::new`/`from_fd` and reads fields (`.quantization()`,
199// `.colorimetry()`, `.pin_host()`, ...) that only the `static` backend's
200// richer `TensorDyn` surface has, so it never compiled under `dynamic` in
201// fact even though nothing gated it -- see `PRIMITIVE-INVENTORY.md`'s
202// "structural findings".
203#[cfg(feature = "static")]
204pub mod blob;
205#[cfg(feature = "static")]
206pub use blob::{
207    BlobError, BlobHeader, BlobPlane, BlobStrings, BlobView, Regions, TransportMode, HEADER_LEN,
208    PLANE_RECORD_LEN,
209};
210pub use format::{ChromaLayout, PixelFormat, PixelLayout, PlaneGeometry};
211pub use lens::Element;
212use num_traits::Num;
213pub use pin::HostPin;
214pub use protocol::{
215    dtype as tensor_dtype, dtype_to_dtype, format as tensor_format, format_from_code,
216    kind as tensor_kind, SendPtr, TensorDesc, ABI_VERSION,
217};
218use serde::{Deserialize, Serialize};
219#[cfg(unix)]
220use std::os::fd::OwnedFd;
221use std::{
222    fmt,
223    sync::{
224        atomic::{AtomicU64, Ordering},
225        Arc, Weak,
226    },
227};
228// `static`'s `Tensor<T>` is declared directly above, in this file; there is
229// nothing to re-export for it. `dynamic`'s lives in `tensor_dyn` (see that
230// module's docs) and is re-exported here under the same crate-root name so
231// both backends present the same `edgefirst_tensor::Tensor` to callers.
232#[cfg(feature = "dynamic")]
233pub use tensor_dyn::Tensor;
234pub use tensor_dyn::{Raw, TensorDyn};
235pub use view::HostView;
236pub use vocabulary::vocabulary_demo;
237
238/// Opaque keep-alive handle for a foreign-memory tensor (see
239/// [`Tensor::from_foreign`] / [`TensorDyn::from_foreign_ptr`]).
240///
241/// The HAL borrows the foreign buffer without owning it; this handle co-owns
242/// the *source* so the borrowed memory stays valid for the tensor's life. Its
243/// `Drop` releases the source — e.g. a small struct that calls `cudaFreeHost`,
244/// or a `Py<PyAny>` that decrements a NumPy array's refcount. Wrapping it in an
245/// `Arc` (then boxing each clone) makes the release fire exactly once, after
246/// the last sharing tensor/view/map drops, regardless of drop order.
247pub type ForeignOwner = Box<dyn std::any::Any + Send + Sync>;
248
249/// Re-export of `half::f16` so downstream crates can write
250/// `Tensor::<edgefirst_tensor::f16>::from_iosurface(…)` without
251/// adding `half` to their own dependency list. The version stays in
252/// lockstep with the `half` workspace dep.
253pub use half::f16;
254
255// =============================================================================
256// RGBA16F packed-layout geometry — single source of truth
257//
258// A `PlanarRgb` [3,H,W] or `PlanarRgba` [4,H,W] f16 tensor is represented
259// on the GPU as an RGBA16F surface (the only float format accepted by the
260// ANGLE IOSurface extension). Four contiguous f16 elements are packed into
261// each 8-byte RGBA16F texel, yielding a `(W/4, C*H)` surface.
262//
263// All call sites that need these dimensions must use `packed_rgba16f_layout`
264// so the rule lives in exactly one place. Currently consumed by:
265//  - `crates/tensor/src/iosurface.rs` `new_image` (macOS IOSurface alloc)
266//  - `crates/image/src/gl/iosurface_import.rs` (macOS GL IOSurface import)
267//  - `crates/image/src/gl/processor/float.rs` (Linux GL float render — PBO
268//    readback and DMA-BUF, also via the `dma_f16_packed_layout` wrapper)
269// =============================================================================
270
271/// Geometry of the RGBA16F-packed surface backing a planar F16 image tensor.
272///
273/// ANGLE only supports one float `(type, internal_format)` pair for IOSurface
274/// import: `(GL_HALF_FLOAT, GL_RGBA)` = RGBA16F (8 bytes/texel). To map a
275/// `[C, H, W]` f16 planar tensor onto such a surface, 4 contiguous f16
276/// elements are packed into each RGBA16F texel, yielding a surface of
277/// `(W/4, C*H)` texels at 8 bytes/texel. The byte stream is identical to a
278/// (nonexistent) R16F `(W, C*H)` surface and can be consumed as `&[f16]`
279/// with shape `[1, C, H, W]` without rearrangement.
280///
281/// Obtain via [`packed_rgba16f_layout`] — never construct directly.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct PackedRgba16fLayout {
284    /// Surface width in texels (`width / 4`).
285    pub surface_w: usize,
286    /// Surface height in texels (`planes * height`).
287    pub surface_h: usize,
288    /// Bytes per RGBA16F texel (always 8).
289    pub bytes_per_texel: usize,
290    /// Row pitch in bytes (`surface_w * 8`).
291    pub pitch: usize,
292}
293
294/// Canonical geometry for the RGBA16F-packed surface backing a planar F16
295/// image tensor.
296///
297/// Returns `Some(layout)` only when **all** of the following hold:
298///
299/// - `dtype == DType::F16`
300/// - `format` is `PixelFormat::PlanarRgb` (3 planes) or
301///   `PixelFormat::PlanarRgba` (4 planes)
302/// - `width % 4 == 0`
303///
304/// Returns `None` for any other `(format, dtype)` combination, misaligned
305/// width, or when the surface geometry would overflow `usize` — callers
306/// must fall back to a non-packed path or return a context-appropriate
307/// error.
308///
309/// # Examples
310///
311/// ```rust
312/// use edgefirst_tensor::{packed_rgba16f_layout, PixelFormat, DType};
313///
314/// let layout = packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 480).unwrap();
315/// assert_eq!(layout.surface_w, 160);
316/// assert_eq!(layout.surface_h, 1440);
317/// assert_eq!(layout.bytes_per_texel, 8);
318/// assert_eq!(layout.pitch, 1280);
319/// ```
320pub fn packed_rgba16f_layout(
321    format: PixelFormat,
322    dtype: DType,
323    width: usize,
324    height: usize,
325) -> Option<PackedRgba16fLayout> {
326    if dtype != DType::F16 {
327        return None;
328    }
329    let planes: usize = match format {
330        PixelFormat::PlanarRgb => 3,
331        PixelFormat::PlanarRgba => 4,
332        _ => return None,
333    };
334    if !width.is_multiple_of(4) {
335        return None;
336    }
337    let surface_w = width / 4;
338    // Checked arithmetic: a degenerate (height, width) could otherwise wrap
339    // and yield an under-sized layout, which downstream allocators trust for
340    // GPU/CPU buffer sizing. Overflow → None (handled like any other
341    // unsupported geometry).
342    let surface_h = planes.checked_mul(height)?;
343    let bytes_per_texel = 8;
344    let pitch = surface_w.checked_mul(bytes_per_texel)?;
345    Some(PackedRgba16fLayout {
346        surface_w,
347        surface_h,
348        bytes_per_texel,
349        pitch,
350    })
351}
352
353/// Geometry of the RGBA8888-packed surface backing a packed RGB u8/i8 image
354/// tensor.
355///
356/// GPUs have no 3-channel renderable format, so the GL engine's two-pass
357/// packed-RGB shader writes the tight `[H, W, 3]` byte stream into an
358/// RGBA8888 surface: each texel carries 4 consecutive RGB bytes, giving a
359/// `(W*3/4, H)` surface at 4 bytes/texel whose rows are byte-identical to
360/// tight RGB — consumable flat as `[H, W, 3]` with no rearrangement (the
361/// u8/i8 analog of [`packed_rgba16f_layout`]; i8 shares the layout since
362/// INT8 quantization is a per-byte `^0x80` bias, not a format change).
363///
364/// Returns `Some(layout)` only when `width % 4 == 0` (so `W*3` bytes divide
365/// into whole texels) and the geometry does not overflow `usize`.
366///
367/// # Examples
368///
369/// ```rust
370/// use edgefirst_tensor::packed_rgb888_layout;
371///
372/// let layout = packed_rgb888_layout(640, 480).unwrap();
373/// assert_eq!(layout.surface_w, 480); // 640*3/4
374/// assert_eq!(layout.surface_h, 480);
375/// assert_eq!(layout.bytes_per_texel, 4);
376/// assert_eq!(layout.pitch, 1920); // 640*3
377/// assert!(packed_rgb888_layout(641, 480).is_none());
378/// ```
379pub fn packed_rgb888_layout(width: usize, height: usize) -> Option<PackedRgb888Layout> {
380    if !width.is_multiple_of(4) {
381        return None;
382    }
383    let row_bytes = width.checked_mul(3)?;
384    let surface_w = row_bytes / 4;
385    Some(PackedRgb888Layout {
386        surface_w,
387        surface_h: height,
388        bytes_per_texel: 4,
389        pitch: row_bytes,
390    })
391}
392
393/// Geometry of the RGBA8888 surface backing a packed RGB u8/i8 image tensor.
394///
395/// Obtain via [`packed_rgb888_layout`] — never construct directly.
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub struct PackedRgb888Layout {
398    /// Surface width in texels (`width * 3 / 4`).
399    pub surface_w: usize,
400    /// Surface height in texels (`height`).
401    pub surface_h: usize,
402    /// Bytes per RGBA8888 texel (always 4).
403    pub bytes_per_texel: usize,
404    /// Row pitch in bytes (`surface_w * 4` = `width * 3`).
405    pub pitch: usize,
406}
407
408/// The logical `(width, height)` an **allocation** shape describes for
409/// `format` -- the inverse of [`PixelFormat::allocation_shape`].
410///
411/// Used wherever a tensor arrives carrying a shape and a format but not its
412/// dimensions: the C constructors, the Python bindings and the cross-package
413/// protocol import.
414///
415/// # The input is the allocation shape, not the addressing shape
416///
417/// [`PixelFormat::allocation_shape`] is what [`Tensor::image`] allocates from
418/// and what `descriptor_pinned` emits from `shape()`, so that is what this
419/// inverts. [`PixelFormat::addressing_shape`] is a different, smaller grid,
420/// and for a semi-planar format the two collide: NV12's addressing shape is
421/// `[h, w]` and its allocation shape is `[combined_h, w]`, both rank 2. Handed
422/// an addressing shape this cannot tell -- NV12 `[480, 640]` reads as a
423/// combined height of 480 and answers `(640, 320)`, silently. Nothing in the
424/// shape distinguishes them, so callers must pass the allocation shape.
425///
426/// A D3D11 import is the one place this cannot bite: the constructors check
427/// the derived size against the texture description, so a mismatch fails with
428/// [`Error::InvalidArgument`] naming both. On Windows, prefer
429/// `d3d11_texture_geometry` and `d3d11_shared_handle_geometry`, which read the
430/// dimensions off the texture itself and leave the shape as a consistency
431/// check -- except for a semi-planar texture, whose width is its row stride
432/// rather than the image width, so those helpers take the width from the shape
433/// they are given and refuse to answer without one.
434///
435/// The rank is part of the format's contract, so a shape of the wrong rank is
436/// an error rather than an interpretation of whatever dimensions are present:
437///
438/// | Layout | Shape | Result |
439/// |--------|-------|--------|
440/// | Packed | `[h, w, c]` | `(shape[1], shape[0])` |
441/// | Planar | `[c, h, w]` | `(shape[2], shape[1])` |
442/// | SemiPlanar | `[combined_h, w]` | `(shape[1], image height)` |
443///
444/// A semi-planar shape carries the *combined* plane height, so the logical
445/// height is recovered through
446/// [`PixelFormat::image_height_from_combined`], which rejects a combined
447/// height no image height produces (721 for NV12, say) instead of rounding to
448/// a nearby one.
449///
450/// # Errors
451///
452/// [`Error::InvalidShape`] for a shape of the wrong rank, or a semi-planar
453/// combined height with no image height behind it.
454///
455/// # Example
456///
457/// ```
458/// use edgefirst_tensor::{image_dims_from_shape, PixelFormat};
459/// assert_eq!(image_dims_from_shape(PixelFormat::Rgba, &[480, 640, 4]).unwrap(), (640, 480));
460/// assert_eq!(image_dims_from_shape(PixelFormat::PlanarRgb, &[3, 480, 640]).unwrap(), (640, 480));
461/// assert_eq!(image_dims_from_shape(PixelFormat::Nv12, &[722, 640]).unwrap(), (640, 481));
462/// assert!(image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).is_err());
463/// ```
464pub fn image_dims_from_shape(format: PixelFormat, shape: &[usize]) -> Result<(usize, usize)> {
465    let wrong_rank = |expected: usize| {
466        Error::InvalidShape(format!(
467            "image_dims_from_shape: {format:?} describes a rank-{expected} allocation \
468             shape, got rank {} ({shape:?})",
469            shape.len()
470        ))
471    };
472    match format.layout() {
473        PixelLayout::Packed => {
474            if shape.len() != 3 {
475                return Err(wrong_rank(3));
476            }
477            Ok((shape[1], shape[0]))
478        }
479        PixelLayout::Planar => {
480            if shape.len() != 3 {
481                return Err(wrong_rank(3));
482            }
483            Ok((shape[2], shape[1]))
484        }
485        PixelLayout::SemiPlanar => {
486            if shape.len() != 2 {
487                return Err(wrong_rank(2));
488            }
489            let height = format.image_height_from_combined(shape[0]).ok_or_else(|| {
490                Error::InvalidShape(format!(
491                    "image_dims_from_shape: no {format:?} image height has combined plane \
492                     height {} (shape {shape:?})",
493                    shape[0]
494                ))
495            })?;
496            Ok((shape[1], height))
497        }
498    }
499}
500
501/// Per-plane DMA-BUF descriptor for external buffer import.
502///
503/// Owns a duplicated file descriptor plus optional stride and offset metadata.
504/// The fd is duplicated eagerly in [`new()`](Self::new) so that a bad fd is
505/// caught immediately. `import_image` consumes the descriptor and takes
506/// ownership of the duped fd — no further cleanup is needed by the caller.
507///
508/// # Examples
509///
510/// ```rust,no_run
511/// use edgefirst_tensor::PlaneDescriptor;
512/// use std::os::fd::BorrowedFd;
513///
514/// // SAFETY: fd 42 is hypothetical; real code must pass a valid fd.
515/// let pd = unsafe { PlaneDescriptor::new(BorrowedFd::borrow_raw(42)) }
516///     .unwrap()
517///     .with_stride(2048)
518///     .with_offset(0);
519/// ```
520#[cfg(unix)]
521pub struct PlaneDescriptor {
522    fd: OwnedFd,
523    stride: Option<usize>,
524    offset: Option<usize>,
525}
526
527#[cfg(unix)]
528impl PlaneDescriptor {
529    /// Create a new plane descriptor by duplicating the given file descriptor.
530    ///
531    /// The fd is duped immediately — a bad fd fails here rather than inside
532    /// `import_image`. The caller retains ownership of the original fd.
533    ///
534    /// # Errors
535    ///
536    /// Returns an error if the `dup()` syscall fails (e.g. invalid fd or
537    /// fd limit reached).
538    pub fn new(fd: std::os::fd::BorrowedFd<'_>) -> Result<Self> {
539        let owned = fd.try_clone_to_owned()?;
540        Ok(Self {
541            fd: owned,
542            stride: None,
543            offset: None,
544        })
545    }
546
547    /// Set the row stride in bytes (consuming builder).
548    pub fn with_stride(mut self, stride: usize) -> Self {
549        self.stride = Some(stride);
550        self
551    }
552
553    /// Set the plane offset in bytes (consuming builder).
554    pub fn with_offset(mut self, offset: usize) -> Self {
555        self.offset = Some(offset);
556        self
557    }
558
559    /// Consume the descriptor and return the owned file descriptor.
560    pub fn into_fd(self) -> OwnedFd {
561        self.fd
562    }
563
564    /// Row stride in bytes, if set.
565    pub fn stride(&self) -> Option<usize> {
566        self.stride
567    }
568
569    /// Plane offset in bytes, if set.
570    pub fn offset(&self) -> Option<usize> {
571        self.offset
572    }
573}
574
575/// A rectangular sub-region of a tensor's leading spatial frame, in pixels.
576///
577/// `Region` is the single rectangle type in the workspace: the argument to
578/// [`Tensor::view`], the source sampling window in the image crate's `Crop`,
579/// and the geometry the image backend lowers to a `glViewport` (a destination
580/// tile) or a sampling rectangle (a source). Coordinates are pixel/element
581/// units of the leading spatial axes; byte addressing is derived from the
582/// parent's row stride, not stored here.
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584pub struct Region {
585    pub x: usize,
586    pub y: usize,
587    pub width: usize,
588    pub height: usize,
589}
590
591impl Region {
592    /// Create a region at `(x, y)` spanning `width` × `height` pixels.
593    pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
594        Self {
595            x,
596            y,
597            width,
598            height,
599        }
600    }
601
602    /// True when the region lies fully within a `width` × `height` frame.
603    pub fn fits_within(&self, width: usize, height: usize) -> bool {
604        self.x.saturating_add(self.width) <= width && self.y.saturating_add(self.height) <= height
605    }
606}
607
608/// The parent image a [`view`](Tensor::view)/[`batch`](Tensor::batch) sub-region
609/// was carved from, snapshotted at the time the view was created.
610///
611/// A view shares the parent's `BufferIdentity` and addresses a sub-rectangle of
612/// it. The GL backend keys its EGLImage import on the **parent** geometry (so all
613/// sibling views of one buffer collapse to a single import) and renders each
614/// view as a `glViewport`+`glScissor` ROI at `(x, y, width, height)` within that
615/// parent — the view is render state, never a distinct import. `parent_width`/
616/// `parent_height` are the parent's logical pixel dimensions; `x`/`y` are this
617/// view's top-left origin within the parent (pixels). Nested views compose:
618/// the snapshot always names the **root** parent, with offsets accumulated.
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub struct ViewOrigin {
621    /// Logical width of the root parent image, in pixels.
622    pub parent_width: usize,
623    /// Logical height of the root parent image, in pixels. For a `batch(n)` view
624    /// of an `[N, H, W, C]` tensor this is `N * H` (the tiles stack vertically in
625    /// the shared buffer).
626    pub parent_height: usize,
627    /// The parent's row stride in **bytes**. The GL backend keys its EGLImage
628    /// import/cache and pitch on this — NOT on the view's own `row_stride`, which
629    /// a single-row view sets tight (for map-span safety). Using the parent
630    /// stride keeps the import pitch parent-consistent so single-row and
631    /// multi-row sibling views collapse onto the same parent import.
632    pub parent_row_stride: usize,
633    /// This view's top-left x origin within the root parent, in pixels.
634    pub x: usize,
635    /// This view's top-left y origin within the root parent, in pixels.
636    pub y: usize,
637}
638
639// Declared through `ef_vocabulary!` (see `vocabulary.rs`): `.code()` is this
640// crate's wire numbering, mirrored by `protocol::dtype` and the C ABI's
641// `hal_dtype`. Both surfaces are generated from the same per-variant literal
642// below rather than re-typed by hand, and `dtype_codes_are_unchanged_by_the_macro`
643// in `tests/vocabulary.rs` pins them against the values already shipping.
644crate::ef_vocabulary! {
645    /// Element type discriminant for runtime type identification.
646    #[derive(Serialize, Deserialize)]
647    #[non_exhaustive]
648    pub enum DType {
649        U8 = 0, "u8", U8,
650        I8 = 1, "i8", I8,
651        U16 = 2, "u16", U16,
652        I16 = 3, "i16", I16,
653        U32 = 4, "u32", U32,
654        I32 = 5, "i32", I32,
655        U64 = 6, "u64", U64,
656        I64 = 7, "i64", I64,
657        F16 = 8, "f16", F16,
658        F32 = 9, "f32", F32,
659        F64 = 10, "f64", F64,
660    }
661    // `#[doc(hidden)]`: this module has to be `pub` for `protocol::dtype`'s
662    // `pub use` re-export below to compile, but it is emission plumbing, not
663    // a second documented API for the same eleven codes -- `protocol::dtype`
664    // is the canonical, documented path.
665    #[doc(hidden)]
666    pub mod dtype_wire;
667}
668
669impl DType {
670    /// Size of one element in bytes.
671    pub const fn size(&self) -> usize {
672        match self {
673            Self::U8 | Self::I8 => 1,
674            Self::U16 | Self::I16 | Self::F16 => 2,
675            Self::U32 | Self::I32 | Self::F32 => 4,
676            Self::U64 | Self::I64 | Self::F64 => 8,
677        }
678    }
679
680    /// Short type name (e.g., "u8", "f32", "f16"). Identical to
681    /// [`DType::as_str`]; kept as its own method for existing callers.
682    pub fn name(&self) -> &'static str {
683        self.as_str()
684    }
685}
686
687impl fmt::Display for DType {
688    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689        f.write_str(self.name())
690    }
691}
692
693/// Map a static numeric type `T` to its `DType` discriminant, returning
694/// `None` for types that do not have a `DType` representation (e.g.
695/// user-defined wrappers in tests).
696///
697/// Runtime dtype of a static `Tensor<T>` element type — a safe `TypeId`
698/// allowlist of HAL's primitive numeric types (`Some` for `u8`..`i64` /
699/// `f16` / `f32` / `f64`, `None` otherwise). Used by the macOS IOSurface
700/// image constructors (FourCC / pixel-format lookup) and by the `Mem`
701/// backing's `alloc_zeroed` fast path (these types' `T::zero()` is the
702/// all-zeros bit pattern), so it is compiled on every target -- but only
703/// under `static`: every call site lives in `impl<T> Tensor<T>`, itself
704/// `#[cfg(feature = "static")]`.
705#[cfg(feature = "static")]
706pub(crate) fn dtype_of<T: 'static>() -> Option<DType> {
707    use std::any::TypeId;
708    let id = TypeId::of::<T>();
709    if id == TypeId::of::<u8>() {
710        Some(DType::U8)
711    } else if id == TypeId::of::<i8>() {
712        Some(DType::I8)
713    } else if id == TypeId::of::<u16>() {
714        Some(DType::U16)
715    } else if id == TypeId::of::<i16>() {
716        Some(DType::I16)
717    } else if id == TypeId::of::<u32>() {
718        Some(DType::U32)
719    } else if id == TypeId::of::<i32>() {
720        Some(DType::I32)
721    } else if id == TypeId::of::<u64>() {
722        Some(DType::U64)
723    } else if id == TypeId::of::<i64>() {
724        Some(DType::I64)
725    } else if id == TypeId::of::<half::f16>() {
726        Some(DType::F16)
727    } else if id == TypeId::of::<f32>() {
728        Some(DType::F32)
729    } else if id == TypeId::of::<f64>() {
730        Some(DType::F64)
731    } else {
732        None
733    }
734}
735
736// =============================================================================
737// Quantization metadata — type-gated to integer element types via sealed
738// `IntegerType` trait. Accessors on `Tensor<T>` only compile when `T` is
739// an integer type; calling them on `Tensor<f32>` / `Tensor<f16>` etc. is a
740// compile error, not a runtime one.
741// =============================================================================
742
743mod sealed {
744    pub trait Sealed {}
745    impl Sealed for u8 {}
746    impl Sealed for i8 {}
747    impl Sealed for u16 {}
748    impl Sealed for i16 {}
749    impl Sealed for u32 {}
750    impl Sealed for i32 {}
751    impl Sealed for u64 {}
752    impl Sealed for i64 {}
753    // f16 / f32 / f64 are sealed too (crate::lens::Element covers all
754    // eleven element types), but deliberately NOT given `IntegerType` below.
755    impl Sealed for half::f16 {}
756    impl Sealed for f32 {}
757    impl Sealed for f64 {}
758}
759
760/// Integer element types that may carry quantization metadata.
761///
762/// Sealed trait: implemented for `u8`, `i8`, `u16`, `i16`, `u32`, `i32`,
763/// `u64`, `i64`. Cannot be implemented downstream. Float element types
764/// (`half::f16`, `f32`, `f64`) are explicitly excluded — quantization
765/// metadata does not apply to float tensors per the edgefirst.json spec.
766pub trait IntegerType: sealed::Sealed {}
767impl IntegerType for u8 {}
768impl IntegerType for i8 {}
769impl IntegerType for u16 {}
770impl IntegerType for i16 {}
771impl IntegerType for u32 {}
772impl IntegerType for i32 {}
773impl IntegerType for u64 {}
774impl IntegerType for i64 {}
775
776/// Quantization parameters for an integer tensor.
777///
778/// Covers all four modes the edgefirst.json spec defines:
779///
780/// | Mode | `scale.len()` | `zero_point` | `axis` |
781/// |---|---|---|---|
782/// | Per-tensor symmetric | 1 | `None` | `None` |
783/// | Per-tensor asymmetric | 1 | `Some(len == 1)` | `None` |
784/// | Per-channel symmetric | >1 | `None` | `Some(c)` |
785/// | Per-channel asymmetric | >1 | `Some(len == scale.len())` | `Some(c)` |
786///
787/// The quantized storage type is carried on the parent [`Tensor<T>`]; this
788/// struct does not duplicate it. Construct via the four named constructors
789/// (the only public entry points); direct field mutation is not allowed so
790/// invalid combinations cannot be represented.
791///
792/// Dequantization formula:
793///
794/// ```text
795///   real_value = scale[c] × (quantized_value[c] - zero_point[c])
796/// ```
797///
798/// where `c` is the channel index (always `0` for per-tensor).
799#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
800pub struct Quantization {
801    /// Per-tensor: `vec![scale]`. Per-channel: `vec![scale_0, scale_1, ...]`.
802    #[serde(deserialize_with = "deserialize_scalar_or_vec_f32")]
803    scale: Vec<f32>,
804
805    /// `None` means symmetric (zero-point is 0). `Some(vec)` must have the
806    /// same length as `scale`.
807    #[serde(
808        default,
809        deserialize_with = "deserialize_opt_scalar_or_vec_i32",
810        skip_serializing_if = "Option::is_none"
811    )]
812    zero_point: Option<Vec<i32>>,
813
814    /// Channel axis for per-channel quantization. `Some(_)` iff
815    /// `scale.len() > 1`. Validated against the parent tensor's shape at
816    /// `set_quantization()` time.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    axis: Option<usize>,
819}
820
821/// Semantic mode discriminant for hot-path kernel dispatch.
822///
823/// Obtain via [`Quantization::mode`] once at kernel entry; never inside a
824/// pixel-level loop. The enum is borrow-based so the hot kernel receives
825/// the scales / zero-points as slices without reallocation.
826#[derive(Debug, Clone, Copy)]
827pub enum QuantMode<'a> {
828    PerTensorSymmetric {
829        scale: f32,
830    },
831    PerTensor {
832        scale: f32,
833        zero_point: i32,
834    },
835    PerChannelSymmetric {
836        scales: &'a [f32],
837        axis: usize,
838    },
839    PerChannel {
840        scales: &'a [f32],
841        zero_points: &'a [i32],
842        axis: usize,
843    },
844}
845
846impl Quantization {
847    /// Per-tensor symmetric (zero_point = 0).
848    pub fn per_tensor_symmetric(scale: f32) -> Self {
849        Self {
850            scale: vec![scale],
851            zero_point: None,
852            axis: None,
853        }
854    }
855
856    /// Per-tensor asymmetric — the most common runtime shape.
857    pub fn per_tensor(scale: f32, zero_point: i32) -> Self {
858        Self {
859            scale: vec![scale],
860            zero_point: Some(vec![zero_point]),
861            axis: None,
862        }
863    }
864
865    /// Per-channel symmetric. Errors on empty `scales`.
866    pub fn per_channel_symmetric(scales: Vec<f32>, axis: usize) -> Result<Self> {
867        if scales.is_empty() {
868            return Err(Error::QuantizationInvalid {
869                field: "scale.len",
870                expected: "non-empty per-channel scales".to_string(),
871                got: "length 0".to_string(),
872            });
873        }
874        Ok(Self {
875            scale: scales,
876            zero_point: None,
877            axis: Some(axis),
878        })
879    }
880
881    /// Per-channel asymmetric. Errors on length mismatch between `scales`
882    /// and `zero_points`, or empty arrays.
883    pub fn per_channel(scales: Vec<f32>, zero_points: Vec<i32>, axis: usize) -> Result<Self> {
884        if scales.is_empty() {
885            return Err(Error::QuantizationInvalid {
886                field: "scale.len",
887                expected: "non-empty per-channel scales".to_string(),
888                got: "length 0".to_string(),
889            });
890        }
891        if scales.len() != zero_points.len() {
892            return Err(Error::QuantizationInvalid {
893                field: "zero_point.len",
894                expected: format!("length matches scale ({})", scales.len()),
895                got: format!("length {}", zero_points.len()),
896            });
897        }
898        Ok(Self {
899            scale: scales,
900            zero_point: Some(zero_points),
901            axis: Some(axis),
902        })
903    }
904
905    /// Borrow-based dispatch view. Match once at kernel entry.
906    pub fn mode(&self) -> QuantMode<'_> {
907        match (self.scale.len(), self.zero_point.as_deref(), self.axis) {
908            (1, None, _) => QuantMode::PerTensorSymmetric {
909                scale: self.scale[0],
910            },
911            (1, Some(zps), _) => QuantMode::PerTensor {
912                scale: self.scale[0],
913                zero_point: zps.first().copied().unwrap_or(0),
914            },
915            (_, None, Some(axis)) => QuantMode::PerChannelSymmetric {
916                scales: &self.scale,
917                axis,
918            },
919            (_, Some(zps), Some(axis)) => QuantMode::PerChannel {
920                scales: &self.scale,
921                zero_points: zps,
922                axis,
923            },
924            // The `validate()` path prevents constructing a
925            // per-channel Quantization without an axis, so the remaining
926            // pattern is unreachable in practice. Fall back to
927            // per-tensor symmetric using scale[0] to avoid panicking in
928            // release; debug builds assert.
929            _ => {
930                debug_assert!(
931                    false,
932                    "Quantization::mode: per-channel without axis is unreachable"
933                );
934                QuantMode::PerTensorSymmetric {
935                    scale: self.scale.first().copied().unwrap_or(1.0),
936                }
937            }
938        }
939    }
940
941    /// Returns `true` for per-tensor quantization (`scale.len() == 1`).
942    pub fn is_per_tensor(&self) -> bool {
943        self.scale.len() == 1
944    }
945
946    /// Returns `true` for per-channel quantization (`scale.len() > 1`).
947    pub fn is_per_channel(&self) -> bool {
948        self.scale.len() > 1
949    }
950
951    /// Returns `true` for symmetric quantization (no zero-point, or
952    /// zero-point vector of all zeros).
953    pub fn is_symmetric(&self) -> bool {
954        match &self.zero_point {
955            None => true,
956            Some(zps) => zps.iter().all(|&z| z == 0),
957        }
958    }
959
960    /// Borrow the scale array. Length 1 for per-tensor; `num_channels` for
961    /// per-channel.
962    pub fn scale(&self) -> &[f32] {
963        &self.scale
964    }
965
966    /// Borrow the zero-point array. `None` for symmetric.
967    pub fn zero_point(&self) -> Option<&[i32]> {
968        self.zero_point.as_deref()
969    }
970
971    /// Channel axis for per-channel quantization. `None` for per-tensor.
972    pub fn axis(&self) -> Option<usize> {
973        self.axis
974    }
975
976    /// Validate against a target tensor shape. Runs in
977    /// `Tensor::set_quantization()`. Catches:
978    ///   - empty `scale` (reject — must declare at least one factor)
979    ///   - `zero_point` length inconsistent with `scale` (reject —
980    ///     per-tensor must have len 1, per-channel must match `scale.len`)
981    ///   - `axis >= shape.len()` (axis out of range)
982    ///   - `scale.len() != shape[axis]` for per-channel
983    ///   - per-channel without axis (reject)
984    ///   - per-tensor with redundant axis (reject)
985    ///
986    /// `static`-only: `Tensor::set_quantization` is `impl<T> Tensor<T>`,
987    /// itself `#[cfg(feature = "static")]`. `dynamic`'s
988    /// `TensorDyn::set_quantization` (`dynamic_backend.rs`) sends the raw
989    /// scale/zero-point/axis over FFI and lets `libedgefirst_tensor.so`
990    /// validate them on its own (`static`) side instead of calling this
991    /// method locally.
992    #[cfg(feature = "static")]
993    pub(crate) fn validate(&self, shape: &[usize]) -> Result<()> {
994        // `Quantization` is `Deserialize`, so malformed JSON like
995        // `{"scale": [], "zero_point": []}` could otherwise produce an
996        // ill-defined value that confuses `mode()` selection and the
997        // per-channel kernels' indexing.
998        if self.scale.is_empty() {
999            return Err(Error::QuantizationInvalid {
1000                field: "scale.len",
1001                expected: ">= 1".to_string(),
1002                got: "0".to_string(),
1003            });
1004        }
1005        if let Some(zps) = self.zero_point.as_ref() {
1006            // Per-tensor: scale.len() == 1 and zero_point.len() must == 1.
1007            // Per-channel: zero_point.len() must == scale.len().
1008            let expected = if self.scale.len() == 1 {
1009                1
1010            } else {
1011                self.scale.len()
1012            };
1013            if zps.len() != expected {
1014                return Err(Error::QuantizationInvalid {
1015                    field: "zero_point.len",
1016                    expected: format!(
1017                        "{expected} (matching {})",
1018                        if self.scale.len() == 1 {
1019                            "per-tensor scale"
1020                        } else {
1021                            "per-channel scale.len"
1022                        }
1023                    ),
1024                    got: format!("length {}", zps.len()),
1025                });
1026            }
1027        }
1028
1029        match (self.scale.len(), self.axis) {
1030            (1, None) => Ok(()),
1031            (1, Some(_)) => Err(Error::QuantizationInvalid {
1032                field: "per_tensor_redundant_axis",
1033                expected: "axis=None for per-tensor quantization".to_string(),
1034                got: format!("axis={:?}", self.axis),
1035            }),
1036            (_, None) => Err(Error::QuantizationInvalid {
1037                field: "per_channel_requires_axis",
1038                expected: format!(
1039                    "axis=Some(_) for per-channel quantization (scale.len={})",
1040                    self.scale.len()
1041                ),
1042                got: "axis=None".to_string(),
1043            }),
1044            (n, Some(axis)) => {
1045                if axis >= shape.len() {
1046                    return Err(Error::QuantizationInvalid {
1047                        field: "axis",
1048                        expected: format!("axis < tensor rank ({})", shape.len()),
1049                        got: format!("axis={axis}"),
1050                    });
1051                }
1052                if shape[axis] != n {
1053                    return Err(Error::QuantizationInvalid {
1054                        field: "scale.len",
1055                        expected: format!("length matches shape[{axis}] ({})", shape[axis]),
1056                        got: format!("length {n}"),
1057                    });
1058                }
1059                Ok(())
1060            }
1061        }
1062    }
1063}
1064
1065impl From<(f32, i32)> for Quantization {
1066    /// Convenience construction from a `(scale, zero_point)` tuple. Matches
1067    /// the legacy `QuantTuple` / `Quantization::new` calling convention so
1068    /// existing `(0.1, -128).into()` sites keep working.
1069    fn from((scale, zero_point): (f32, i32)) -> Self {
1070        Self::per_tensor(scale, zero_point)
1071    }
1072}
1073
1074fn deserialize_scalar_or_vec_f32<'de, D: serde::Deserializer<'de>>(
1075    de: D,
1076) -> std::result::Result<Vec<f32>, D::Error> {
1077    use serde::de::{self, Visitor};
1078    struct V;
1079    impl<'de> Visitor<'de> for V {
1080        type Value = Vec<f32>;
1081        // Only serde's error text reads this; no value flows from it.
1082        #[cfg_attr(test, mutants::skip)]
1083        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1084            f.write_str("f32 or array of f32")
1085        }
1086        fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
1087            Ok(vec![v as f32])
1088        }
1089        #[allow(clippy::cast_possible_truncation)]
1090        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1091            Ok(vec![v as f32])
1092        }
1093        #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1094        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1095            Ok(vec![v as f32])
1096        }
1097        fn visit_seq<A: de::SeqAccess<'de>>(
1098            self,
1099            mut seq: A,
1100        ) -> std::result::Result<Self::Value, A::Error> {
1101            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1102            while let Some(x) = seq.next_element::<f32>()? {
1103                out.push(x);
1104            }
1105            Ok(out)
1106        }
1107    }
1108    de.deserialize_any(V)
1109}
1110
1111fn deserialize_opt_scalar_or_vec_i32<'de, D: serde::Deserializer<'de>>(
1112    de: D,
1113) -> std::result::Result<Option<Vec<i32>>, D::Error> {
1114    use serde::de::{self, Visitor};
1115    struct V;
1116    impl<'de> Visitor<'de> for V {
1117        type Value = Option<Vec<i32>>;
1118        // Only serde's error text reads this; no value flows from it.
1119        #[cfg_attr(test, mutants::skip)]
1120        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1121            f.write_str("null, i32, or array of i32")
1122        }
1123        // `Ok(None)` is `Ok(Default::default())`, the only replacement.
1124        #[cfg_attr(test, mutants::skip)]
1125        fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1126            Ok(None)
1127        }
1128        // As `visit_none`.
1129        #[cfg_attr(test, mutants::skip)]
1130        fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1131            Ok(None)
1132        }
1133        fn visit_some<D2: serde::Deserializer<'de>>(
1134            self,
1135            de: D2,
1136        ) -> std::result::Result<Self::Value, D2::Error> {
1137            struct Inner;
1138            impl<'de> Visitor<'de> for Inner {
1139                type Value = Vec<i32>;
1140                // Only serde's error text reads this; no value flows from it.
1141                #[cfg_attr(test, mutants::skip)]
1142                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1143                    f.write_str("i32 or array of i32")
1144                }
1145                #[allow(clippy::cast_possible_truncation)]
1146                fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1147                    Ok(vec![v as i32])
1148                }
1149                #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1150                fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1151                    Ok(vec![v as i32])
1152                }
1153                fn visit_seq<A: de::SeqAccess<'de>>(
1154                    self,
1155                    mut seq: A,
1156                ) -> std::result::Result<Self::Value, A::Error> {
1157                    let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1158                    while let Some(x) = seq.next_element::<i32>()? {
1159                        out.push(x);
1160                    }
1161                    Ok(out)
1162                }
1163            }
1164            de.deserialize_any(Inner).map(Some)
1165        }
1166        #[allow(clippy::cast_possible_truncation)]
1167        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1168            Ok(Some(vec![v as i32]))
1169        }
1170        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1171        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1172            Ok(Some(vec![v as i32]))
1173        }
1174        fn visit_seq<A: de::SeqAccess<'de>>(
1175            self,
1176            mut seq: A,
1177        ) -> std::result::Result<Self::Value, A::Error> {
1178            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1179            while let Some(x) = seq.next_element::<i32>()? {
1180                out.push(x);
1181            }
1182            Ok(Some(out))
1183        }
1184    }
1185    de.deserialize_option(V)
1186}
1187
1188/// Count of tensor maps requested beyond the buffer's declared
1189/// [`CpuAccess`] (including any CPU map of a `CpuAccess::None` buffer).
1190/// Undeclared CPU access is a pipeline smell: it forfeits layout
1191/// optimizations (write-combined mappings, tile compression) and may be
1192/// slow or refused on Android. Read via [`unplanned_cpu_access_count`];
1193/// each offending buffer also logs one warning.
1194static UNPLANNED_CPU_ACCESS: AtomicU64 = AtomicU64::new(0);
1195
1196/// Number of tensor maps that exceeded the buffer's declared
1197/// [`CpuAccess`] since process start. A pipeline that declares its CPU
1198/// access correctly holds this flat; see [`CpuAccess`] for the contract.
1199pub fn unplanned_cpu_access_count() -> u64 {
1200    UNPLANNED_CPU_ACCESS.load(Ordering::Relaxed)
1201}
1202
1203/// Record an unplanned CPU access on `identity_id`, warning once per
1204/// buffer (a steady-state pipeline maps the same buffer every frame — a
1205/// per-map warn would flood the log; repeats count silently).
1206///
1207/// `static`-only: the sole caller is `impl<T> TensorMapTrait<T> for
1208/// Tensor<T>` in `lib.rs`, itself `#[cfg(feature = "static")]`.
1209/// [`unplanned_cpu_access_count`] stays available under `dynamic` too --
1210/// it just always reads 0 there, honestly reflecting that this backend
1211/// produces no such telemetry.
1212#[cfg(feature = "static")]
1213pub(crate) fn note_unplanned_cpu_access(identity_id: u64, backend: &str, detail: &str) {
1214    UNPLANNED_CPU_ACCESS.fetch_add(1, Ordering::Relaxed);
1215    static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<u64>>> =
1216        std::sync::OnceLock::new();
1217    let warned = WARNED.get_or_init(Default::default);
1218    if warned.lock().is_ok_and(|mut s| s.insert(identity_id)) {
1219        log::warn!(
1220            "unplanned CPU access on {backend} buffer (identity {identity_id}): {detail} — \
1221             declare the intent at allocation (CpuAccess::Read/Write/ReadWrite) to make \
1222             this a planned, optimized mapping"
1223        );
1224    }
1225}
1226
1227/// Uniform guard for mutable access through a read-only map. Every
1228/// `TensorMap` backend calls this from `as_mut_slice`/`deref_mut` so a
1229/// `map_read()` misuse fails identically on all platforms instead of
1230/// passing on tolerant ones and exploding only on Android.
1231#[inline]
1232pub(crate) fn assert_map_writable(writable: bool, backend: &str) {
1233    assert!(
1234        writable,
1235        "{backend} map is read-only (obtained via map_read()/CpuAccess::Read) — \
1236         use map_mut() or map_write() for mutable access"
1237    );
1238}
1239
1240/// Declared CPU involvement for an image tensor, chosen at allocation.
1241///
1242/// The HAL assumes buffers are produced and consumed by hardware (ISP,
1243/// codec, GPU, NPU) — hardware access needs no declaration. CPU access is
1244/// the opt-in: it selects the CPU usage/mapping mode at allocation
1245/// (write-combined for `Write`, cached for `Read`) and, on Android, pins
1246/// the layout linear (vendor tile compression requires `None`).
1247///
1248/// Mapping beyond the declared access is best-effort, never silent: it
1249/// may be refused ([`Error::NotImplemented`]) or take a slow path, and it
1250/// always increments [`unplanned_cpu_access_count`] with a once-per-buffer
1251/// warning.
1252#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1253pub enum CpuAccess {
1254    /// Hardware-only buffer (the default): no CPU mapping declared.
1255    /// Compression-eligible on platforms with vendor tile layouts.
1256    #[default]
1257    None,
1258    /// CPU reads (verification, CPU consumers) — cached mapping.
1259    Read,
1260    /// CPU writes (decode targets) — write-combined mapping where the
1261    /// platform supports it; reading through a `Write` map is undeclared.
1262    Write,
1263    /// CPU reads and writes — the pre-CpuAccess implicit behavior.
1264    ReadWrite,
1265}
1266
1267impl CpuAccess {
1268    /// Whether this declaration includes CPU reads.
1269    pub fn reads(self) -> bool {
1270        matches!(self, CpuAccess::Read | CpuAccess::ReadWrite)
1271    }
1272
1273    /// Whether this declaration includes CPU writes.
1274    pub fn writes(self) -> bool {
1275        matches!(self, CpuAccess::Write | CpuAccess::ReadWrite)
1276    }
1277
1278    /// Whether this declaration covers `requested` (every direction the
1279    /// request needs is declared).
1280    pub fn covers(self, requested: CpuAccess) -> bool {
1281        (!requested.reads() || self.reads()) && (!requested.writes() || self.writes())
1282    }
1283}
1284
1285/// Requested tile-compression behavior for an image allocation (set via
1286/// [`ImageDesc::with_compression`]).
1287///
1288/// Vendor GPUs store textures in proprietary compressed tile layouts
1289/// (UBWC, AFBC, PVRIC, DCC) that cut memory bandwidth; eligibility
1290/// requires a hardware-only buffer ([`CpuAccess::None`]) because CPU
1291/// mapping pins the layout linear. The request records best knowledge on
1292/// the tensor ([`Tensor::compression`]); it never changes what bytes a
1293/// consumer sees through the GPU/NPU.
1294#[non_exhaustive]
1295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1296pub enum Compression {
1297    /// Let the platform use its native scheme when the format is
1298    /// eligible; otherwise allocate linear and count the fallback
1299    /// ([`compression_fallback_count`]). The right default for pipelines
1300    /// that want the bandwidth win without portability failures.
1301    Any,
1302    /// Require one specific scheme: allocation fails with
1303    /// [`Error::InvalidArgument`] when the device's scheme differs and
1304    /// [`Error::NotImplemented`] on platforms without vendor tile
1305    /// compression. For consumers whose ABI names a layout (e.g. a
1306    /// QNN context binary declaring UBWC inputs).
1307    Scheme(CompressionScheme),
1308}
1309
1310// Declared through `ef_vocabulary!` (see `vocabulary.rs`). This vocabulary
1311// gained a wire numbering when `ef_tensor_compression` was added: the
1312// dynamic backend reads a scheme back across the C boundary, so the enum
1313// needs codes that cannot drift from the C enumerators. Hand-writing that
1314// mapping in `edgefirst-tensor-capi` was the alternative and it is strictly
1315// worse -- `#[non_exhaustive]` makes a cross-crate `match` require a
1316// wildcard arm, so a scheme added here would silently map to "linear"
1317// there. Generated from one declaration, it cannot.
1318//
1319// Code 0 is deliberately unassigned and reserved for "linear" -- the state
1320// this crate spells `Option::None`, which has no variant to carry a code of
1321// its own. The C side names it `EF_COMPRESSION_NONE`.
1322crate::ef_vocabulary! {
1323    /// Vendor tile-compression schemes the HAL recognizes and records.
1324    ///
1325    /// Unrecognized platforms record `None` (linear) — there is deliberately
1326    /// no `Unknown` variant; a scheme is only recorded when the vendor is
1327    /// positively identified.
1328    #[non_exhaustive]
1329    pub enum CompressionScheme {
1330        /// Qualcomm Adreno Universal Bandwidth Compression.
1331        Ubwc = 1, "ubwc", UBWC,
1332        /// Arm Mali/Immortalis Framebuffer Compression.
1333        Afbc = 2, "afbc", AFBC,
1334        /// Imagination PowerVR Image Compression (Google Tensor G5+).
1335        Pvric = 3, "pvric", PVRIC,
1336        /// Samsung Xclipse (AMD RDNA) Delta Color Compression.
1337        Dcc = 4, "dcc", DCC,
1338    }
1339    // `#[doc(hidden)]` for the same reason `dtype_wire` is: emission
1340    // plumbing for `const`-only consumers, not a second documented API.
1341    #[doc(hidden)]
1342    pub mod compression_wire;
1343}
1344
1345crate::ef_vocabulary! {
1346    /// Whether a tensor's memory is known to be physically contiguous
1347    /// ([`Tensor::contiguity`]).
1348    ///
1349    /// Capture and other DMA engines without an IOMMU (the i.MX 8M Plus ISI,
1350    /// the vvcam ISP) can only write into physically contiguous memory.
1351    /// Only memory this crate allocated from a Linux DMA heap is known
1352    /// either way: the CMA heap is contiguous and the system heap is not.
1353    /// An imported fd, and every other kind of memory, is `Unknown`.
1354    #[non_exhaustive]
1355    pub enum Contiguity {
1356        /// Not known: an imported fd, or memory not allocated from a DMA heap.
1357        Unknown = 0, "unknown", UNKNOWN,
1358        /// Allocated from the CMA heap.
1359        Contiguous = 1, "contiguous", CONTIGUOUS,
1360        /// Allocated from the system heap.
1361        NonContiguous = 2, "non_contiguous", NON_CONTIGUOUS,
1362    }
1363    #[doc(hidden)]
1364    pub mod contiguity_wire;
1365}
1366
1367/// Count of image allocations that requested [`Compression::Any`] but
1368/// resolved to a linear layout (ineligible format/dtype, unrecognized
1369/// vendor, or a platform without vendor tile compression). Read via
1370/// [`compression_fallback_count`].
1371static COMPRESSION_FALLBACKS: AtomicU64 = AtomicU64::new(0);
1372
1373/// Number of [`Compression::Any`] requests since process start that
1374/// resolved to a linear layout instead of a vendor tile scheme. A
1375/// steady-state pipeline holds this flat after warmup; growth means an
1376/// allocation path keeps requesting compression it never gets.
1377pub fn compression_fallback_count() -> u64 {
1378    COMPRESSION_FALLBACKS.load(Ordering::Relaxed)
1379}
1380
1381/// Record a `Compression::Any` request resolving linear.
1382///
1383/// `static`-only: the sole caller is `Tensor::image_desc` (`impl<T>
1384/// Tensor<T>`), itself `#[cfg(feature = "static")]`.
1385/// [`compression_fallback_count`] stays available under `dynamic` too --
1386/// it just always reads 0 there.
1387#[cfg(feature = "static")]
1388pub(crate) fn note_compression_fallback(detail: &str) {
1389    COMPRESSION_FALLBACKS.fetch_add(1, Ordering::Relaxed);
1390    log::debug!("compression request fell back to linear: {detail}");
1391}
1392
1393/// Whether this platform can allocate `(format, dtype)` images in a
1394/// vendor tile-compressed layout. `true` requires an Android build, an
1395/// eligible format (RGBA8888 `u8`/`i8` initially), and a positively
1396/// identified GPU vendor; everywhere else the answer is `false` and
1397/// [`Compression::Any`] requests fall back to linear.
1398pub fn compression_support(format: PixelFormat, dtype: DType) -> bool {
1399    #[cfg(target_os = "android")]
1400    {
1401        crate::ahardwarebuffer_layout::compression_eligible(format, dtype)
1402            && crate::ahardwarebuffer::device_compression_scheme().is_some()
1403    }
1404    #[cfg(not(target_os = "android"))]
1405    {
1406        let _ = (format, dtype);
1407        false
1408    }
1409}
1410
1411/// Declarative image-allocation request — the full-featured front door
1412/// for image tensors ([`TensorDyn::image_desc`] and the image crate's
1413/// `ImageProcessor::create_image_desc`).
1414///
1415/// The classic constructors (`image`, `create_image`) cover the common
1416/// cases; the desc carries the optional requests — today the
1417/// [`Compression`] request — without another constructor-parameter
1418/// sweep. Fields are private and the builders consume/return by value,
1419/// so future options are non-breaking.
1420///
1421/// ```
1422/// use edgefirst_tensor::{Compression, CpuAccess, DType, ImageDesc, PixelFormat};
1423/// let desc = ImageDesc::new(640, 640, PixelFormat::Rgba, DType::U8)
1424///     .with_access(CpuAccess::None)
1425///     .with_compression(Compression::Any);
1426/// ```
1427#[derive(Debug, Clone)]
1428pub struct ImageDesc {
1429    width: usize,
1430    height: usize,
1431    format: PixelFormat,
1432    dtype: DType,
1433    memory: Option<TensorMemory>,
1434    access: CpuAccess,
1435    compression: Option<Compression>,
1436    contiguous: bool,
1437}
1438
1439impl ImageDesc {
1440    /// A new image request: auto-selected memory, [`CpuAccess::None`]
1441    /// (hardware-only), no compression request.
1442    pub fn new(width: usize, height: usize, format: PixelFormat, dtype: DType) -> Self {
1443        Self {
1444            width,
1445            height,
1446            format,
1447            dtype,
1448            memory: None,
1449            access: CpuAccess::None,
1450            compression: None,
1451            contiguous: false,
1452        }
1453    }
1454
1455    /// Request a specific memory backing (`None` = auto-select).
1456    pub fn with_memory(mut self, memory: Option<TensorMemory>) -> Self {
1457        self.memory = memory;
1458        self
1459    }
1460
1461    /// Declare the CPU access (see [`CpuAccess`]). Any declaration other
1462    /// than `None` makes a compression request invalid.
1463    pub fn with_access(mut self, access: CpuAccess) -> Self {
1464        self.access = access;
1465        self
1466    }
1467
1468    /// Request a tile-compressed layout (see [`Compression`]).
1469    pub fn with_compression(mut self, compression: Compression) -> Self {
1470        self.compression = Some(compression);
1471        self
1472    }
1473
1474    /// Require physically contiguous memory (see [`Contiguity`]).
1475    ///
1476    /// The image is allocated from the Linux CMA DMA heap only. Allocation
1477    /// fails, naming the heap, when there is no CMA heap or it cannot fit
1478    /// the image, instead of falling back to the system heap or to another
1479    /// kind of memory. Memory must be auto-selected or
1480    /// [`TensorMemory::DmaBuf`]. Other platforms return
1481    /// [`Error::NotImplemented`].
1482    pub fn with_contiguous(mut self, contiguous: bool) -> Self {
1483        self.contiguous = contiguous;
1484        self
1485    }
1486
1487    /// Requested width in pixels.
1488    pub fn width(&self) -> usize {
1489        self.width
1490    }
1491
1492    /// Requested height in pixels.
1493    pub fn height(&self) -> usize {
1494        self.height
1495    }
1496
1497    /// Requested pixel format.
1498    pub fn format(&self) -> PixelFormat {
1499        self.format
1500    }
1501
1502    /// Requested element type.
1503    pub fn dtype(&self) -> DType {
1504        self.dtype
1505    }
1506
1507    /// Requested memory backing (`None` = auto-select).
1508    pub fn memory(&self) -> Option<TensorMemory> {
1509        self.memory
1510    }
1511
1512    /// Declared CPU access.
1513    pub fn access(&self) -> CpuAccess {
1514        self.access
1515    }
1516
1517    /// The compression request, if any.
1518    pub fn compression(&self) -> Option<Compression> {
1519        self.compression
1520    }
1521
1522    /// Whether physically contiguous memory is required.
1523    pub fn contiguous(&self) -> bool {
1524        self.contiguous
1525    }
1526}
1527
1528/// What kind of system key an identity was derived from. Participates in the
1529/// id so that, say, inode 7 and IOSurfaceID 7 cannot collide.
1530///
1531/// Invariant every variant must uphold: the key passed to
1532/// [`BufferIdentity::derived`] is unique among live buffers of that kind
1533/// **that own an allocation**. There is no "no identity" case — where no
1534/// system-wide key exists a process-local one does (a pointer, a GL name),
1535/// and a process-local key is exactly as sound as a system-wide one:
1536/// pointer/name reuse after free is the same ABA hazard as inode reuse, and
1537/// is mitigated the same way, by the retaining cache (see the design spec's
1538/// Task 6).
1539///
1540/// The allocation qualifier is load-bearing, not a hedge. A zero-element
1541/// tensor (`[0, num_protos]`, the tracker's "no detections this frame"
1542/// sentinel — see [`crate::mem`]) owns no allocation, so its `Vec` yields the
1543/// dangling well-aligned pointer `align_of::<T>()`. Every zero-element
1544/// `Tensor<f32>` in a process therefore shares one identity, and can even
1545/// collide across kinds with a small integer key from another variant.
1546///
1547/// That is safe rather than tolerated: an identity exists to answer "are
1548/// these the same bytes?", and buffers with no bytes have nothing to alias.
1549/// Nor can they reach an import cache. There are four GPU import paths —
1550/// a dma-buf fd on Linux, an IOSurface ref on macOS, an `AHardwareBuffer` on
1551/// Android, and an `ID3D11Texture2D` on Windows — and each takes only its own
1552/// object, so a tensor with no allocation reaches none of them. The Windows
1553/// path additionally keys on the texture the import itself holds a reference
1554/// to, and `edgefirst-image` **refuses** any texture whose identity is not
1555/// `D3d11Texture` over that same pointer — in every build, and on every
1556/// import including one served from its cache
1557/// (`GlPlatform::validate_import_identity`). That refusal exists because the
1558/// rule was once only written
1559/// down: the dynamic backend handed the image crate `HostPtr` identities over
1560/// recyclable handle addresses, and converts silently rendered into dropped
1561/// textures. Do not "fix" the zero-element case by minting a distinct id per
1562/// empty tensor: that reintroduces a counter, which is the state this whole
1563/// design exists to remove.
1564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1565pub enum IdentityKind {
1566    /// `(st_dev, st_ino)` of a dma-buf or memfd. Survives `dup`, and is the
1567    /// same value in every process that holds the buffer.
1568    DmaBuf,
1569    Shm,
1570    /// `IOSurfaceGetID`.
1571    IoSurface,
1572    /// `AHardwareBuffer_getId`, API 31+.
1573    AHardwareBufferId,
1574    /// The `AHardwareBuffer*` pointer, pre-API-31 where no id exists.
1575    /// Process-local: unique among live buffers here, meaningless elsewhere.
1576    AHardwareBufferPtr,
1577    /// An `ID3D11Texture2D*`. Process-local, like `AHardwareBufferPtr`, and
1578    /// the same pointer for every kind of texture tensor: one the HAL
1579    /// allocated, one wrapped from a caller's pointer, and one opened from a
1580    /// shared NT handle all key on the texture object they hold.
1581    ///
1582    /// **Both backends derive it from the same pointer, so their ids are
1583    /// equal byte for byte.** The static backend reads the `ComPtr` it owns
1584    /// (`d3d11::texture::tex_key`); the dynamic backend reads the same
1585    /// pointer back across the ABI with `ef_tensor_d3d11_texture`
1586    /// (`TensorDyn::derive_identity`). That equality is the point, not a
1587    /// coincidence: [`BufferIdentity::derived`] exists so two independently
1588    /// linked copies of this crate name one buffer identically, and
1589    /// `edgefirst-image` links the dynamic backend inside every `-capi` leaf
1590    /// while its own tests link the static one.
1591    ///
1592    /// **An `ef_tensor` handle's address must never be the key.** It is not a
1593    /// buffer at all, nothing the cache holds keeps it alive, and this
1594    /// library's allocator hands a freed handle's address to the very next
1595    /// one it mints — measured at 400 texture handles over 4 distinct
1596    /// addresses. The dynamic backend did exactly that on Windows until
1597    /// 2026-09-04, and a convert whose destination key collided rendered into
1598    /// the dropped texture while returning success.
1599    ///
1600    /// A handle import therefore does **not** compare equal to the tensor it
1601    /// was exported from, nor to a second import of the same handle:
1602    /// `OpenSharedResource1` mints a fresh `ID3D11Texture2D` per open. Keying
1603    /// on a handle value would make those compare equal, and would be unsound:
1604    /// Windows recycles a closed handle value straight into the next
1605    /// `DuplicateHandle`, so a consumer that caches on this id past the
1606    /// producing tensor's lifetime -- as the image crate's EGLImage cache
1607    /// does -- could serve one texture's entry for a later, unrelated import.
1608    /// A COM object's address cannot be recycled that way: a cache that holds
1609    /// a reference to the texture keeps the address taken for as long as its
1610    /// entry can be found. The cost is a cache miss per re-import instead of
1611    /// a wrong image.
1612    D3d11Texture,
1613    /// A host mapping address. Process-local.
1614    HostPtr,
1615    /// A GL buffer name. Valid only in the context that created it.
1616    Pbo,
1617}
1618
1619/// Unique identity for a tensor's underlying buffer.
1620///
1621/// Created fresh on every buffer allocation or import. The `id` is a cache
1622/// key, `kind` records what system key (if any) it was derived from, and the
1623/// `guard` is an `Arc<()>` whose weak references allow downstream caches to
1624/// detect when the buffer has been dropped.
1625#[derive(Debug, Clone)]
1626pub struct BufferIdentity {
1627    id: u64,
1628    kind: IdentityKind,
1629    guard: Arc<()>,
1630}
1631
1632impl BufferIdentity {
1633    /// Derive an identity from a key the operating system already guarantees.
1634    ///
1635    /// Deliberately NOT a counter: two independently-linked copies of this
1636    /// crate must derive the same id for the same buffer, or a consumer's
1637    /// import cache misses every frame. See the design spec, "The core finding".
1638    pub fn derived(kind: IdentityKind, key: u64) -> Self {
1639        // Mix the discriminant into the high bits so different kinds with the
1640        // same numeric key never collide.
1641        let id = ((kind as u64) << 56) ^ key;
1642        Self {
1643            id,
1644            kind,
1645            guard: Arc::new(()),
1646        }
1647    }
1648
1649    /// Unique identifier for this buffer. Changes when the buffer changes.
1650    pub fn id(&self) -> u64 {
1651        self.id
1652    }
1653
1654    /// What kind of system key this identity was derived from.
1655    pub fn kind(&self) -> IdentityKind {
1656        self.kind
1657    }
1658
1659    /// Returns a weak reference to the buffer guard. Goes dead when the
1660    /// owning Tensor is dropped (and no clones remain).
1661    pub fn weak(&self) -> Weak<()> {
1662        Arc::downgrade(&self.guard)
1663    }
1664}
1665
1666// `static`-only from here through `fs_magic` below: every use site lives in
1667// `impl<T> TensorStorage<T>` (`from_fd`'s DMA-BUF-vs-tmpfs classification),
1668// itself `#[cfg(feature = "static")]`. `dynamic`'s `from_fd`
1669// (`dynamic_backend.rs`) drives `ef_tensor_builder_wrap` instead, which
1670// does this same classification inside `libedgefirst_tensor.so`.
1671#[cfg(all(target_os = "linux", feature = "static"))]
1672use nix::sys::stat::{major, minor};
1673
1674/// Filesystem magic of the internal `dma_buf` mount, from
1675/// `include/uapi/linux/magic.h` (`DMA_BUF_MAGIC`, "DMAB").
1676///
1677/// This is stable UAPI and is the only reliable way to recognize a DMA-BUF
1678/// fd — see [`TensorStorage::from_fd`] for why `st_dev` cannot be used.
1679#[cfg(all(target_os = "linux", feature = "static"))]
1680const DMA_BUF_MAGIC: u32 = 0x444d_4142;
1681
1682/// Filesystem magic of tmpfs, from `include/uapi/linux/magic.h`
1683/// (`TMPFS_MAGIC`). Covers both POSIX `shm_open` segments under `/dev/shm`
1684/// and anonymous `memfd_create` files.
1685#[cfg(all(target_os = "linux", feature = "static"))]
1686const TMPFS_MAGIC: u32 = 0x0102_1994;
1687
1688/// Normalize a raw `fstatfs` `f_type` to the 32-bit unsigned magic that
1689/// `include/uapi/linux/magic.h` documents.
1690///
1691/// The width and signedness of `f_type` are target-dependent: `__fsword_t`
1692/// on Linux/gnu (`i64` on 64-bit, `i32` on 32-bit targets such as armv7,
1693/// i686 and aarch64-ilp32), `c_int` on uclibc, `c_ulong` on musl, and
1694/// `c_uint` on s390x. Where it is signed and 32 bits wide, widening it
1695/// sign-extends every magic with bit 31 set — hugetlbfs (`0x958458f6`),
1696/// btrfs (`0x9123683e`), f2fs (`0xf2f52010`) — which would make the value
1697/// reported by [`Error::UnknownBufferType`] impossible to find in the
1698/// header the docs point callers at.
1699///
1700/// Every magic is a 32-bit quantity, so truncating back to `u32` recovers
1701/// the true value from all four representations and loses nothing.
1702#[cfg(all(target_os = "linux", feature = "static"))]
1703const fn fs_magic(raw: i64) -> u32 {
1704    raw as u32
1705}
1706
1707/// The operations every memory backend implements.
1708///
1709/// This is the seam that lets [`Tensor<T>`] hide which backend is in play.
1710/// The implementors are `DmaTensor` (Linux DMA-BUF), `IoSurfaceTensor`
1711/// (macOS/iOS), `AHardwareBufferTensor` (Android), `ShmTensor`, `MemTensor`,
1712/// and [`PboTensor`]; `TensorStorage<T>` dispatches to whichever is active.
1713/// Backend types are not public — allocate a [`Tensor<T>`] or [`TensorDyn`]
1714/// and call these methods through it.
1715///
1716/// Import the trait to get `shape`, `size`, `map`, `clone_fd`,
1717/// `buffer_identity`, and the zero-copy sub-region `view` on a tensor value.
1718/// `Tensor::view` and `Tensor::batch` route through [`TensorTrait::view`], so
1719/// each backend's identity-sharing rule lives in exactly one place.
1720///
1721/// [`TensorDyn`]: crate::TensorDyn
1722pub trait TensorTrait<T>: Send + Sync
1723where
1724    T: Num + Clone + fmt::Debug,
1725{
1726    /// Create a new tensor with the given shape and optional name. If no name
1727    /// is given, a random name will be generated.
1728    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
1729    where
1730        Self: Sized;
1731
1732    #[cfg(unix)]
1733    /// Import an existing buffer as a tensor, taking ownership of its file
1734    /// descriptor. The buffer is adopted in place — no bytes are copied.
1735    ///
1736    /// The backend is **detected**, not chosen: the fd already belongs to a
1737    /// buffer type, and this call's job is to recognize which. On Linux that
1738    /// is decided by the fd's filesystem magic, which is stable UAPI
1739    /// (`include/uapi/linux/magic.h`):
1740    ///
1741    /// | Filesystem | Magic | Resulting [`TensorMemory`] |
1742    /// |------------|-------|----------------------------|
1743    /// | `dma_buf` | `DMA_BUF_MAGIC` (`0x444d4142`) | [`TensorMemory::DmaBuf`] |
1744    /// | `tmpfs` (`/dev/shm` **and** `memfd`) | `TMPFS_MAGIC` (`0x01021994`) | [`TensorMemory::Shm`] |
1745    /// | anything else | — | *rejected* — see Errors |
1746    ///
1747    /// Both supported types are identified **positively**. An unrecognized
1748    /// filesystem is an error, never a fallback to shared memory: the wrong
1749    /// branch does not fail loudly (a DMA-BUF is mmap-able, so it would
1750    /// import as a perfectly functional tensor that merely isn't DMA, and a
1751    /// pipe would import as a zero-length one), so guessing would trade a
1752    /// clear error here for silent loss of zero-copy far downstream.
1753    ///
1754    /// The device number is deliberately **not** consulted. `dma_buf` files
1755    /// live on an internal kernel mount whose `st_dev` comes from
1756    /// `get_anon_bdev()` — an IDA shared with every other anonymous
1757    /// pseudo-filesystem and allocated in boot order — so the minor a
1758    /// DMA-BUF lands on varies by kernel build and is not part of any ABI.
1759    ///
1760    /// On non-Linux Unix (macOS/iOS/Android) there is no fd-based DMA import;
1761    /// the fd is always adopted as [`TensorMemory::Shm`].
1762    ///
1763    /// # Arguments
1764    ///
1765    /// * `fd` - Owned descriptor for the buffer to import. Ownership
1766    ///   transfers to the returned tensor and the fd is closed on drop; pass
1767    ///   [`clone_fd`](TensorTrait::clone_fd) output to keep your own handle.
1768    /// * `shape` - Logical shape to interpret the buffer with. Must describe
1769    ///   no more elements than the buffer holds.
1770    /// * `name` - Optional name; a random one is generated when `None`.
1771    ///
1772    /// # Returns
1773    ///
1774    /// A tensor sharing the imported buffer's memory, whose
1775    /// [`memory()`](TensorTrait::memory) reports the detected backend.
1776    ///
1777    /// # Errors
1778    ///
1779    /// * [`Error::UnknownBufferType`] - the fd is on a filesystem that is
1780    ///   neither `dma_buf` nor `tmpfs`, so its buffer type cannot be
1781    ///   determined. Carries the observed `fstatfs` magic as a `u32`,
1782    ///   normalized so it can be looked up in `magic.h` directly on both
1783    ///   32- and 64-bit targets. Typical causes: a regular file, a pipe or
1784    ///   socket, or a `MFD_HUGETLB` memfd (hugetlbfs, not tmpfs). Linux only.
1785    /// * [`Error::UnknownDeviceType`] - the fd's `st_dev` major is non-zero,
1786    ///   i.e. it lives on a real block device rather than an anonymous or
1787    ///   in-memory filesystem. Linux only.
1788    /// * [`Error::InvalidSize`] - `shape` is empty or describes zero
1789    ///   elements.
1790    /// * [`Error::NixError`] - `fstat`, `fstatfs`, or `mmap` failed on the
1791    ///   descriptor.
1792    ///
1793    /// # Examples
1794    ///
1795    /// ```no_run
1796    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
1797    ///
1798    /// # fn main() -> edgefirst_tensor::Result<()> {
1799    /// let src = Tensor::<u8>::new(&[480, 640, 3], Some(TensorMemory::DmaBuf), None)?;
1800    ///
1801    /// // Round-tripping a DMA-BUF fd preserves the backend — the import is
1802    /// // still zero-copy, and still eligible for GPU/NPU paths.
1803    /// let imported = Tensor::<u8>::from_fd(src.clone_fd()?, src.shape(), None)?;
1804    /// assert_eq!(imported.memory(), TensorMemory::DmaBuf);
1805    /// # Ok(())
1806    /// # }
1807    /// ```
1808    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
1809    where
1810        Self: Sized;
1811
1812    #[cfg(unix)]
1813    /// Clone the file descriptor associated with this tensor.
1814    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd>;
1815
1816    /// Get the memory type of this tensor.
1817    fn memory(&self) -> TensorMemory;
1818
1819    /// Get the name of this tensor.
1820    fn name(&self) -> String;
1821
1822    /// Get the number of elements in this tensor.
1823    fn len(&self) -> usize {
1824        self.shape().iter().product()
1825    }
1826
1827    /// Check if the tensor is empty.
1828    fn is_empty(&self) -> bool {
1829        self.len() == 0
1830    }
1831
1832    /// Get the size in bytes of this tensor.
1833    fn size(&self) -> usize {
1834        self.len() * std::mem::size_of::<T>()
1835    }
1836
1837    /// Get the shape of this tensor.
1838    fn shape(&self) -> &[usize];
1839
1840    /// Reshape this tensor to the given shape. The total number of elements
1841    /// must remain the same.
1842    fn reshape(&mut self, shape: &[usize]) -> Result<()>;
1843
1844    /// Bytes of the underlying allocation (>= the current logical `size()`).
1845    fn capacity_bytes(&self) -> usize;
1846
1847    /// Set the logical shape to any shape whose byte size fits the allocation
1848    /// capacity, without the equal-size constraint of `reshape`.
1849    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()>;
1850
1851    /// Map the tensor into memory with the given access direction and
1852    /// return a TensorMap for accessing the data.
1853    ///
1854    /// `access` selects the platform mapping mode (read-only IOSurface
1855    /// lock, dma-buf sync direction, AHardwareBuffer lock usage) and the
1856    /// map's mutability: a map obtained with [`CpuAccess::Read`] rejects
1857    /// `as_mut_slice`. [`CpuAccess::None`] is not a mappable direction
1858    /// and returns [`Error::InvalidArgument`].
1859    ///
1860    /// Prefer the typed wrappers [`map_read`](Self::map_read) /
1861    /// [`map_write`](Self::map_write) / [`map_mut`](Self::map_mut).
1862    /// Map for CPU access.
1863    ///
1864    /// Like [`pin_host`](Tensor::pin_host), `'a` bounds how long the backing
1865    /// allocation lives rather than borrowing the tensor: the returned view
1866    /// shares ownership through the pin's keepalive, so a map can coexist with
1867    /// a `&mut` on the same tensor.
1868    ///
1869    /// # Windows D3D11 textures: a write-only map of a partial window
1870    ///
1871    /// A [`CpuAccess::Write`] map is not refreshed from the texture first, so
1872    /// the bytes the caller leaves untouched are undefined -- the documented
1873    /// contract a whole-tensor map can meet and a sub-view cannot, since the
1874    /// window spans its own rows only.
1875    ///
1876    /// A tensor allocated [`CpuAccess::Write`] (no staging texture) publishes
1877    /// only the window's own rows on unmap, so a sub-view -- a
1878    /// [`view`](Tensor::view), or a shape narrowed with `set_logical_shape`
1879    /// -- is refused only when its window is not a whole, pitch-aligned row
1880    /// range (a plain-offset [`view`](Tensor::view) always is; an arbitrary
1881    /// byte offset from `set_plane_offset` might not be), which fails with
1882    /// [`Error::InvalidArgument`].
1883    ///
1884    /// A tensor allocated [`CpuAccess::Read`] or [`CpuAccess::ReadWrite`]
1885    /// (staging texture) has no such per-window publish: unmapping always
1886    /// copies the whole staging texture back, so a write-only sub-view is
1887    /// refused with [`Error::PartialWriteRequiresReadWrite`], naming
1888    /// [`CpuAccess::ReadWrite`] as the same window with the refresh that
1889    /// makes the untouched rows well-defined -- and, unlike the
1890    /// `CpuAccess::Write` case above, that retry always succeeds.
1891    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1892    where
1893        T: 'a;
1894
1895    /// Map the tensor read-write (equivalent to
1896    /// `map_with(CpuAccess::ReadWrite)` — the historical `map()`
1897    /// behavior).
1898    fn map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1899    where
1900        T: 'a,
1901    {
1902        self.map_with(CpuAccess::ReadWrite)
1903    }
1904
1905    /// Map the tensor for CPU reading only. The returned map rejects
1906    /// `as_mut_slice`; on macOS this takes the read-only IOSurface lock
1907    /// (skips the unlock flush), on Linux the dma-buf read-direction
1908    /// sync.
1909    fn map_read<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1910    where
1911        T: 'a,
1912    {
1913        self.map_with(CpuAccess::Read)
1914    }
1915
1916    /// Map the tensor for CPU writing (fill-only: reading through a
1917    /// write map may see write-combined memory — do not read the slice).
1918    fn map_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1919    where
1920        T: 'a,
1921    {
1922        self.map_with(CpuAccess::Write)
1923    }
1924
1925    /// Map the tensor read-write (alias of [`map`](Self::map) with the
1926    /// intent spelled out).
1927    fn map_mut<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1928    where
1929        T: 'a,
1930    {
1931        self.map_with(CpuAccess::ReadWrite)
1932    }
1933
1934    /// Non-blocking [`map_with`](Self::map_with): returns
1935    /// `Err(Error::IoError(WouldBlock))` while a GPU copy the map depends on
1936    /// is still in flight, and makes progress on a retry.
1937    ///
1938    /// Backings with no such copy (host memory, shared memory, dma-buf,
1939    /// IOSurface, AHardwareBuffer, PBO) alias `map_with`, so a caller can use
1940    /// this unconditionally and only ever see `WouldBlock` where the wait is
1941    /// real -- today the Windows D3D11 texture's staging refresh.
1942    ///
1943    /// A retry loop must yield or sleep between attempts. On the WARP
1944    /// software adapter the CPU threads are the GPU, so a tight loop starves
1945    /// the very copy it is waiting for.
1946    ///
1947    /// [`CpuAccess::Write`] is refused on a Windows D3D11 texture tensor whose
1948    /// window is shorter than its backing, exactly as it is for
1949    /// [`map_with`](Self::map_with); see that method.
1950    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1951    where
1952        T: 'a,
1953    {
1954        self.map_with(access)
1955    }
1956
1957    /// Non-blocking [`map`](Self::map): read-write, and `WouldBlock` rather
1958    /// than a stall. See [`try_map_with`](Self::try_map_with).
1959    fn try_map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1960    where
1961        T: 'a,
1962    {
1963        self.try_map_with(CpuAccess::ReadWrite)
1964    }
1965
1966    /// Get the buffer identity for cache keying and liveness tracking.
1967    fn buffer_identity(&self) -> &BufferIdentity;
1968
1969    /// Create a zero-copy sub-region view of this backing that shares the
1970    /// underlying allocation **and** [`BufferIdentity`].
1971    ///
1972    /// The window is `[offset_bytes, offset_bytes + shape.product() *
1973    /// size_of::<T>())` measured from this tensor's own logical start, so a
1974    /// sub-view of a sub-view composes by adding offsets. Sharing the parent's
1975    /// identity is the contract that lets identity-keyed caches (e.g. the GL
1976    /// EGLImage import cache) treat offset-distinct windows as one buffer rather
1977    /// than unrelated allocations — `view` must never mint a fresh identity.
1978    ///
1979    /// Defaults to [`Error::NotImplemented`]; every backend that supports
1980    /// sub-views overrides it (`Mem`, `Shm`, Linux DMA, macOS IOSurface, `Pbo`).
1981    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self>
1982    where
1983        Self: Sized,
1984    {
1985        let _ = (offset_bytes, shape);
1986        Err(Error::NotImplemented(
1987            "view (zero-copy sub-region) is not supported for this tensor backend".to_owned(),
1988        ))
1989    }
1990}
1991
1992pub trait TensorMapTrait<T>
1993where
1994    T: Num + Clone + fmt::Debug,
1995{
1996    /// Get the shape of this tensor map.
1997    fn shape(&self) -> &[usize];
1998
1999    /// Unmap the tensor from memory.
2000    fn unmap(&mut self);
2001
2002    /// Get the number of elements in this tensor map.
2003    fn len(&self) -> usize {
2004        self.shape().iter().product()
2005    }
2006
2007    /// Check if the tensor map is empty.
2008    fn is_empty(&self) -> bool {
2009        self.len() == 0
2010    }
2011
2012    /// Get the size in bytes of this tensor map.
2013    fn size(&self) -> usize {
2014        self.len() * std::mem::size_of::<T>()
2015    }
2016
2017    /// Get a slice to the data in this tensor map.
2018    fn as_slice(&self) -> &[T];
2019
2020    /// Get a mutable slice to the data in this tensor map.
2021    fn as_mut_slice(&mut self) -> &mut [T];
2022
2023    /// Whether this mapping permits writes -- i.e. whether the coherency
2024    /// bracket it holds will flush on release.
2025    ///
2026    /// The single source of truth for a binding that has to advertise
2027    /// mutability to a foreign consumer (the Python buffer protocol's
2028    /// `readonly` flag, say). Deriving that from the access a caller
2029    /// *requested* instead would leave the advertisement agreeing with the
2030    /// request while both disagreed with the mapping actually taken.
2031    fn is_writable(&self) -> bool;
2032
2033    #[cfg(feature = "ndarray")]
2034    /// Get an ndarray ArrayView of the tensor data.
2035    fn view(&'_ self) -> Result<ndarray::ArrayView<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2036        Ok(ndarray::ArrayView::from_shape(
2037            self.shape(),
2038            self.as_slice(),
2039        )?)
2040    }
2041
2042    #[cfg(feature = "ndarray")]
2043    /// Get an ndarray ArrayViewMut of the tensor data.
2044    fn view_mut(
2045        &'_ mut self,
2046    ) -> Result<ndarray::ArrayViewMut<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2047        let shape = self.shape().to_vec();
2048        Ok(ndarray::ArrayViewMut::from_shape(
2049            shape,
2050            self.as_mut_slice(),
2051        )?)
2052    }
2053}
2054
2055// Declared through `ef_vocabulary!` (see `vocabulary.rs`). Before this, the
2056// same four backings were numbered three different ways -- Rust's implicit
2057// discriminants (`Dma=0, Shm=1, Mem=2, Pbo=3`), the C ABI's
2058// `hal_tensor_memory` (`MEM=0, DMA=1, SHM=2, PBO=3`), and Python's
2059// `PyTensorMemory`, which had no explicit discriminants at all and a
2060// `#[cfg(unix)]` variant in the middle of the list, so its numbering
2061// depended on the target OS. The canonical assignment below is the one all
2062// of them converge on; `hal_tensor_memory` is the single explicitly-mapped
2063// outlier that keeps its own released values (see `to_hal_tensor_memory` in
2064// the capi crate).
2065//
2066// Note this is NOT `protocol::kind`, which is a genuinely different
2067// vocabulary and stays so: `kind` describes what a `TensorDesc`'s `handle`
2068// and `ptr` fields MEAN to an importer, and both `Mem` and `Shm` mean the
2069// same thing there (`kind::HOST`, host-addressable pointer, no handle). A
2070// vocabulary that collapses two variants into one cannot also be the
2071// vocabulary that distinguishes them. `kind_of()` in `protocol.rs` is the
2072// mapping between them.
2073crate::ef_vocabulary! {
2074    /// Which memory backend a tensor is (or should be) allocated from.
2075    ///
2076    /// Pass `Some(..)` to a constructor to pin the backend, or `None` to
2077    /// auto-select. Auto-selection differs by constructor: [`Tensor::new`]
2078    /// tries `DmaBuf` → `Mem`, while the image constructors try `DmaBuf` →
2079    /// `Mem` and skip `Shm`. `EDGEFIRST_TENSOR_FORCE_MEM=1` short-circuits
2080    /// either chain to `Mem`, which is how tests run on hosts without
2081    /// DMA-heap permissions.
2082    ///
2083    /// A pinned request has no fallback: if the backend cannot serve it, the
2084    /// constructor fails rather than quietly giving you something slower.
2085    /// [`TensorTrait::memory`] reports what a tensor actually ended up with.
2086    ///
2087    /// # Every variant exists on every platform
2088    ///
2089    /// This is a namespace of codes, not a list of what this build can
2090    /// allocate -- [`TensorMemory::is_available`] answers the latter, at
2091    /// runtime. `Shm` used to be `#[cfg(unix)]`, which shifted every
2092    /// variant declared after it on a non-unix build and made the
2093    /// numbering a compile-time detail of the host. It also made a
2094    /// cross-platform recording unreadable: a blob captured on Linux
2095    /// carries `"dmabuf"`, and a macOS consumer has to be able to NAME
2096    /// that backing in order to report "cannot materialise here" instead
2097    /// of failing with an unrecognised code.
2098    ///
2099    /// # `#[non_exhaustive]`
2100    ///
2101    /// Match this with a wildcard arm. The variant list is expected to
2102    /// keep growing -- `IoSurface` and `Cuda` were added here, Windows
2103    /// backings and a real CUDA device backing are named as coming -- and
2104    /// this attribute is what keeps a closed-source consumer's
2105    /// `match memory { .. }` compiling across those additions. It protects
2106    /// DOWNSTREAM, not this crate: inside `edgefirst-tensor` the matches
2107    /// stay exhaustive and compile-enforced. Same rule as [`DType`],
2108    /// [`Compression`] and [`PixelFormat`], which `TensorMemory` was the
2109    /// lone exception to.
2110    #[non_exhaustive]
2111    pub enum TensorMemory {
2112        /// Regular system memory allocation. Available everywhere; the
2113        /// floor every auto-selection chain ends on.
2114        Mem = 0, "mem", MEM,
2115        /// POSIX Shared Memory allocation. Suitable for inter-process
2116        /// communication, but not for hardware acceleration. Only
2117        /// allocatable on unix; nameable everywhere.
2118        Shm = 1, "shm", SHM,
2119        /// Platform-native zero-copy GPU buffer.
2120        ///
2121        /// On Linux this is a DMA-BUF (`DmaTensor` in
2122        /// `crates/tensor/src/dma.rs`) allocated via the DRM/dma-heap
2123        /// subsystem. On macOS/iOS it is currently also this variant that
2124        /// yields an IOSurface, and on Android an AHardwareBuffer: they
2125        /// share the `TensorStorage::Dma` slot at the trait level, and the
2126        /// public C API discriminant (`HAL_TENSOR_MEMORY_DMA = 1`) covers
2127        /// all three with no ABI break. `IoSurface` below is the code for
2128        /// naming that backing specifically; no backend reports it yet.
2129        ///
2130        /// Allows hardware-accelerated paths (OpenGL backend on Linux via
2131        /// `EGL_EXT_image_dma_buf_import`; macOS via
2132        /// `EGL_ANGLE_iosurface_client_buffer`). CPU access via `map()`
2133        /// incurs cache-coherency overhead on Linux DMA-BUF and is similar
2134        /// in cost on IOSurface; SHM/Mem are cheaper for CPU-only workloads.
2135        ///
2136        /// On Windows it is an `ID3D11Texture2D` on the process D3D11 device
2137        /// (`crates/tensor/src/d3d11/`), allocated only through
2138        /// [`Tensor::image`](crate::Tensor::image); CPU access goes through a
2139        /// staging copy. The first such allocation creates the process
2140        /// device, so a host supplying its own device must call
2141        /// `d3d11::use_external_device` before it -- and before
2142        /// [`crate::is_gpu_buffer_available`], which creates the device too.
2143        DmaBuf = 2, "dmabuf", DMABUF,
2144        /// Apple IOSurface, named specifically rather than through the
2145        /// portable `DmaBuf` spelling.
2146        ///
2147        /// Defined as a code so a descriptor or recording that names an
2148        /// IOSurface is parseable on every platform. **No backend produces
2149        /// or accepts it yet** -- macOS/iOS allocate and report `DmaBuf`.
2150        /// Pinning a request to it fails with `NotImplemented`.
2151        IoSurface = 3, "iosurface", IOSURFACE,
2152        /// OpenGL Pixel Buffer Object memory. Created by `ImageProcessor`
2153        /// when DMA-buf is unavailable but OpenGL is present -- never by
2154        /// [`Tensor::new`], which has no GL context to create it in.
2155        Pbo = 4, "pbo", PBO,
2156        /// CUDA device memory: a device pointer, not host-addressable.
2157        ///
2158        /// Defined as a code for the same reason as `IoSurface`. **No
2159        /// backend produces or accepts it yet**; pinning a request to it
2160        /// fails with `NotImplemented`.
2161        Cuda = 5, "cuda", CUDA,
2162    }
2163    // `#[doc(hidden)]`: `pub` so the const-only form is reachable by an FFI
2164    // or cbindgen consumer, but it is emission plumbing rather than a second
2165    // documented API -- `TensorMemory::code()` is the documented path, and
2166    // the two are generated from the same literal.
2167    #[doc(hidden)]
2168    pub mod tensor_memory_wire;
2169}
2170
2171impl TensorMemory {
2172    /// Whether this backing can actually be allocated in this process, now.
2173    ///
2174    /// A runtime question, not a compile-time one, which is why it is a
2175    /// method on the vocabulary rather than a `cfg` on the variant:
2176    /// `DmaBuf` needs `/dev/dma_heap` to exist *and* this process to have
2177    /// permission on it (a container, a user outside the `video`/`render`
2178    /// groups, and a stock desktop give three different answers on the same
2179    /// kernel), and `Shm` needs a writable `/dev/shm`. Each probe allocates
2180    /// once and caches.
2181    ///
2182    /// Reports what can be *allocated*, which is not the same as what can be
2183    /// *named*: every variant is nameable on every platform (that is the
2184    /// point of the vocabulary), and the ones this build cannot serve say so
2185    /// here rather than by being absent from the enum.
2186    ///
2187    /// Three variants answer `false` unconditionally today, for reasons
2188    /// worth stating rather than hiding behind a probe that cannot fail:
2189    ///
2190    /// * `IoSurface` -- no backend produces or accepts it yet; macOS/iOS
2191    ///   serve IOSurface under the `DmaBuf` spelling, so `DmaBuf` is the
2192    ///   variant that answers `true` there.
2193    /// * `Pbo` -- PBO tensors are created by `ImageProcessor::create_image`
2194    ///   with a GL context current, never by [`Tensor::new`]. This crate
2195    ///   holds no GL context and cannot see one; ask the image crate
2196    ///   whether a GL backend is live.
2197    /// * `Cuda` -- there is no CUDA backing in this crate at all. Probing
2198    ///   for `libcuda` would answer `true` on a Jetson while every
2199    ///   allocation still failed, which is worse than answering `false`.
2200    ///
2201    /// One variant answers `true` for a narrower constructor than the rest:
2202    /// on Windows `DmaBuf` is an `ID3D11Texture2D`, which is image-formatted,
2203    /// so `true` there means [`Tensor::image`] can serve it and
2204    /// [`Tensor::new`] with `Some(DmaBuf)` still answers `NotImplemented`.
2205    /// [`is_gpu_buffer_available`](crate::is_gpu_buffer_available), which this
2206    /// forwards to, states the same split.
2207    pub fn is_available(self) -> bool {
2208        match self {
2209            // Always: the fallback every constructor chain ends on. If this
2210            // could fail, allocation would have no floor left.
2211            TensorMemory::Mem => true,
2212            TensorMemory::Shm => is_shm_available(),
2213            // Today `DmaBuf` is the portable spelling of "platform-native
2214            // zero-copy GPU buffer", so it is the portable probe that
2215            // answers for it -- true on macOS/iOS (IOSurface) and Android
2216            // (AHardwareBuffer) as well as Linux.
2217            TensorMemory::DmaBuf => is_gpu_buffer_available(),
2218            TensorMemory::IoSurface | TensorMemory::Pbo | TensorMemory::Cuda => false,
2219        }
2220    }
2221
2222    /// The error to return when a caller pins a request to a backing this
2223    /// build knows the name of but cannot serve.
2224    ///
2225    /// Deliberately not `unreachable!()`. `IoSurface` and `Cuda` are
2226    /// unreachable *today* only because no backend reports them yet; the
2227    /// day one does, an `unreachable!()` written now becomes a panic in
2228    /// library code reached from an ordinary caller request. A "this build
2229    /// cannot do that" error is the same information without the crash, and
2230    /// it is what every other unservable request here already returns.
2231    ///
2232    /// `static`-only: every call site is inside `impl<T> TensorStorage<T>`
2233    /// (`TensorStorage::new`), itself `#[cfg(feature = "static")]`.
2234    #[cfg(feature = "static")]
2235    pub(crate) fn unsupported_here(self) -> Error {
2236        let reason = match self {
2237            // Not reachable: `Mem` is servable on every target. Stated
2238            // rather than panicked on, for the reason above.
2239            TensorMemory::Mem => "Mem is servable on every platform, so this should not happen",
2240            TensorMemory::Shm => "POSIX shared memory is unix-only",
2241            TensorMemory::DmaBuf => {
2242                "this target has no platform GPU-buffer allocator (DMA-BUF is Linux, \
2243                 IOSurface macOS/iOS, AHardwareBuffer Android, D3D11 textures on \
2244                 Windows)"
2245            }
2246            TensorMemory::IoSurface => {
2247                "no backend produces or accepts IOSurface under its own code yet -- \
2248                 macOS/iOS allocate and report TensorMemory::DmaBuf"
2249            }
2250            TensorMemory::Pbo => {
2251                "PBO tensors are created by ImageProcessor::create_image with a GL \
2252                 context current, never by Tensor::new"
2253            }
2254            TensorMemory::Cuda => "there is no CUDA backing in this build",
2255        };
2256        Error::NotImplemented(format!(
2257            "TensorMemory::{self:?} is not supported by this build: {reason}"
2258        ))
2259    }
2260}
2261
2262impl From<TensorMemory> for String {
2263    /// The wire string, via [`TensorMemory::as_str`] -- one spelling, not a
2264    /// second one maintained alongside it.
2265    ///
2266    /// `DmaBuf` renders as `"dmabuf"`, not the `"dma"` this impl used to
2267    /// emit. `TryFrom<&str>` still accepts `"dma"` so anything that
2268    /// persisted the old spelling keeps parsing; the benchmark result keys
2269    /// in `.github/scripts` spell it `"dma"` but build that string
2270    /// themselves and never went through this impl.
2271    fn from(memory: TensorMemory) -> Self {
2272        memory.as_str().to_owned()
2273    }
2274}
2275
2276impl TryFrom<&str> for TensorMemory {
2277    type Error = Error;
2278
2279    /// Parses [`TensorMemory::as_str`]'s spelling, plus the legacy `"dma"`
2280    /// alias for `DmaBuf`.
2281    fn try_from(s: &str) -> Result<Self> {
2282        TensorMemory::from_str_code(s)
2283            .or(if s == "dma" {
2284                Some(TensorMemory::DmaBuf)
2285            } else {
2286                None
2287            })
2288            .ok_or_else(|| Error::InvalidMemoryType(s.to_owned()))
2289    }
2290}
2291
2292// Everything from here through the end of `impl<T> TensorTrait<T> for
2293// Tensor<T>` below is the `static` backend's storage-owning `Tensor<T>`:
2294// `TensorStorage<T>` (the per-memory-backend enum) and the `Tensor<T>`
2295// struct that wraps it. `dynamic`'s `Tensor<T>` is a different, much
2296// smaller type -- a typed lens over `TensorDyn` with no storage of its
2297// own -- defined in `tensor_dyn/dynamic_tensor.rs` and re-exported below.
2298#[cfg(feature = "static")]
2299#[derive(Debug)]
2300#[allow(dead_code)] // Variants are constructed by downstream crates via pub(crate) helpers
2301pub(crate) enum TensorStorage<T>
2302where
2303    T: Num + Clone + fmt::Debug + Send + Sync,
2304{
2305    /// Platform-native zero-copy GPU buffer. Inner type differs per
2306    /// target: `DmaTensor` on Linux (DMA-BUF fd), `IoSurfaceTensor` on
2307    /// macOS (CFRetained IOSurface). The shared variant name keeps the
2308    /// public `TensorMemory::DmaBuf` discriminant stable across platforms.
2309    #[cfg(target_os = "linux")]
2310    Dma(DmaTensor<T>),
2311    #[cfg(any(target_os = "macos", target_os = "ios"))]
2312    Dma(IoSurfaceTensor<T>),
2313    #[cfg(target_os = "android")]
2314    Dma(AHardwareBufferTensor<T>),
2315    #[cfg(target_os = "windows")]
2316    Dma(crate::d3d11::texture::D3d11TextureTensor<T>),
2317    #[cfg(unix)]
2318    Shm(ShmTensor<T>),
2319    Mem(MemTensor<T>),
2320    Pbo(PboTensor<T>),
2321}
2322
2323#[cfg(feature = "static")]
2324impl<T> TensorStorage<T>
2325where
2326    T: Num + Clone + fmt::Debug + Send + Sync,
2327{
2328    /// The backing allocation's intrinsic physical row pitch in bytes, if it
2329    /// has one that is fixed independent of the logical shape. macOS IOSurface
2330    /// reports its 64-aligned `bytesPerRow`; other backings (Linux DMA, SHM,
2331    /// Mem, PBO) have no fixed pitch beyond the logical shape and return `None`.
2332    ///
2333    /// Used by `configure_image` to preserve the physical pitch when a reused
2334    /// pool tensor is reconfigured to a smaller logical image — so the decode
2335    /// writes rows at the surface's real stride and the GPU samples them with
2336    /// the same stride (the physical-grid / logical-ROI decoupling).
2337    ///
2338    /// Compiled only where such a backing exists; on Linux and other unix
2339    /// targets it would always answer `None`.
2340    #[cfg(any(
2341        target_os = "macos",
2342        target_os = "ios",
2343        target_os = "android",
2344        target_os = "windows"
2345    ))]
2346    pub(crate) fn backing_row_stride(&self) -> Option<usize> {
2347        match self {
2348            // Only genuine image-formatted IOSurfaces (height > 1) carry a real
2349            // per-row pitch; a generic byte-bag (height == 1) returns `None` so
2350            // `configure_image` does not adopt its whole-buffer "row" as a stride.
2351            #[cfg(any(target_os = "macos", target_os = "ios"))]
2352            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2353            // Android AHardwareBuffer: same rule — only genuine 2D
2354            // image-formatted buffers (height > 1) carry a real row pitch.
2355            #[cfg(target_os = "android")]
2356            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2357            // Windows D3D11: the driver's staging row pitch. `None` when the
2358            // tensor has no staging (write-only or hardware-only), whose host
2359            // buffer has tight rows -- except for a semi-planar tensor, whose
2360            // texture is allocated as wide as that pitch for every access, so
2361            // it always has one to report.
2362            #[cfg(target_os = "windows")]
2363            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2364            _ => None,
2365        }
2366    }
2367
2368    /// Create a new tensor storage with the given shape, memory type, and
2369    /// optional name. If no name is given, a random name will be generated.
2370    /// If no memory type is given, the best available memory type will be
2371    /// chosen based on the platform and environment variables.
2372    fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2373        match memory {
2374            #[cfg(target_os = "linux")]
2375            Some(TensorMemory::DmaBuf) => {
2376                DmaTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2377            }
2378            #[cfg(any(target_os = "macos", target_os = "ios"))]
2379            Some(TensorMemory::DmaBuf) => {
2380                IoSurfaceTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2381            }
2382            #[cfg(target_os = "android")]
2383            Some(TensorMemory::DmaBuf) => {
2384                AHardwareBufferTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2385            }
2386            // Windows has a DmaBuf backing, but it is a D3D11 texture: an image
2387            // layout, not a byte bag, so a shape alone cannot describe one.
2388            // Name the constructor that can, rather than quietly handing back
2389            // host memory the caller did not ask for.
2390            #[cfg(target_os = "windows")]
2391            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2392                "D3D11 texture tensors are image-formatted; use Tensor::image with \
2393                 TensorMemory::DmaBuf"
2394                    .to_owned(),
2395            )),
2396            #[cfg(not(any(
2397                target_os = "linux",
2398                target_os = "macos",
2399                target_os = "ios",
2400                target_os = "android",
2401                target_os = "windows"
2402            )))]
2403            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2404                "TensorMemory::DmaBuf is only available on Linux (DMA-BUF), macOS/iOS (IOSurface), \
2405                 Android (AHardwareBuffer), and Windows (D3D11 textures, via Tensor::image)"
2406                    .to_owned(),
2407            )),
2408            #[cfg(unix)]
2409            Some(TensorMemory::Shm) => {
2410                ShmTensor::<T>::new(shape, name).map(TensorStorage::Shm)
2411            }
2412            #[cfg(not(unix))]
2413            Some(m @ TensorMemory::Shm) => Err(m.unsupported_here()),
2414            Some(TensorMemory::Mem) => {
2415                MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2416            }
2417            Some(TensorMemory::Pbo) => Err(crate::error::Error::NotImplemented(
2418                "PboTensor cannot be created via Tensor::new() — use ImageProcessor::create_image()".to_owned(),
2419            )),
2420            // Defined codes with no backing behind them yet. An error, not
2421            // an `unreachable!()` -- see `unsupported_here`.
2422            Some(m @ (TensorMemory::IoSurface | TensorMemory::Cuda)) => Err(m.unsupported_here()),
2423            None => {
2424                if std::env::var("EDGEFIRST_TENSOR_FORCE_MEM")
2425                    .is_ok_and(|x| x != "0" && x.to_lowercase() != "false")
2426                {
2427                    MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2428                } else {
2429                    // Auto-select priority: Dma > Mem. Shm is intentionally NOT
2430                    // auto-selected — it offers no advantage over Mem for an
2431                    // in-process tensor and Mem always succeeds, so Shm below Mem
2432                    // is effectively never reached. Request Shm explicitly via
2433                    // `TensorMemory::Shm` when cross-process sharing is needed.
2434                    // (PBO sits between Dma and Mem but is GL-backed and created
2435                    // only via `ImageProcessor::create_image`.)
2436                    #[cfg(target_os = "linux")]
2437                    {
2438                        // Linux: Try DMA -> Mem
2439                        match DmaTensor::<T>::new(shape, name) {
2440                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2441                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2442                        }
2443                    }
2444                    #[cfg(any(target_os = "macos", target_os = "ios"))]
2445                    {
2446                        // macOS/iOS: Try IOSurface -> Mem. IOSurface is the
2447                        // GPU-shareable backend (zero-copy via ANGLE), filling the
2448                        // same role as DMA-BUF on Linux.
2449                        match IoSurfaceTensor::<T>::new(shape, name) {
2450                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2451                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2452                        }
2453                    }
2454                    #[cfg(target_os = "android")]
2455                    {
2456                        // Android: Mem. Unlike macOS (where a byte-bag
2457                        // IOSurface is still GL-importable as R8) a generic
2458                        // BLOB AHardwareBuffer cannot back a GPU texture, so
2459                        // auto-selecting it buys nothing and costs plenty: a
2460                        // gralloc allocator-HAL ioctl per allocation (orders
2461                        // slower than malloc, ≥1 page + a dmabuf fd even for
2462                        // tiny tensors) and a lock/unlock cache-maintenance
2463                        // round trip on every map(). Zero-copy image tensors
2464                        // come from `Tensor::image(..)`; callers that want a
2465                        // BLOB (NNAPI handoff) request `TensorMemory::DmaBuf`
2466                        // explicitly.
2467                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2468                    }
2469                    #[cfg(all(
2470                        unix,
2471                        not(any(
2472                            target_os = "linux",
2473                            target_os = "macos",
2474                            target_os = "ios",
2475                            target_os = "android"
2476                        ))
2477                    ))]
2478                    {
2479                        // Other Unix (BSD): Mem only (no DMA; Shm is explicit-only)
2480                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2481                    }
2482                    #[cfg(not(unix))]
2483                    {
2484                        // Windows/other: Mem only
2485                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2486                    }
2487                }
2488            }
2489        }
2490    }
2491
2492    /// Create a DMA-backed tensor storage with an explicit byte size that
2493    /// may exceed `shape.product() * sizeof(T)`. Used for image tensors
2494    /// with row-padded layouts (see `DmaTensor::new_with_byte_size`).
2495    ///
2496    /// This is intentionally DMA-only: padding is only meaningful for
2497    /// buffers that will be imported as GPU textures via EGLImage. PBO,
2498    /// Shm, and Mem storage doesn't benefit from pitch alignment and
2499    /// shouldn't pay the memory cost.
2500    /// With `contiguous`, the memory comes from the CMA heap only (see
2501    /// [`ImageDesc::with_contiguous`]).
2502    #[cfg(target_os = "linux")]
2503    pub(crate) fn new_dma_with_byte_size(
2504        shape: &[usize],
2505        byte_size: usize,
2506        name: Option<&str>,
2507        contiguous: bool,
2508    ) -> Result<Self> {
2509        DmaTensor::<T>::new_with_byte_size(shape, byte_size, name, contiguous)
2510            .map(TensorStorage::Dma)
2511    }
2512
2513    // No non-Linux stub: the only caller (`Tensor::image_with_stride`)
2514    // returns `NotImplemented` directly on non-Linux without ever
2515    // reaching the storage layer, so defining a stub here would be
2516    // dead code and fail the `-D warnings` clippy gate on macOS CI.
2517
2518    /// Create a Mem-backed tensor storage with an explicit byte size that may
2519    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2520    /// 64-byte-aligned row strides (see `MemTensor::with_capacity_bytes`).
2521    pub(crate) fn new_mem_with_byte_size(
2522        shape: &[usize],
2523        byte_size: usize,
2524        name: Option<&str>,
2525    ) -> Result<Self>
2526    where
2527        T: 'static,
2528    {
2529        MemTensor::<T>::with_capacity_bytes(shape, byte_size, name).map(TensorStorage::Mem)
2530    }
2531
2532    /// Create a Shm-backed tensor storage with an explicit byte size that may
2533    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2534    /// 64-byte-aligned row strides (see `ShmTensor::new_with_byte_size`).
2535    #[cfg(unix)]
2536    pub(crate) fn new_shm_with_byte_size(
2537        shape: &[usize],
2538        byte_size: usize,
2539        name: Option<&str>,
2540    ) -> Result<Self> {
2541        ShmTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Shm)
2542    }
2543
2544    /// Allocate an image-formatted IOSurface-backed storage (macOS).
2545    ///
2546    /// Used by `Tensor::image()` when the caller requests
2547    /// `TensorMemory::DmaBuf` and the format has an IOSurface FourCC
2548    /// mapping (YUYV, RGBA, BGRA today). Falls back to `new_with_byte_size`
2549    /// otherwise.
2550    #[cfg(any(target_os = "macos", target_os = "ios"))]
2551    pub(crate) fn new_image_iosurface(
2552        width: usize,
2553        height: usize,
2554        format: PixelFormat,
2555        dtype: DType,
2556        shape: &[usize],
2557        name: Option<&str>,
2558    ) -> Result<Self> {
2559        IoSurfaceTensor::<T>::new_image(width, height, format, dtype, shape, name)
2560            .map(TensorStorage::Dma)
2561    }
2562
2563    /// Allocate an image-formatted AHardwareBuffer-backed storage (Android).
2564    ///
2565    /// Used by `Tensor::image()` when the caller requests
2566    /// `TensorMemory::DmaBuf` and the format has an AHardwareBuffer format
2567    /// mapping (RGBA8 and the RGBA16F float paths today). Falls back to
2568    /// `new_with_byte_size` otherwise.
2569    #[cfg(target_os = "android")]
2570    pub(crate) fn new_image_ahardwarebuffer(
2571        width: usize,
2572        height: usize,
2573        format: PixelFormat,
2574        dtype: DType,
2575        shape: &[usize],
2576        name: Option<&str>,
2577        access: CpuAccess,
2578    ) -> Result<Self> {
2579        AHardwareBufferTensor::<T>::new_image(width, height, format, dtype, shape, name, access)
2580            .map(TensorStorage::Dma)
2581    }
2582
2583    /// Allocate an image-formatted D3D11 texture storage (Windows).
2584    ///
2585    /// Used by `Tensor::image()` when the caller requests
2586    /// `TensorMemory::DmaBuf`. Unlike the IOSurface and AHardwareBuffer
2587    /// constructors there is no byte-bag fallback: a format with no texture
2588    /// layout is an error, because a texture is the only thing this backing
2589    /// can be.
2590    #[cfg(target_os = "windows")]
2591    pub(crate) fn new_image_d3d11(
2592        width: usize,
2593        height: usize,
2594        format: PixelFormat,
2595        dtype: DType,
2596        shape: &[usize],
2597        name: Option<&str>,
2598        access: CpuAccess,
2599    ) -> Result<Self> {
2600        crate::d3d11::texture::D3d11TextureTensor::<T>::new_image(
2601            width, height, format, dtype, shape, name, access,
2602        )
2603        .map(TensorStorage::Dma)
2604    }
2605
2606    /// Create a new tensor storage using the given file descriptor, shape,
2607    /// and optional name.
2608    #[cfg(unix)]
2609    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2610        #[cfg(target_os = "linux")]
2611        {
2612            use nix::sys::stat::fstat;
2613            use nix::sys::statfs::fstatfs;
2614
2615            let stat = fstat(&fd)?;
2616            let major = major(stat.st_dev);
2617            let minor = minor(stat.st_dev);
2618
2619            if major != 0 {
2620                // Dma and Shm tensors are expected to have major number 0
2621                return Err(Error::UnknownDeviceType(major, minor));
2622            }
2623
2624            // Classify by filesystem magic, never by the st_dev minor.
2625            //
2626            // dma_buf files live on an internal kernel mount (`dma_buf_mnt`,
2627            // created with `kern_mount`) whose superblock draws its device
2628            // number from `get_anon_bdev()` — an IDA allocated first-come,
2629            // first-served during boot, shared with every other anonymous
2630            // pseudo-filesystem (pipefs, sockfs, anon_inodefs, nsfs, bdev,
2631            // tracefs, ...). The minor a DMA-BUF lands on is therefore a
2632            // function of which pseudo-filesystems registered first on that
2633            // kernel build: it moves with kernel config, driver load order,
2634            // initramfs use, and kernel version. It is not part of any ABI.
2635            //
2636            // Observed values for a genuine DMA-BUF: 12 on x86 desktop, 8 on
2637            // the ADIS Verdin. An earlier revision hardcoded `9 | 10` here,
2638            // which silently imported real DMA-BUFs as shared memory
2639            // everywhere else.
2640            //
2641            // The filesystem magic, by contrast, is stable UAPI
2642            // (include/uapi/linux/magic.h). Both branches are identified
2643            // positively; anything else is genuinely unknown and is rejected
2644            // rather than guessed at. Falling back to SHM would "work" — a
2645            // DMA-BUF is mmap-able, and even a pipe imports as a zero-length
2646            // tensor — which is exactly why it must not be the default.
2647            let magic = fs_magic(fstatfs(&fd)?.filesystem_type().0 as i64);
2648
2649            log::debug!(
2650                "Creating tensor from fd: major={major}, minor={minor}, magic={magic:#010x}"
2651            );
2652
2653            match magic {
2654                DMA_BUF_MAGIC => DmaTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Dma),
2655                TMPFS_MAGIC => ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm),
2656                other => Err(Error::UnknownBufferType(other)),
2657            }
2658        }
2659        #[cfg(all(unix, not(target_os = "linux")))]
2660        {
2661            // On macOS/iOS/BSD, always use SHM (no DMA-BUF fd import)
2662            ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm)
2663        }
2664    }
2665}
2666
2667#[cfg(feature = "static")]
2668impl<T> TensorTrait<T> for TensorStorage<T>
2669where
2670    T: Num + Clone + fmt::Debug + Send + Sync,
2671{
2672    fn new(shape: &[usize], name: Option<&str>) -> Result<Self> {
2673        Self::new(shape, None, name)
2674    }
2675
2676    #[cfg(unix)]
2677    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2678        Self::from_fd(fd, shape, name)
2679    }
2680
2681    #[cfg(unix)]
2682    fn clone_fd(&self) -> Result<OwnedFd> {
2683        match self {
2684            #[cfg(any(
2685                target_os = "linux",
2686                target_os = "macos",
2687                target_os = "ios",
2688                target_os = "android"
2689            ))]
2690            TensorStorage::Dma(t) => t.clone_fd(),
2691            TensorStorage::Shm(t) => t.clone_fd(),
2692            TensorStorage::Mem(t) => t.clone_fd(),
2693            TensorStorage::Pbo(t) => t.clone_fd(),
2694        }
2695    }
2696
2697    fn memory(&self) -> TensorMemory {
2698        match self {
2699            #[cfg(any(
2700                target_os = "linux",
2701                target_os = "macos",
2702                target_os = "ios",
2703                target_os = "android",
2704                target_os = "windows"
2705            ))]
2706            TensorStorage::Dma(_) => TensorMemory::DmaBuf,
2707            #[cfg(unix)]
2708            TensorStorage::Shm(_) => TensorMemory::Shm,
2709            TensorStorage::Mem(_) => TensorMemory::Mem,
2710            TensorStorage::Pbo(_) => TensorMemory::Pbo,
2711        }
2712    }
2713
2714    fn name(&self) -> String {
2715        match self {
2716            #[cfg(any(
2717                target_os = "linux",
2718                target_os = "macos",
2719                target_os = "ios",
2720                target_os = "android",
2721                target_os = "windows"
2722            ))]
2723            TensorStorage::Dma(t) => t.name(),
2724            #[cfg(unix)]
2725            TensorStorage::Shm(t) => t.name(),
2726            TensorStorage::Mem(t) => t.name(),
2727            TensorStorage::Pbo(t) => t.name(),
2728        }
2729    }
2730
2731    fn shape(&self) -> &[usize] {
2732        match self {
2733            #[cfg(any(
2734                target_os = "linux",
2735                target_os = "macos",
2736                target_os = "ios",
2737                target_os = "android",
2738                target_os = "windows"
2739            ))]
2740            TensorStorage::Dma(t) => t.shape(),
2741            #[cfg(unix)]
2742            TensorStorage::Shm(t) => t.shape(),
2743            TensorStorage::Mem(t) => t.shape(),
2744            TensorStorage::Pbo(t) => t.shape(),
2745        }
2746    }
2747
2748    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
2749        match self {
2750            #[cfg(any(
2751                target_os = "linux",
2752                target_os = "macos",
2753                target_os = "ios",
2754                target_os = "android",
2755                target_os = "windows"
2756            ))]
2757            TensorStorage::Dma(t) => t.reshape(shape),
2758            #[cfg(unix)]
2759            TensorStorage::Shm(t) => t.reshape(shape),
2760            TensorStorage::Mem(t) => t.reshape(shape),
2761            TensorStorage::Pbo(t) => t.reshape(shape),
2762        }
2763    }
2764
2765    fn capacity_bytes(&self) -> usize {
2766        match self {
2767            #[cfg(any(
2768                target_os = "linux",
2769                target_os = "macos",
2770                target_os = "ios",
2771                target_os = "android",
2772                target_os = "windows"
2773            ))]
2774            TensorStorage::Dma(t) => t.capacity_bytes(),
2775            #[cfg(unix)]
2776            TensorStorage::Shm(t) => t.capacity_bytes(),
2777            TensorStorage::Mem(t) => t.capacity_bytes(),
2778            TensorStorage::Pbo(t) => t.capacity_bytes(),
2779        }
2780    }
2781
2782    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
2783        match self {
2784            #[cfg(any(
2785                target_os = "linux",
2786                target_os = "macos",
2787                target_os = "ios",
2788                target_os = "android",
2789                target_os = "windows"
2790            ))]
2791            TensorStorage::Dma(t) => t.set_logical_shape(shape),
2792            #[cfg(unix)]
2793            TensorStorage::Shm(t) => t.set_logical_shape(shape),
2794            TensorStorage::Mem(t) => t.set_logical_shape(shape),
2795            TensorStorage::Pbo(t) => t.set_logical_shape(shape),
2796        }
2797    }
2798
2799    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2800    where
2801        T: 'a,
2802    {
2803        match self {
2804            #[cfg(any(
2805                target_os = "linux",
2806                target_os = "macos",
2807                target_os = "ios",
2808                target_os = "android",
2809                target_os = "windows"
2810            ))]
2811            TensorStorage::Dma(t) => t.map_with(access),
2812            #[cfg(unix)]
2813            TensorStorage::Shm(t) => t.map_with(access),
2814            TensorStorage::Mem(t) => t.map_with(access),
2815            TensorStorage::Pbo(t) => t.map_with(access),
2816        }
2817    }
2818
2819    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2820    where
2821        T: 'a,
2822    {
2823        match self {
2824            #[cfg(any(
2825                target_os = "linux",
2826                target_os = "macos",
2827                target_os = "ios",
2828                target_os = "android",
2829                target_os = "windows"
2830            ))]
2831            TensorStorage::Dma(t) => t.try_map_with(access),
2832            #[cfg(unix)]
2833            TensorStorage::Shm(t) => t.try_map_with(access),
2834            TensorStorage::Mem(t) => t.try_map_with(access),
2835            TensorStorage::Pbo(t) => t.try_map_with(access),
2836        }
2837    }
2838
2839    fn buffer_identity(&self) -> &BufferIdentity {
2840        match self {
2841            #[cfg(any(
2842                target_os = "linux",
2843                target_os = "macos",
2844                target_os = "ios",
2845                target_os = "android",
2846                target_os = "windows"
2847            ))]
2848            TensorStorage::Dma(t) => t.buffer_identity(),
2849            #[cfg(unix)]
2850            TensorStorage::Shm(t) => t.buffer_identity(),
2851            TensorStorage::Mem(t) => t.buffer_identity(),
2852            TensorStorage::Pbo(t) => t.buffer_identity(),
2853        }
2854    }
2855
2856    /// Forward a sub-region view to the active backend, re-wrapping the
2857    /// backend's view (which shares the parent's allocation and
2858    /// [`BufferIdentity`]) back into the matching `TensorStorage` variant. Each
2859    /// backend's `view` is its own `TensorTrait::view` override; this single
2860    /// match is the only per-variant dispatch (`Tensor::subview` calls through
2861    /// here rather than matching the storage itself).
2862    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
2863        match self {
2864            #[cfg(any(
2865                target_os = "linux",
2866                target_os = "macos",
2867                target_os = "ios",
2868                target_os = "android",
2869                target_os = "windows"
2870            ))]
2871            TensorStorage::Dma(t) => t.view(offset_bytes, shape).map(TensorStorage::Dma),
2872            #[cfg(unix)]
2873            TensorStorage::Shm(t) => t.view(offset_bytes, shape).map(TensorStorage::Shm),
2874            TensorStorage::Mem(t) => t.view(offset_bytes, shape).map(TensorStorage::Mem),
2875            TensorStorage::Pbo(t) => t.view(offset_bytes, shape).map(TensorStorage::Pbo),
2876        }
2877    }
2878}
2879
2880/// Multi-backend tensor with optional image format metadata.
2881///
2882/// When `format` is `Some`, this tensor represents an image. Width, height,
2883/// and channels are derived from `shape` + `format`. When `format` is `None`,
2884/// this is a raw tensor (identical to the pre-refactoring behavior).
2885///
2886/// This is the `static` backend's `Tensor<T>` -- it owns its storage. See
2887/// `tensor_dyn/dynamic_tensor.rs` for `dynamic`'s `Tensor<T>`, a typed lens
2888/// with no storage of its own.
2889#[cfg(feature = "static")]
2890#[derive(Debug)]
2891pub struct Tensor<T>
2892where
2893    T: Num + Clone + fmt::Debug + Send + Sync,
2894{
2895    /// CUDA registration for this tensor, if any. Set after creation by
2896    /// the image crate once a PBO is registered with CUDA interop.
2897    ///
2898    /// MUST be declared before `storage`: CUDA must unregister the GL buffer
2899    /// before storage's Drop deletes it (cudaGraphicsUnregisterResource before
2900    /// glDeleteBuffers). Rust drops fields in declaration order.
2901    cuda: Option<crate::cuda::CudaHandle>,
2902    pub(crate) storage: TensorStorage<T>,
2903    format: Option<PixelFormat>,
2904    chroma: Option<Box<Tensor<T>>>,
2905    /// Row stride in bytes for externally allocated buffers with row padding.
2906    /// `None` means tightly packed (stride == width * bytes_per_pixel).
2907    row_stride: Option<usize>,
2908    /// Byte offset within the DMA-BUF where image data starts.
2909    /// `None` means offset 0 (data starts at the beginning of the buffer).
2910    plane_offset: Option<usize>,
2911    /// Quantization metadata for integer-typed tensors. Public access is
2912    /// gated by the `IntegerType` trait — `Tensor<f32>` etc. carry the
2913    /// field for layout uniformity but have no way to read or write it.
2914    pub(crate) quantization: Option<Quantization>,
2915    /// Optional colorimetry metadata. `None` = undefined; never auto-filled.
2916    colorimetry: Option<crate::Colorimetry>,
2917    /// Declared CPU access (see [`CpuAccess`]). Image constructors set it
2918    /// from their `access` parameter; non-image tensors (`new`, imports,
2919    /// numpy) default to `ReadWrite` — they are CPU-centric by nature.
2920    /// `map_with` counts requests beyond this declaration as unplanned.
2921    cpu_access: CpuAccess,
2922    /// Recorded vendor tile-compression scheme — best knowledge from
2923    /// allocation time (see [`Compression`]). `Some` only for Android
2924    /// hardware-only AHardwareBuffers whose allocation requested
2925    /// compression on an eligible format with a recognized vendor. When
2926    /// set, the row-stride accessors describe no meaningful linear
2927    /// layout. `configure_image` preserves it (physical layout, unlike
2928    /// colorimetry); views inherit it.
2929    compression: Option<CompressionScheme>,
2930    /// Parent-image snapshot when this tensor is a [`view`](Self::view)/
2931    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. Lets the GL
2932    /// backend key its import on the parent and render the view as a
2933    /// `glViewport`/`glScissor` ROI. See [`ViewOrigin`].
2934    view_origin: Option<ViewOrigin>,
2935}
2936
2937#[cfg(feature = "static")]
2938impl<T> Tensor<T>
2939where
2940    T: Num + Clone + fmt::Debug + Send + Sync,
2941{
2942    /// Wrap a TensorStorage in a Tensor with no image metadata.
2943    pub(crate) fn wrap(storage: TensorStorage<T>) -> Self {
2944        Self {
2945            storage,
2946            format: None,
2947            chroma: None,
2948            row_stride: None,
2949            plane_offset: None,
2950            quantization: None,
2951            cuda: None,
2952            colorimetry: None,
2953            cpu_access: CpuAccess::ReadWrite,
2954            compression: None,
2955            view_origin: None,
2956        }
2957    }
2958
2959    /// Construct a tensor from a row-major element slice + shape. Allocates a
2960    /// new buffer (`TensorMemory::Mem`) and memcpys the contents; caller
2961    /// retains ownership of the input slice.
2962    ///
2963    /// # Errors
2964    ///
2965    /// - [`Error::InvalidShape`] if `values.len() != shape.iter().product()`.
2966    /// - Propagates any allocation error from [`Self::new`].
2967    pub fn from_slice(values: &[T], shape: &[usize]) -> Result<Self>
2968    where
2969        T: Copy,
2970    {
2971        let expected: usize = shape.iter().product();
2972        if values.len() != expected {
2973            return Err(Error::InvalidShape(format!(
2974                "from_slice: values.len()={} but shape product={expected} (shape={shape:?})",
2975                values.len()
2976            )));
2977        }
2978        let t = Self::new(shape, Some(TensorMemory::Mem), None)?;
2979        {
2980            let mut m = t.map()?;
2981            m.as_mut_slice().copy_from_slice(values);
2982        }
2983        Ok(t)
2984    }
2985
2986    /// Wrap externally-owned memory as a tensor without copying. The tensor
2987    /// borrows `[ptr, ptr + shape.product() * size_of::<T>())` as
2988    /// [`TensorMemory::Mem`]; `owner`, when `Some`, co-owns the source so it
2989    /// outlives the tensor (and all derived views/maps). See [`ForeignOwner`].
2990    ///
2991    /// The canonical use is CUDA zero-copy: allocate host-coherent memory
2992    /// (`cudaHostAlloc`), wrap the host pointer here, and bind the matching
2993    /// device pointer to the inference engine — reads and writes hit the same
2994    /// physical buffer with no host copy. The identical primitive backs the
2995    /// Python `Tensor.from_numpy` zero-copy borrow (owner = the NumPy object).
2996    ///
2997    /// # Safety
2998    ///
2999    /// `ptr` must be non-null, aligned to `align_of::<T>()`, and valid for
3000    /// `shape.product()` elements of `T` for as long as the returned tensor —
3001    /// and every view/map sharing its backing — is alive. Pass an `owner` that
3002    /// co-owns the source to uphold that contract.
3003    ///
3004    /// # Errors
3005    ///
3006    /// [`Error::InvalidSize`] if `shape` is empty.
3007    pub unsafe fn from_foreign(
3008        ptr: *mut T,
3009        shape: &[usize],
3010        owner: Option<crate::ForeignOwner>,
3011        name: Option<&str>,
3012    ) -> Result<Self> {
3013        // SAFETY: forwarded to `from_foreign_with_capacity`'s contract; `0`
3014        // clamps to the tight shape-derived footprint below, i.e. no extra
3015        // headroom -- identical to this function's own prior behaviour.
3016        unsafe { Self::from_foreign_with_capacity(ptr, shape, 0, owner, name) }
3017    }
3018
3019    /// [`Self::from_foreign`], additionally recording that the allocation
3020    /// behind `ptr` holds at least `capacity_bytes` bytes -- headroom beyond
3021    /// the tight `shape.product() * size_of::<T>()` footprint that a later
3022    /// [`configure_image`](Self::configure_image) / `set_logical_shape` can
3023    /// grow into without erroring.
3024    ///
3025    /// This is the consumer half of the cross-package capsule protocol's
3026    /// [`crate::protocol::TensorDesc::capacity`]: a producer's pool tensor
3027    /// (allocated for its largest expected image, holding a smaller one
3028    /// today) reports its real allocation size so the imported alias
3029    /// inherits the same headroom instead of being clamped to today's
3030    /// logical shape. `capacity_bytes` below the tight footprint is clamped
3031    /// up to it, never down -- the shape-implied floor always holds.
3032    ///
3033    /// # Safety
3034    ///
3035    /// Same as [`Self::from_foreign`], except `ptr` must be valid for
3036    /// `capacity_bytes` bytes (or the tight shape footprint, whichever is
3037    /// larger), not just the shape-implied footprint.
3038    ///
3039    /// # Errors
3040    ///
3041    /// [`Error::InvalidSize`] if `shape` is empty.
3042    pub unsafe fn from_foreign_with_capacity(
3043        ptr: *mut T,
3044        shape: &[usize],
3045        capacity_bytes: usize,
3046        owner: Option<crate::ForeignOwner>,
3047        name: Option<&str>,
3048    ) -> Result<Self> {
3049        if shape.is_empty() {
3050            return Err(Error::InvalidSize(0));
3051        }
3052        if ptr.is_null() {
3053            return Err(Error::InvalidArgument(
3054                "from_foreign: ptr must be non-null".to_owned(),
3055            ));
3056        }
3057        shape
3058            .iter()
3059            .copied()
3060            .try_fold(1usize, |acc, dim| acc.checked_mul(dim))
3061            .ok_or_else(|| {
3062                Error::InvalidArgument(format!(
3063                    "from_foreign: shape.product() overflows usize (shape={shape:?})"
3064                ))
3065            })?;
3066        let capacity_elems = capacity_bytes / std::mem::size_of::<T>();
3067        // SAFETY: this function's own contract (see its doc comment) is
3068        // exactly `MemTensor::from_foreign_with_capacity`'s contract.
3069        let mem = unsafe {
3070            MemTensor::<T>::from_foreign_with_capacity(ptr, shape, capacity_elems, owner, name)
3071        };
3072        Ok(Self::wrap(TensorStorage::Mem(mem)))
3073    }
3074
3075    /// Pin a stable host address for this tensor's data.
3076    ///
3077    /// The returned [`HostPin`] carries **no borrow of the tensor**, so a
3078    /// caller can pin once at init and still pass `&mut self` to
3079    /// `ImageProcessor::convert` every frame. That borrow conflict is the
3080    /// blocker reported in [#134](https://github.com/EdgeFirstAI/hal/issues/134).
3081    ///
3082    /// The address stays valid until the pin is dropped, and survives
3083    /// re-layout: `configure_image` and `set_logical_shape` only rewrite the
3084    /// logical shape after a capacity check, and every backend's pin owns its
3085    /// own mapping, so nothing can move underneath it.
3086    ///
3087    /// A pin is therefore a **snapshot**, not a tracker: [`HostPin::len`]
3088    /// reports the extent at pin time and does not follow a later re-layout.
3089    /// That is the safe direction — the window always stays inside the
3090    /// allocation — but it means a caller who re-layouts should re-pin if it
3091    /// wants the new extent.
3092    ///
3093    /// The address is **not** a coherency guarantee: on non-coherent backends
3094    /// bracket CPU access with
3095    /// [`sync_for_cpu`](Self::sync_for_cpu) and
3096    /// [`sync_for_device`](Self::sync_for_device), which are no-ops where the
3097    /// backend does not require them.
3098    ///
3099    /// # Cost
3100    ///
3101    /// On `Mem` this is an `Arc` refcount bump — the backing allocation is
3102    /// never resized, so its base pointer is already stable. Other backends
3103    /// establish a mapping once and reuse it.
3104    ///
3105    /// # Errors
3106    ///
3107    /// - [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`]: a
3108    ///   pinned mapping is CPU access by definition, so this is an error
3109    ///   rather than a warn-and-stage.
3110    /// - [`Error::NotImplemented`] for the two backends that **cannot** pin,
3111    ///   rather than merely not having been wired yet: a PBO
3112    ///   (`glMapBufferRange` establishes the address and the visibility
3113    ///   together, and there is no `GL_MAP_PERSISTENT_BIT` in GLES) and an
3114    ///   AHardwareBuffer (`AHardwareBuffer_lock` is what yields the address at
3115    ///   all, so there is no address outside the lock). Both name
3116    ///   `map()`/`map_with()` as the alternative. `Mem`, `Shm`, DMA-BUF,
3117    ///   IOSurface and the Windows D3D11 texture all pin — the texture through
3118    ///   a persistent host buffer the storage owns, which
3119    ///   [`sync_for_cpu`](Self::sync_for_cpu) and
3120    ///   [`sync_for_device`](Self::sync_for_device) move bytes between and the
3121    ///   texture, rather than through the staging mapping (a pin outlives every
3122    ///   map guard, so holding the staging texture mapped would freeze it).
3123    pub fn pin_host<'a>(&self, access: CpuAccess) -> Result<HostPin<'a>>
3124    where
3125        T: 'a,
3126    {
3127        if access == CpuAccess::None {
3128            return Err(Error::InvalidArgument(
3129                "pin_host: CpuAccess::None is not a valid pin; a pinned mapping \
3130                 is CPU access by definition"
3131                    .to_owned(),
3132            ));
3133        }
3134        // Backends yield everything addressable from the tensor's offset;
3135        // narrow to the logical extent so a consumer cannot mistake pitch
3136        // padding for data. A map guard keeps the wider window.
3137        let logical = self.len() * std::mem::size_of::<T>();
3138        let pin = match &self.storage {
3139            TensorStorage::Mem(m) => Ok(m.host_pin()),
3140            #[cfg(unix)]
3141            TensorStorage::Shm(s) => s.host_pin(),
3142            #[cfg(target_os = "linux")]
3143            TensorStorage::Dma(d) => d.host_pin(),
3144            #[cfg(any(target_os = "macos", target_os = "ios"))]
3145            TensorStorage::Dma(s) => s.host_pin(),
3146            // Windows D3D11: the pin is the storage's own persistent host
3147            // buffer, not the staging mapping -- see `D3d11TextureTensor::host_pin`.
3148            // The direction goes with it: a writable pin is what tells
3149            // `sync_for_device` the host buffer, not the staging copy, carries
3150            // the caller's writes.
3151            #[cfg(target_os = "windows")]
3152            TensorStorage::Dma(t) => t.host_pin(access),
3153            // AHardwareBuffer cannot support a pin at all, and that is a
3154            // platform contract rather than missing work: the NDK exposes no
3155            // way to obtain a CPU address outside AHardwareBuffer_lock, so a
3156            // "persistent" address would mean holding the lock for the pin's
3157            // lifetime -- which blocks the GPU from the buffer and makes any
3158            // further lock undefined. Both are the opposite of what a pin is
3159            // for. Say so, rather than implying a TODO.
3160            #[cfg(target_os = "android")]
3161            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3162                "pin_host: AHardwareBuffer has no host address outside \
3163                 AHardwareBuffer_lock, so it cannot hand out one that outlives \
3164                 a guard. Use map()/map_with() for CPU access; for zero-copy \
3165                 handoff pass the buffer itself."
3166                    .to_owned(),
3167            )),
3168            // PBO is the same shape of constraint as AHardwareBuffer. The
3169            // message is a shared constant because `TensorDyn::pin_host`
3170            // (`tensor_dyn/dynamic_backend.rs`) must repeat this refusal --
3171            // the ABI has `ef_tensor_map` and no `ef_tensor_pin_host`, so the
3172            // library cannot tell a pin from a map. See
3173            // `crate::pbo::PIN_HOST_PBO_REFUSAL` for the reasoning at length.
3174            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3175                crate::pbo::PIN_HOST_PBO_REFUSAL.to_owned(),
3176            )),
3177        }?;
3178        Ok(pin.narrowed(logical))
3179    }
3180
3181    /// Make CPU reads/writes of this tensor coherent — the CPU is about to
3182    /// access the buffer.
3183    ///
3184    /// Corresponds to `DMA_BUF_SYNC_START`, and the name matches the Linux
3185    /// kernel DMA API for the same reason: `for_cpu` says who is about to
3186    /// touch the memory, which is what the caller actually knows.
3187    ///
3188    /// Pair with [`sync_for_device`](Self::sync_for_device).
3189    ///
3190    /// # Which backends owe maintenance
3191    ///
3192    /// | backend | behaviour |
3193    /// |---|---|
3194    /// | `Mem`, `Shm` | **no-op** — plain CPU memory, coherent by construction |
3195    /// | `Dma` (Linux DMA-BUF) | one `DMA_BUF_IOCTL_SYNC`, direction-tagged |
3196    /// | `Dma` (macOS/iOS IOSurface) | `IOSurfaceLock` / `IOSurfaceUnlock`, options matched to `access` |
3197    /// | `Dma` (Android AHardwareBuffer) | [`Error::NotImplemented`] — no sync independent of the lock |
3198    /// | `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 |
3199    /// | `Pbo` | [`Error::NotImplemented`] — no coherency window independent of the map |
3200    ///
3201    /// Only the first row is a no-op, so "bracket unconditionally and pay
3202    /// nothing" holds for `Mem`/`Shm` alone. The two that report
3203    /// `NotImplemented` do so because their platform fuses addressing and
3204    /// coherency into one call — `AHardwareBuffer_lock` and
3205    /// `glMapBufferRange` each perform the maintenance *as* they hand out the
3206    /// address, so a separate bracket would have to nest inside a map guard
3207    /// that already owns the pair. Both name `map()`/`map_with()` as the
3208    /// alternative. They previously fell through a wildcard and answered
3209    /// `Ok(())`, which is worse than an error because the symptom is
3210    /// stale-but-plausible pixels rather than a failure. The match is
3211    /// exhaustive so a new backend cannot inherit that silence — it must state
3212    /// its coherency story to compile.
3213    ///
3214    /// # Cost
3215    ///
3216    /// `Mem`/`Shm`: nothing. `Dma` on Linux: one ioctl. IOSurface: one
3217    /// lock/unlock pair. Windows D3D11 texture: one `CopyResource` plus a
3218    /// staging map per direction, and nothing at all on a tensor no one has
3219    /// pinned.
3220    ///
3221    /// # Errors
3222    ///
3223    /// [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`], which
3224    /// describes no access to make coherent.
3225    pub fn sync_for_cpu(&self, access: CpuAccess) -> Result<()> {
3226        Self::check_sync_access(access)?;
3227        match &self.storage {
3228            // Plain CPU memory: coherent by construction.
3229            TensorStorage::Mem(_) => Ok(()),
3230            #[cfg(unix)]
3231            TensorStorage::Shm(_) => Ok(()),
3232            #[cfg(target_os = "linux")]
3233            TensorStorage::Dma(d) => {
3234                // The direction tells the kernel which maintenance this access
3235                // needs: a read-only bracket skips the writeback at END, a
3236                // write-only one skips the invalidate at START. It MUST match
3237                // the direction passed to sync_for_device.
3238                crate::dmabuf::sync_access(&d.fd, true, access).map_err(Error::NixError)
3239            }
3240            #[cfg(any(target_os = "macos", target_os = "ios"))]
3241            TensorStorage::Dma(s) => s.sync_for_cpu(access),
3242            // Windows D3D11: refreshes the pinned host buffer from the texture
3243            // through the staging copy.
3244            #[cfg(target_os = "windows")]
3245            TensorStorage::Dma(t) => t.sync_for_cpu(access),
3246            #[cfg(target_os = "android")]
3247            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3248                "sync_for_cpu: AHardwareBuffer has no sync independent of its \
3249                 lock. AHardwareBuffer_lock both establishes the address and \
3250                 performs the cache invalidate, and the NDK leaves locking an \
3251                 already-locked buffer undefined, so a bracket on top of a map \
3252                 guard would nest. Use map()/map_with(), which owns the pair."
3253                    .to_owned(),
3254            )),
3255            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3256                "sync_for_cpu: a PBO has no coherency window independent of its \
3257                 map -- glMapBufferRange establishes the address and the \
3258                 visibility together, and glUnmapBuffer publishes the writes. \
3259                 Use map()/map_with(), which owns the pair."
3260                    .to_owned(),
3261            )),
3262        }
3263    }
3264
3265    /// Release the buffer back to the device — the CPU is done accessing it.
3266    ///
3267    /// Corresponds to `DMA_BUF_SYNC_END`. See
3268    /// [`sync_for_cpu`](Self::sync_for_cpu) for the pairing and cost.
3269    pub fn sync_for_device(&self, access: CpuAccess) -> Result<()> {
3270        Self::check_sync_access(access)?;
3271        match &self.storage {
3272            TensorStorage::Mem(_) => Ok(()),
3273            #[cfg(unix)]
3274            TensorStorage::Shm(_) => Ok(()),
3275            #[cfg(target_os = "linux")]
3276            TensorStorage::Dma(d) => {
3277                crate::dmabuf::sync_access(&d.fd, false, access).map_err(Error::NixError)
3278            }
3279            #[cfg(any(target_os = "macos", target_os = "ios"))]
3280            TensorStorage::Dma(s) => s.sync_for_device(access),
3281            // Windows D3D11: uploads the pinned host buffer to the texture.
3282            #[cfg(target_os = "windows")]
3283            TensorStorage::Dma(t) => t.sync_for_device(access),
3284            #[cfg(target_os = "android")]
3285            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3286                "sync_for_device: AHardwareBuffer has no sync independent of \
3287                 its lock — see sync_for_cpu. Use map()/map_with()."
3288                    .to_owned(),
3289            )),
3290            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3291                "sync_for_device: a PBO has no coherency window independent of \
3292                 its map -- see sync_for_cpu. Use map()/map_with()."
3293                    .to_owned(),
3294            )),
3295        }
3296    }
3297
3298    fn check_sync_access(access: CpuAccess) -> Result<()> {
3299        if access == CpuAccess::None {
3300            return Err(Error::InvalidArgument(
3301                "sync_for_cpu/sync_for_device: CpuAccess::None describes no \
3302                 access to synchronise"
3303                    .to_owned(),
3304            ));
3305        }
3306        Ok(())
3307    }
3308
3309    /// Construct a tensor from a 3-D ndarray view. Respects strides — one
3310    /// copy in all cases; contiguous views take a memcpy fast path.
3311    ///
3312    /// Only available when the `ndarray` feature is enabled.
3313    #[cfg(feature = "ndarray")]
3314    pub fn from_arrayview3(view: ndarray::ArrayView3<'_, T>) -> Result<Self>
3315    where
3316        T: Copy,
3317    {
3318        let (h, w, c) = view.dim();
3319        let t = Self::new(&[h, w, c], Some(TensorMemory::Mem), None)?;
3320        {
3321            let mut m = t.map()?;
3322            let dst = m.as_mut_slice();
3323            if let Some(src) = view.as_slice() {
3324                dst.copy_from_slice(src);
3325            } else {
3326                for (d, &s) in dst.iter_mut().zip(view.iter()) {
3327                    *d = s;
3328                }
3329            }
3330        }
3331        Ok(t)
3332    }
3333
3334    /// Create a new tensor with the given shape, memory type, and optional
3335    /// name. If no name is given, a random name will be generated. If no
3336    /// memory type is given, the best available memory type will be chosen
3337    /// based on the platform and environment variables.
3338    ///
3339    /// On Linux platforms, the order of preference is: Dma -> Shm -> Mem.
3340    /// On other Unix platforms (macOS), the order is: Shm -> Mem.
3341    /// On non-Unix platforms, only Mem is available.
3342    ///
3343    /// # Environment Variables
3344    /// - `EDGEFIRST_TENSOR_FORCE_MEM`: If set to a non-zero and non-false
3345    ///   value, forces the use of regular system memory allocation
3346    ///   (`TensorMemory::Mem`) regardless of platform capabilities.
3347    ///
3348    /// # Example
3349    /// ```rust
3350    /// use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
3351    /// # fn main() -> Result<(), Error> {
3352    /// let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
3353    /// assert_eq!(tensor.memory(), TensorMemory::Mem);
3354    /// assert_eq!(tensor.name(), "test_tensor");
3355    /// #    Ok(())
3356    /// # }
3357    /// ```
3358    pub fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
3359        let _span = tracing::trace_span!(
3360            "tensor.alloc",
3361            ?shape,
3362            memory = ?memory,
3363            dtype = std::any::type_name::<T>(),
3364        )
3365        .entered();
3366        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
3367        let mut t = TensorStorage::new(shape, memory, name).map(Self::wrap)?;
3368        // Best-effort: attach a CUDA ExternalMemory handle for DMA tensors on
3369        // CUDA-capable hosts. Never blocks tensor creation on failure.
3370        // RUNTIME-UNVALIDATED: no CUDA+dma_heap test platform available; ABI
3371        // layout-asserted vs. CUDA 12.6 driver_types.h; mechanism proven by
3372        // gpu-probe O5 on Orin.
3373        #[cfg(target_os = "linux")]
3374        t.try_init_dma_cuda();
3375        Ok(t)
3376    }
3377
3378    /// Create an image tensor with the given format.
3379    /// Allocate an image tensor from a declarative request — the
3380    /// full-featured constructor behind [`Self::image`] and friends.
3381    ///
3382    /// Adds the [`Compression`] request to the classic parameters:
3383    ///
3384    /// - a request with any CPU access other than [`CpuAccess::None`] is
3385    ///   [`Error::InvalidArgument`] (CPU mapping pins the layout linear);
3386    /// - [`Compression::Scheme`] fails with [`Error::NotImplemented`] on
3387    ///   platforms without vendor tile compression, and with
3388    ///   [`Error::InvalidArgument`] when the device's native scheme or
3389    ///   the format eligibility doesn't match;
3390    /// - [`Compression::Any`] never fails for compression reasons: it
3391    ///   records the scheme when the allocation is eligible and
3392    ///   otherwise resolves linear, incrementing
3393    ///   [`compression_fallback_count`].
3394    ///
3395    /// The recorded outcome is readable via [`Tensor::compression`].
3396    ///
3397    /// A [`ImageDesc::with_contiguous`] request allocates from the CMA heap
3398    /// only, and fails instead of falling back; [`Tensor::contiguity`]
3399    /// reports the heap.
3400    pub fn image_desc(desc: &ImageDesc) -> Result<Self>
3401    where
3402        T: 'static,
3403    {
3404        let Some(t_dtype) = dtype_of::<T>() else {
3405            return Err(Error::InvalidArgument(
3406                "image_desc: element type has no DType mapping".into(),
3407            ));
3408        };
3409        if t_dtype != desc.dtype {
3410            return Err(Error::InvalidArgument(format!(
3411                "image_desc: desc.dtype is {:?} but the tensor element type is {t_dtype:?}",
3412                desc.dtype
3413            )));
3414        }
3415
3416        // A contiguous request is a CMA DMA-BUF or an error: no fallback to
3417        // another heap or another kind of memory.
3418        if desc.contiguous {
3419            #[cfg(not(target_os = "linux"))]
3420            {
3421                return Err(Error::NotImplemented(
3422                    "image_desc: physically contiguous memory needs the Linux CMA DMA heap".into(),
3423                ));
3424            }
3425            #[cfg(target_os = "linux")]
3426            if !matches!(desc.memory, None | Some(TensorMemory::DmaBuf)) {
3427                return Err(Error::InvalidArgument(format!(
3428                    "image_desc: physically contiguous memory is DMA-BUF from the CMA heap, \
3429                     but {:?} was requested",
3430                    desc.memory
3431                )));
3432            }
3433        }
3434
3435        // Compression-request guards. CPU access pins the layout linear,
3436        // so a request combined with any declared access is a
3437        // contradiction the caller should hear about immediately.
3438        if desc.compression.is_some() && desc.access != CpuAccess::None {
3439            return Err(Error::InvalidArgument(format!(
3440                "image_desc: a compression request requires CpuAccess::None                  (declared {:?}) — CPU mapping pins the layout linear",
3441                desc.access
3442            )));
3443        }
3444        if let Some(Compression::Scheme(requested)) = desc.compression {
3445            #[cfg(not(target_os = "android"))]
3446            {
3447                return Err(Error::NotImplemented(format!(
3448                    "image_desc: Compression::Scheme({requested:?}) — no vendor tile                      compression on this platform (request Compression::Any for a                      portable fallback)"
3449                )));
3450            }
3451            #[cfg(target_os = "android")]
3452            {
3453                if matches!(
3454                    desc.memory,
3455                    Some(TensorMemory::Mem) | Some(TensorMemory::Shm) | Some(TensorMemory::Pbo)
3456                ) {
3457                    return Err(Error::InvalidArgument(format!(
3458                        "image_desc: Compression::Scheme({requested:?}) requires                          hardware memory (TensorMemory::DmaBuf or auto-select), got {:?}",
3459                        desc.memory
3460                    )));
3461                }
3462                if !crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype) {
3463                    return Err(Error::InvalidArgument(format!(
3464                        "image_desc: ({:?}, {:?}) is not compression-eligible                          (RGBA8888 u8/i8 initially)",
3465                        desc.format, desc.dtype
3466                    )));
3467                }
3468                let device = crate::ahardwarebuffer::device_compression_scheme();
3469                if device != Some(requested) {
3470                    return Err(Error::InvalidArgument(format!(
3471                        "image_desc: Compression::Scheme({requested:?}) but the device's                          native scheme is {device:?}"
3472                    )));
3473                }
3474            }
3475        }
3476
3477        // A compression request implies a hardware pipeline, so auto
3478        // memory promotes to the platform's zero-copy allocation first
3479        // (`Tensor::image` only takes the AHardwareBuffer/IOSurface path
3480        // under an explicit Dma request). `Scheme` propagates the Dma
3481        // failure — the caller demanded a layout only that allocator can
3482        // produce; `Any` falls back to plain auto-select.
3483        #[allow(unused_mut)]
3484        let mut t = if desc.contiguous {
3485            Self::image_impl(
3486                desc.width,
3487                desc.height,
3488                desc.format,
3489                Some(TensorMemory::DmaBuf),
3490                desc.access,
3491                true,
3492            )?
3493        } else {
3494            match (desc.memory, desc.compression) {
3495                (None, Some(request)) => {
3496                    match Self::image(
3497                        desc.width,
3498                        desc.height,
3499                        desc.format,
3500                        Some(TensorMemory::DmaBuf),
3501                        desc.access,
3502                    ) {
3503                        Ok(t) => t,
3504                        Err(e) if matches!(request, Compression::Scheme(_)) => return Err(e),
3505                        Err(_) => {
3506                            Self::image(desc.width, desc.height, desc.format, None, desc.access)?
3507                        }
3508                    }
3509                }
3510                (memory, _) => {
3511                    Self::image(desc.width, desc.height, desc.format, memory, desc.access)?
3512                }
3513            }
3514        };
3515
3516        // Record best knowledge / count fallbacks. Only an Android
3517        // hardware-only AHardwareBuffer allocation can actually hold a
3518        // vendor tile layout; everywhere else an Any request resolves
3519        // linear and is counted.
3520        if let Some(request) = desc.compression {
3521            #[cfg(target_os = "android")]
3522            {
3523                let eligible =
3524                    crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype);
3525                let scheme = crate::ahardwarebuffer::device_compression_scheme();
3526                let is_ahb = t.memory() == TensorMemory::DmaBuf;
3527                match (request, scheme) {
3528                    (_, Some(s)) if eligible && is_ahb => {
3529                        t.set_compression_unchecked(Some(s));
3530                    }
3531                    (Compression::Scheme(requested), _) => {
3532                        // Pre-validated eligible + scheme match, so the only
3533                        // way here is the allocation resolving off-AHB.
3534                        return Err(Error::InvalidOperation(format!(
3535                            "image_desc: Compression::Scheme({requested:?}) requested but                              the allocation resolved to {:?} (not an AHardwareBuffer)",
3536                            t.memory()
3537                        )));
3538                    }
3539                    (Compression::Any, _) => {
3540                        note_compression_fallback(&format!(
3541                            "({:?}, {:?}) {}x{}: eligible={eligible}, scheme={scheme:?},                              memory={:?}",
3542                            desc.format,
3543                            desc.dtype,
3544                            desc.width,
3545                            desc.height,
3546                            t.memory()
3547                        ));
3548                    }
3549                }
3550            }
3551            #[cfg(not(target_os = "android"))]
3552            {
3553                debug_assert!(matches!(request, Compression::Any));
3554                let _ = request;
3555                note_compression_fallback(&format!(
3556                    "({:?}, {:?}) {}x{}: no vendor tile compression on this platform",
3557                    desc.format, desc.dtype, desc.width, desc.height
3558                ));
3559            }
3560        }
3561        Ok(t)
3562    }
3563
3564    /// Allocate an image tensor of `width` × `height` in `format`.
3565    ///
3566    /// This is the common-case image constructor. The shape follows from the
3567    /// format's layout — packed `[H, W, C]`, planar `[C, H, W]`, semi-planar
3568    /// `[H + chroma_rows, W]` — and the format is recorded on the tensor, so
3569    /// the image crate and the codec can work from it without out-of-band
3570    /// parameters.
3571    ///
3572    /// `memory` requests a backend, or `None` to auto-select. Auto-select for
3573    /// images is **DMA → Mem**: unlike [`Tensor::new`] it does not try SHM,
3574    /// which offers an image nothing that Mem doesn't (it is not
3575    /// GPU-importable, and Mem always succeeds). Ask for
3576    /// [`TensorMemory::Shm`] explicitly if you need it.
3577    ///
3578    /// `access` declares what the **CPU** intends to do with the buffer and is
3579    /// required; see [`CpuAccess`]. Hardware access — GPU, NPU, ISP, codec —
3580    /// is always implied and never declared. Prefer the narrowest declaration
3581    /// that fits: `None` keeps the allocation eligible for Android vendor tile
3582    /// compression, and anything else pins the layout linear.
3583    ///
3584    /// DMA-backed images always get a **64-byte-aligned row stride**, in every
3585    /// layout, because Mali and Vivante reject an EGLImage import at an
3586    /// unaligned pitch. Read the real pitch back with
3587    /// [`Tensor::effective_row_stride`] rather than assuming `width × bpp`.
3588    ///
3589    /// For a compression request or any other less common knob, build an
3590    /// [`ImageDesc`] and call [`Tensor::image_desc`].
3591    ///
3592    /// # Errors
3593    ///
3594    /// - [`Error::InvalidArgument`] if `width` × `height` is not a valid size
3595    ///   for `format` (for example odd dimensions where the format's chroma
3596    ///   subsampling forbids them), or, on macOS with an explicit
3597    ///   [`TensorMemory::DmaBuf`], if the format cannot be expressed as an
3598    ///   image-formatted IOSurface at this width. The message names the
3599    ///   aligned width to use.
3600    /// - Whatever the chosen backend returns if the allocation itself fails —
3601    ///   for an explicit `memory` request there is no fallback.
3602    ///
3603    /// # Example
3604    ///
3605    /// ```rust
3606    /// use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
3607    ///
3608    /// # fn main() -> Result<(), edgefirst_tensor::Error> {
3609    /// // A camera frame the GPU converts and the CPU never touches.
3610    /// let frame = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12,
3611    ///                                 Some(TensorMemory::Mem), CpuAccess::None)?;
3612    /// assert_eq!(frame.format(), Some(PixelFormat::Nv12));
3613    /// # Ok(())
3614    /// # }
3615    /// ```
3616    pub fn image(
3617        width: usize,
3618        height: usize,
3619        format: PixelFormat,
3620        memory: Option<TensorMemory>,
3621        access: CpuAccess,
3622    ) -> Result<Self>
3623    where
3624        T: 'static,
3625    {
3626        Self::image_impl(width, height, format, memory, access, false)
3627    }
3628
3629    /// [`Tensor::image`], plus the [`ImageDesc::with_contiguous`] request.
3630    /// `contiguous` is only honoured with `Some(TensorMemory::DmaBuf)` on
3631    /// Linux; [`Tensor::image_desc`] checks that before calling.
3632    fn image_impl(
3633        width: usize,
3634        height: usize,
3635        format: PixelFormat,
3636        memory: Option<TensorMemory>,
3637        access: CpuAccess,
3638        contiguous: bool,
3639    ) -> Result<Self>
3640    where
3641        T: 'static,
3642    {
3643        debug_assert!(!contiguous || memory == Some(TensorMemory::DmaBuf));
3644        #[cfg(not(target_os = "linux"))]
3645        let _ = contiguous;
3646        // Shape comes from the shared `PixelFormat::allocation_shape` helper (packed /
3647        // planar / semi-planar NV12·NV16). NV12 supports odd dimensions via the
3648        // `H + ceil(H/2)` combined-plane height.
3649        // The `T: 'static` bound is required by the macOS IOSurface path below.
3650        let shape = format.allocation_shape(width, height).ok_or_else(|| {
3651            Error::InvalidArgument(format!(
3652                "invalid dimensions {width}x{height} for format {format:?}"
3653            ))
3654        })?;
3655
3656        // macOS Dma path: allocate a format-aware IOSurface (FourCC +
3657        // 2D dimensions) so the GL backend can bind it via
3658        // `EGL_ANGLE_iosurface_client_buffer`. Without this, the IOSurface
3659        // would default to a generic byte buffer (FourCC 'L008') and
3660        // ANGLE would reject the import with `EGL_BAD_ATTRIBUTE`.
3661        //
3662        // Guard: IOSurface rounds `bytes_per_row` up to 64-byte alignment.
3663        // If the natural row pitch (`width * channels * sizeof(T)`) is not
3664        // already 64-byte aligned, the padded allocation cannot be mapped
3665        // as a contiguous packed tensor — CPU reads/writes would use the
3666        // wrong stride.
3667        //
3668        // Explicit-Dma contract: when the caller passes
3669        // `Some(TensorMemory::DmaBuf)` they have asked for an
3670        // **image-formatted IOSurface**. Silently downgrading to the
3671        // generic 'L008' byte-bag when alignment fails buries the
3672        // mismatch — the caller only finds out hours later when ANGLE
3673        // (or any GL importer) rejects the bind with
3674        // `EGL_BAD_ATTRIBUTE`. Same anti-pattern bit us previously on
3675        // Mali GPUs with DMA-BUF padding. The right behaviour is to
3676        // fail loudly here with the alignment requirement spelled out
3677        // so the caller can either pick aligned dimensions, request
3678        // SHM/Mem explicitly, or pass `memory=None` for auto-select.
3679        #[cfg(any(target_os = "macos", target_os = "ios"))]
3680        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3681            // For planar formats the IOSurface stacks channels
3682            // vertically (channels * height rows), so the row stride is
3683            // single-channel width * sizeof(T). Packed formats keep the
3684            // natural width * channels * sizeof(T) stride.
3685            let natural_row_bytes = match format.layout() {
3686                PixelLayout::Planar => width * std::mem::size_of::<T>(),
3687                _ => width * format.channels() * std::mem::size_of::<T>(),
3688            };
3689            // A format with a real IOSurface FourCC (RGBA/BGRA/YUYV packed,
3690            // GREY/NV12/NV16/NV24 as R8) tolerates a non-64-aligned natural
3691            // pitch: the surface is allocated with its own 64-aligned
3692            // `bytes_per_row`, the tensor records that stride below, and a CPU
3693            // map iterates rows correctly via the strided-map path while the GL
3694            // import uses the surface's pitch directly — fully zero-copy.
3695            // Planar (the F16 RGBA16F packing) is consumed flat as
3696            // `[1, C, H, W]` with no stride, so it still requires an aligned
3697            // pitch; and formats without a FourCC would fall through to a
3698            // generic byte-bag GL can't bind. Both fail loudly rather than
3699            // silently downgrade.
3700            let has_image_fourcc = dtype_of::<T>()
3701                .and_then(|dt| crate::iosurface_layout::image_iosurface_layout(format, dt))
3702                .is_some();
3703            let padded_ok = has_image_fourcc && format.layout() != PixelLayout::Planar;
3704            if !natural_row_bytes.is_multiple_of(64) && !padded_ok {
3705                let elem_size = std::mem::size_of::<T>();
3706                let per_pixel_bytes = match format.layout() {
3707                    PixelLayout::Planar => elem_size.max(1),
3708                    _ => format.channels().max(1) * elem_size.max(1),
3709                };
3710                // Compute the next 64-byte-aligned width by rounding the
3711                // natural row pitch up to the next multiple of 64 and
3712                // dividing back by per-pixel bytes. This handles every
3713                // `per_pixel_bytes` value correctly:
3714                //
3715                //   * Divisors of 64 (1/2/4/8/16/32/64) → the suggestion
3716                //     is always 64-byte aligned.
3717                //   * Non-divisors of 64 (e.g. RGB u8 with 3 B/pixel) →
3718                //     the next aligned row pitch may not be an integer
3719                //     multiple of per_pixel_bytes (3 doesn't divide 64
3720                //     in any way), so a "pad width to N" suggestion is
3721                //     structurally impossible — omit the suggestion
3722                //     instead of printing a wrong number.
3723                //   * per_pixel_bytes > 64 → same situation, also
3724                //     omitted; the previous formula divided by zero.
3725                //
3726                // The error always names the alignment requirement
3727                // verbatim and lists the two non-DMA alternatives so
3728                // the caller has at least one always-applicable fix.
3729                let aligned_row_bytes = natural_row_bytes.next_multiple_of(64);
3730                let pad_hint =
3731                    if per_pixel_bytes > 0 && aligned_row_bytes.is_multiple_of(per_pixel_bytes) {
3732                        let w = aligned_row_bytes / per_pixel_bytes;
3733                        format!("Pad width to {w} (the next 64-byte-aligned stride), ")
3734                    } else {
3735                        String::new()
3736                    };
3737                return Err(Error::InvalidArgument(format!(
3738                    "Tensor::image: {format:?} {width}x{height} with element \
3739                     size {elem_size} produces a {natural_row_bytes}-byte natural \
3740                     row pitch, which is not 64-byte aligned. \
3741                     IOSurface rounds bytes_per_row up to 64 bytes, so a \
3742                     contiguous CPU map of this tensor would read garbage. \
3743                     {pad_hint}pass memory=None to auto-fall-back to SHM, or \
3744                     pass memory=Some(TensorMemory::Shm) or \
3745                     Some(TensorMemory::Mem) explicitly."
3746                )));
3747            }
3748            // Alignment OK. Explicit-Dma contract: the caller asked for an
3749            // **image-formatted, GL-importable** IOSurface, so every failure
3750            // from here on is loud. The old behaviour fell through to the
3751            // generic 'L008' byte-bag `Some(other)` arm below, and the caller
3752            // only found out when the GL import rejected the bind with
3753            // `EGL_BAD_ATTRIBUTE` — the same silent-downgrade anti-pattern as
3754            // the alignment case above. (Semi-planar/Grey u8 map to 'L008'
3755            // *by design* in `image_iosurface_layout` — the R8-plane
3756            // representation the YUV shaders sample — so they return through
3757            // the mapped path and never reach these errors.)
3758            let dtype = dtype_of::<T>().ok_or_else(|| {
3759                Error::InvalidArgument(format!(
3760                    "Tensor::image: element type {} has no DType, so no \
3761                     image-formatted IOSurface exists. Pass memory=None or \
3762                     Some(TensorMemory::Mem) for a CPU tensor.",
3763                    std::any::type_name::<T>()
3764                ))
3765            })?;
3766            if crate::iosurface_layout::image_iosurface_layout(format, dtype).is_none() {
3767                return Err(Error::InvalidArgument(format!(
3768                    "Tensor::image: no zero-copy IOSurface mapping exists for \
3769                     {format:?}/{dtype:?} on macOS/iOS (supported: \
3770                     Rgba/Rgb @ U8/I8, Bgra/Yuyv/Grey/Nv12/Nv16/Nv24 @ U8, \
3771                     Rgba/PlanarRgb/PlanarRgba @ F16). Pass memory=None to \
3772                     auto-select, or Some(TensorMemory::Mem) explicitly, for \
3773                     a CPU tensor."
3774                )));
3775            }
3776            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3777            // reject a width the texel packing cannot express up front
3778            // (mirrors the Android pre-guard).
3779            if format == PixelFormat::Rgb
3780                && matches!(dtype, DType::U8 | DType::I8)
3781                && packed_rgb888_layout(width, height).is_none()
3782            {
3783                return Err(Error::InvalidArgument(format!(
3784                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3785                     IOSurface packing (got width={width}). Pad the width, or pass \
3786                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3787                )));
3788            }
3789            let storage = TensorStorage::<T>::new_image_iosurface(
3790                width, height, format, dtype, &shape, None,
3791            )?;
3792            let mut t = Self::wrap(storage);
3793            t.format = Some(format);
3794            // IOSurface rounds `bytes_per_row` up to 64 bytes. When that
3795            // pitch exceeds the natural packed/planar row stride, record
3796            // it so CPU consumers iterate rows correctly (the GL import
3797            // already uses the surface's own pitch). For 64-aligned rows
3798            // — the common model-input case — the two match and no stride
3799            // is stored, leaving the flat mapping unchanged.
3800            if let TensorStorage::Dma(ref io) = t.storage {
3801                let bpr = io.bytes_per_row();
3802                if let Some(natural) = t.effective_row_stride() {
3803                    if bpr > natural {
3804                        t.set_row_stride_unchecked(bpr);
3805                    }
3806                }
3807            }
3808            t.cpu_access = access;
3809            return Ok(t);
3810        }
3811
3812        // Android Dma path: allocate a format-aware AHardwareBuffer so the
3813        // GL backend can import it as an EGLImage
3814        // (`eglGetNativeClientBufferANDROID` → `eglCreateImageKHR`).
3815        //
3816        // Geometry: gralloc chooses the row pitch (`desc.stride`) at
3817        // allocation, and pads freely (validated on the Galaxy S26 Ultra,
3818        // where SnapAlloc pads the planar-F16 RGBA16F surface). A padded
3819        // pitch is recorded on the tensor so CPU maps iterate rows via the
3820        // strided-map path; the GL render uses the buffer's own pitch
3821        // through the EGLImage either way, so the GPU path stays fully
3822        // zero-copy. Consumers needing a FLAT layout (the future NPU
3823        // handoff's `[1, C, H, W]` contract) must check `row_stride()` and
3824        // repack when set — flatness is a per-device property here, unlike
3825        // macOS where IOSurface's 64-BYTE alignment keeps model-sized F16
3826        // surfaces naturally flat.
3827        #[cfg(target_os = "android")]
3828        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3829            // Explicit-Dma contract (mirrors the macOS block above): the
3830            // caller asked for an image-formatted, GL-importable
3831            // AHardwareBuffer, so an unmapped combination or a gralloc
3832            // refusal errors here instead of falling through to a BLOB
3833            // byte-bag the GL backend can never import — that downgrade
3834            // only surfaced as a silent per-frame CPU upload.
3835            let dtype = dtype_of::<T>().ok_or_else(|| {
3836                Error::InvalidArgument(format!(
3837                    "Tensor::image: element type {} has no DType, so no \
3838                     image-formatted AHardwareBuffer exists. Pass memory=None \
3839                     or Some(TensorMemory::Mem) for a CPU tensor.",
3840                    std::any::type_name::<T>()
3841                ))
3842            })?;
3843            // Planar F16 requires width % 4 == 0 for the RGBA16F
3844            // packing — reject up front with the requirement spelled
3845            // out (mirrors the macOS pre-allocation guard) instead of
3846            // falling through to a byte-bag GL cannot bind.
3847            if format.layout() == PixelLayout::Planar
3848                && dtype == DType::F16
3849                && packed_rgba16f_layout(format, dtype, width, height).is_none()
3850            {
3851                return Err(Error::InvalidArgument(format!(
3852                    "Tensor::image: {format:?} F16 requires width%4==0 for the RGBA16F \
3853                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3854                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3855                )));
3856            }
3857            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3858            // the same whole-texel constraint as the F16 packing above.
3859            if format == PixelFormat::Rgb
3860                && matches!(dtype, DType::U8 | DType::I8)
3861                && packed_rgb888_layout(width, height).is_none()
3862            {
3863                return Err(Error::InvalidArgument(format!(
3864                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3865                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3866                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3867                )));
3868            }
3869            if crate::ahardwarebuffer::image_ahardwarebuffer_layout(format, dtype).is_none() {
3870                return Err(Error::InvalidArgument(format!(
3871                    "Tensor::image: no zero-copy AHardwareBuffer mapping exists \
3872                     for {format:?}/{dtype:?} on Android (Grey/NV* need \
3873                     R8-format buffers, API 29+; the HAL floor is API 26 — see \
3874                     `image_ahardwarebuffer_layout`). Pass memory=None to \
3875                     auto-select, or Some(TensorMemory::Mem) explicitly, for a \
3876                     CPU tensor; camera NV12 stays zero-copy by wrapping the \
3877                     camera's own AHardwareBuffer instead of allocating one."
3878                )));
3879            }
3880            let storage = TensorStorage::<T>::new_image_ahardwarebuffer(
3881                width, height, format, dtype, &shape, None, access,
3882            )?;
3883            let mut t = Self::wrap(storage);
3884            t.format = Some(format);
3885            if let TensorStorage::Dma(ref ahb) = t.storage {
3886                // gralloc chooses the row pitch (Qualcomm's
3887                // SnapAlloc pads e.g. the 160-px-wide RGBA16F
3888                // surface of a 640-wide planar F16 target).
3889                // When it exceeds the natural stride, record
3890                // it so CPU consumers iterate rows correctly
3891                // via `effective_row_stride()` — the GPU
3892                // renders through the EGLImage at the
3893                // buffer's real pitch regardless, so the
3894                // render stays fully zero-copy. Consumers
3895                // that need the FLAT `[1, C, H, W]` layout
3896                // (the future NPU handoff) must check
3897                // `row_stride()` is unset and repack — or
3898                // pick an aligned width — rather than assume
3899                // flatness (see the module docs).
3900                let bpr = ahb.bytes_per_row();
3901                if let Some(natural) = t.effective_row_stride() {
3902                    if bpr > natural {
3903                        log::debug!(
3904                            "Tensor::image: gralloc padded the {format:?} \
3905                             AHardwareBuffer pitch to {bpr} bytes (natural \
3906                             {natural}); recording row stride"
3907                        );
3908                        t.set_row_stride_unchecked(bpr);
3909                    }
3910                }
3911            }
3912            t.cpu_access = access;
3913            return Ok(t);
3914        }
3915
3916        // Windows Dma path: allocate an `ID3D11Texture2D` on the process
3917        // device so the GL backend can bind it through ANGLE's D3D11 texture
3918        // import and a D3D12/CUDA consumer can open its shared NT handle.
3919        //
3920        // Explicit-Dma contract, as on macOS and Android: the caller asked for
3921        // a texture, so a format with no texture layout is an error here
3922        // rather than a silent fall-through to the host-memory arms below,
3923        // which would only surface much later as a failed GPU import.
3924        #[cfg(target_os = "windows")]
3925        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3926            let dtype = dtype_of::<T>().ok_or_else(|| {
3927                Error::InvalidArgument(format!(
3928                    "Tensor::image: element type {} has no DType, so no D3D11 texture \
3929                     layout exists. Pass memory=None or Some(TensorMemory::Mem) for a \
3930                     CPU tensor.",
3931                    std::any::type_name::<T>()
3932                ))
3933            })?;
3934            let storage = TensorStorage::<T>::new_image_d3d11(
3935                width, height, format, dtype, &shape, None, access,
3936            )?;
3937            let mut t = Self::wrap(storage);
3938            t.format = Some(format);
3939            record_d3d11_row_stride(&mut t, format, "Tensor::image");
3940            t.cpu_access = access;
3941            // Best-effort CUDA import, as the Linux DMA arms do: a texture a
3942            // CUDA consumer can map is worth the one import at allocation.
3943            t.try_init_d3d11_cuda();
3944            return Ok(t);
3945        }
3946
3947        // Compute the **64-byte-aligned** row stride for every image layout.
3948        //
3949        // Embedded GPUs reject `eglCreateImage` DMA-BUF imports whose row pitch
3950        // is not 64-byte aligned: Mali returns `EGL_BAD_ALLOC`, Vivante
3951        // `EGL_BAD_ACCESS`. This bit packed RGBA/RGB destinations at odd widths
3952        // AND at even non-multiple-of-16 widths (e.g. 321→1284, 322→1288 bytes —
3953        // neither divisible by 64), so an odd-source → RGBA convert failed on
3954        // imx95/imx8mp while succeeding on V3D/Tegra. Semi-planar already aligned
3955        // here; we now align packed and planar identically so every image()
3956        // allocation is GPU-importable regardless of width.
3957        //
3958        // The per-layout natural pitch and total row count:
3959        //   * SemiPlanar `[total_h, width]`     — pitch = even(width)·elem, rows = total_h
3960        //   * Packed     `[height, width, ch]`  — pitch = width·ch·elem,    rows = height
3961        //   * Planar     `[ch, height, width]`  — pitch = width·elem,       rows = ch·height
3962        // Allocation byte size = `aligned_stride · total_rows` (NOT the shape
3963        // product, which reflects only the logical width and under-allocates the
3964        // padding on odd / unaligned widths).
3965        let elem = std::mem::size_of::<T>();
3966        let channels = format.channels();
3967        let (natural_stride, total_rows) = match format.layout() {
3968            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
3969            PixelLayout::Packed => (width * channels * elem, height),
3970            PixelLayout::Planar => (width * elem, channels * height),
3971        };
3972        let aligned_stride = natural_stride.next_multiple_of(64);
3973        let semi = format.layout() == PixelLayout::SemiPlanar;
3974
3975        // DMA buffers MUST carry a 64-aligned row pitch — Mali/Vivante reject a
3976        // DMA-BUF EGLImage whose pitch is not 64-aligned. Semi-planar also needs
3977        // the aligned pitch on every backend (its chroma-plane offset math
3978        // assumes it). Packed/planar on host-only memory (Mem/Shm) keep the
3979        // natural tight pitch so the many flat CPU consumers are unaffected.
3980        let host_stride = if semi { aligned_stride } else { natural_stride };
3981        let host_byte_size = host_stride * total_rows;
3982        #[cfg(target_os = "linux")]
3983        let dma_byte_size = aligned_stride * total_rows;
3984
3985        // `used_stride` is the actual row pitch of the storage created below.
3986        let (storage, used_stride) = match memory {
3987            #[cfg(target_os = "linux")]
3988            Some(TensorMemory::DmaBuf) => (
3989                TensorStorage::<T>::new_dma_with_byte_size(
3990                    &shape,
3991                    dma_byte_size,
3992                    None,
3993                    contiguous,
3994                )?,
3995                aligned_stride,
3996            ),
3997            #[cfg(unix)]
3998            Some(TensorMemory::Shm) => (
3999                TensorStorage::<T>::new_shm_with_byte_size(&shape, host_byte_size, None)?,
4000                host_stride,
4001            ),
4002            Some(TensorMemory::Mem) => (
4003                TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
4004                host_stride,
4005            ),
4006            #[allow(unused_variables)]
4007            Some(other) => {
4008                // PBO and any future variants: fall through to standard new().
4009                return {
4010                    let mut t = Self::new(&shape, Some(other), None)?;
4011                    t.format = Some(format);
4012                    t.cpu_access = access;
4013                    Ok(t)
4014                };
4015            }
4016            None => {
4017                // Auto-select priority: DMA → Mem (DMA gets the 64-aligned
4018                // pitch; the Mem fallback keeps the tight host pitch, so the
4019                // recorded stride matches the storage used). Shm is NOT
4020                // auto-selected — it offers no advantage over Mem for an
4021                // in-process image and Mem always succeeds, so it sits below Mem
4022                // and is reached only via an explicit `TensorMemory::Shm`.
4023                #[cfg(target_os = "linux")]
4024                {
4025                    match TensorStorage::<T>::new_dma_with_byte_size(
4026                        &shape,
4027                        dma_byte_size,
4028                        None,
4029                        false,
4030                    ) {
4031                        Ok(s) => (s, aligned_stride),
4032                        Err(_) => (
4033                            TensorStorage::<T>::new_mem_with_byte_size(
4034                                &shape,
4035                                host_byte_size,
4036                                None,
4037                            )?,
4038                            host_stride,
4039                        ),
4040                    }
4041                }
4042                #[cfg(not(target_os = "linux"))]
4043                {
4044                    (
4045                        TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
4046                        host_stride,
4047                    )
4048                }
4049            }
4050        };
4051
4052        let mut t = Self::wrap(storage);
4053        t.format = Some(format);
4054        // Record the row stride when it exceeds the natural tight pitch (padding
4055        // is present — DMA packed/planar at an unaligned width, or always for
4056        // semi-planar), mirroring the IOSurface path above. Aligned-width and
4057        // host-only packed/planar images keep their flat layout with no explicit
4058        // stride; `effective_row_stride()` then falls back to the identical
4059        // computed pitch. When padding IS present, consumers must iterate rows by
4060        // `effective_row_stride()` to skip it.
4061        if semi || used_stride > natural_stride {
4062            t.set_row_stride_unchecked(used_stride);
4063        }
4064        debug_assert!(
4065            t.row_stride.is_some() || !semi,
4066            "image() must always set row_stride for semi-planar tensors"
4067        );
4068        t.cpu_access = access;
4069        #[cfg(target_os = "linux")]
4070        t.try_init_dma_cuda();
4071        Ok(t)
4072    }
4073
4074    /// Create a DMA-backed image tensor with an explicit row stride that
4075    /// may exceed the natural `width * channels * sizeof(T)` pitch.
4076    ///
4077    /// Used for image tensors that need GPU pitch alignment padding: the
4078    /// underlying DMA-BUF is sized to `row_stride * height` bytes, but
4079    /// the tensor's logical shape stays at `[height, width, channels]`.
4080    /// `width()` / `height()` / `shape()` continue to report the
4081    /// user-requested values; the padding is visible only via
4082    /// `row_stride()` / `effective_row_stride()` and is automatically
4083    /// propagated to the GL backend's EGLImage import so Mali Valhall
4084    /// accepts the buffer.
4085    ///
4086    /// # Supported formats
4087    ///
4088    /// Currently only **packed** pixel layouts (RGBA8, BGRA8, RGB888,
4089    /// Grey, etc.) are supported — the formats the GL backend uses as
4090    /// render destinations. Semi-planar formats (NV12, NV16) come from
4091    /// external allocators (camera capture, video decoders) and are
4092    /// imported via `TensorDyn::from_fd` + `set_row_stride`, which
4093    /// already supports padded strides.
4094    ///
4095    /// # Supported memory
4096    ///
4097    /// Currently only `TensorMemory::DmaBuf` is supported. PBO and Mem
4098    /// storage don't go through EGLImage import so they don't need
4099    /// pitch alignment; if you pass any other memory type this returns
4100    /// `NotImplemented`. `None` (auto-select) is treated as `Dma`.
4101    ///
4102    /// # Errors
4103    ///
4104    /// - `InvalidArgument` if `row_stride_bytes < width * channels * sizeof(T)`
4105    ///   (the requested stride would not fit a single row)
4106    /// - `NotImplemented` for non-packed formats or non-DMA memory
4107    /// - `IoError` if the DMA-heap allocation fails (propagated from
4108    ///   `DmaTensor::new_with_byte_size`)
4109    pub fn image_with_stride(
4110        width: usize,
4111        height: usize,
4112        format: PixelFormat,
4113        row_stride_bytes: usize,
4114        memory: Option<TensorMemory>,
4115        access: CpuAccess,
4116    ) -> Result<Self> {
4117        #[cfg(not(target_os = "linux"))]
4118        let _ = access;
4119        // DMA backing (the only thing this constructor produces) is
4120        // Linux-only. On macOS/BSD/Windows the non-Linux block below is
4121        // the only compiled body and returns `NotImplemented` directly;
4122        // on Linux the non-Linux block is cfg-removed and the function
4123        // falls through to the real validation + allocation path. Each
4124        // target compiles exactly one of the two blocks, and the block
4125        // serves as the function's tail expression in both cases — so
4126        // neither needs an explicit `return` (avoids
4127        // `clippy::needless_return` on the macOS CI gate).
4128        #[cfg(not(target_os = "linux"))]
4129        {
4130            let _ = (width, height, format, row_stride_bytes, memory);
4131            Err(Error::NotImplemented(
4132                "image_with_stride requires DMA support (Linux only)".to_owned(),
4133            ))
4134        }
4135
4136        #[cfg(target_os = "linux")]
4137        {
4138            if format.layout() != PixelLayout::Packed {
4139                return Err(Error::NotImplemented(format!(
4140                    "Tensor::image_with_stride only supports packed pixel layouts, got {format:?}"
4141                )));
4142            }
4143            let elem = std::mem::size_of::<T>();
4144            let min_stride = width
4145                .checked_mul(format.channels())
4146                .and_then(|p| p.checked_mul(elem))
4147                .ok_or_else(|| {
4148                    Error::InvalidArgument(format!(
4149                        "image_with_stride: width {width} × channels {} × sizeof::<T>={elem} \
4150                         overflows usize",
4151                        format.channels()
4152                    ))
4153                })?;
4154            if row_stride_bytes < min_stride {
4155                return Err(Error::InvalidArgument(format!(
4156                    "image_with_stride: row_stride {row_stride_bytes} < minimum {min_stride} \
4157                     ({width} px × {} ch × {elem} B)",
4158                    format.channels()
4159                )));
4160            }
4161            let total_byte_size = row_stride_bytes.checked_mul(height).ok_or_else(|| {
4162                Error::InvalidArgument(format!(
4163                    "image_with_stride: row_stride {row_stride_bytes} × height {height} overflows usize"
4164                ))
4165            })?;
4166
4167            let shape = vec![height, width, format.channels()];
4168
4169            let storage = match memory {
4170                Some(TensorMemory::DmaBuf) | None => TensorStorage::<T>::new_dma_with_byte_size(
4171                    &shape,
4172                    total_byte_size,
4173                    None,
4174                    false,
4175                )?,
4176                Some(other) => {
4177                    return Err(Error::NotImplemented(format!(
4178                        "image_with_stride: only TensorMemory::DmaBuf is supported, got {other:?}"
4179                    )));
4180                }
4181            };
4182
4183            let mut t = Self::wrap(storage);
4184            t.format = Some(format);
4185            t.row_stride = Some(row_stride_bytes);
4186            t.cpu_access = access;
4187            // Match new()/from_fd(): a DMA tensor must attempt CUDA external-
4188            // memory import so a strided DMA buffer is also zero-copy
4189            // CUDA-mappable (no-op when libcudart is absent).
4190            t.try_init_dma_cuda();
4191            Ok(t)
4192        }
4193    }
4194
4195    /// Attach format metadata to an existing tensor.
4196    ///
4197    /// # Arguments
4198    ///
4199    /// * `format` - The pixel format to attach
4200    ///
4201    /// # Returns
4202    ///
4203    /// `Ok(())` on success, with the format stored as metadata on the tensor.
4204    ///
4205    /// # Errors
4206    ///
4207    /// Returns `Error::InvalidShape` if the tensor shape is incompatible with
4208    /// the format's layout (packed expects `[H, W, C]`, planar expects
4209    /// `[C, H, W]`, semi-planar expects `[H*k, W]` with format-specific
4210    /// height constraints).
4211    pub fn set_format(&mut self, format: PixelFormat) -> Result<()> {
4212        let shape = self.shape();
4213        match format.layout() {
4214            PixelLayout::Packed => {
4215                if shape.len() != 3 || shape[2] != format.channels() {
4216                    return Err(Error::InvalidShape(format!(
4217                        "packed format {format:?} expects [H, W, {}], got {shape:?}",
4218                        format.channels()
4219                    )));
4220                }
4221            }
4222            PixelLayout::Planar => {
4223                if shape.len() != 3 || shape[0] != format.channels() {
4224                    return Err(Error::InvalidShape(format!(
4225                        "planar format {format:?} expects [{}, H, W], got {shape:?}",
4226                        format.channels()
4227                    )));
4228                }
4229            }
4230            PixelLayout::SemiPlanar => {
4231                if shape.len() != 2 {
4232                    return Err(Error::InvalidShape(format!(
4233                        "semi-planar format {format:?} expects [H*k, W], got {shape:?}"
4234                    )));
4235                }
4236                match format {
4237                    // Combined-plane height is `H + ceil(H/2)` (luma + chroma
4238                    // rows). For even H that is `3H/2` (≡ 0 mod 3); for odd H it
4239                    // is `(3H+1)/2` (≡ 2 mod 3). Only totals ≡ 1 mod 3 are
4240                    // unreachable, so reject just those — odd-height NV12 is
4241                    // valid (e.g. 725 rows for a 483-tall image).
4242                    PixelFormat::Nv12 if shape[0] % 3 == 1 => {
4243                        return Err(Error::InvalidShape(format!(
4244                            "NV12 contiguous shape[0] must be H + ceil(H/2) for some height; \
4245                             {} is unreachable (≡ 1 mod 3)",
4246                            shape[0]
4247                        )));
4248                    }
4249                    PixelFormat::Nv16 if !shape[0].is_multiple_of(2) => {
4250                        return Err(Error::InvalidShape(format!(
4251                            "NV16 contiguous shape[0] must be even, got {}",
4252                            shape[0]
4253                        )));
4254                    }
4255                    // NV24 (4:4:4): combined-plane height is 3H (Y + 2H chroma).
4256                    PixelFormat::Nv24 if !shape[0].is_multiple_of(3) => {
4257                        return Err(Error::InvalidShape(format!(
4258                            "NV24 contiguous shape[0] must be a multiple of 3 (= 3H), got {}",
4259                            shape[0]
4260                        )));
4261                    }
4262                    _ => {}
4263                }
4264            }
4265        }
4266        // Clear stored stride/offset when format changes — they may be invalid
4267        // for the new format. Caller must re-set after changing format.
4268        if self.format != Some(format) {
4269            self.row_stride = None;
4270            self.plane_offset = None;
4271            match self.storage {
4272                TensorStorage::Mem(ref mut m) => m.set_offset(0),
4273                #[cfg(target_os = "linux")]
4274                TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
4275                // Paired with the arm in `set_plane_offset`: a setter that
4276                // takes effect on this backing needs a clear that does too,
4277                // or the wrapper reports `None` while `map()` still starts at
4278                // the old offset -- a stale window rather than a lost one.
4279                // `reshape`'s clear site needs no such arm:
4280                // `IoSurfaceTensor::reshape` zeroes `view_offset` itself
4281                // before the match there runs.
4282                #[cfg(any(target_os = "macos", target_os = "ios"))]
4283                TensorStorage::Dma(ref mut io) => io.view_offset = 0,
4284                // The same pair for D3D11. Unlike `IoSurfaceTensor`,
4285                // `D3d11TextureTensor::reshape` leaves `view_offset` alone, so
4286                // `reshape`'s clear site carries this arm as well.
4287                #[cfg(target_os = "windows")]
4288                TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
4289                // Paired with the arm in `set_plane_offset`: a setter that
4290                // takes effect on this backing needs a clear that does too.
4291                // `reshape`'s clear site needs no `Pbo` arm --
4292                // `PboTensor::reshape` zeroes `view_offset` itself before the
4293                // match there runs, exactly as `IoSurfaceTensor::reshape`
4294                // does. `set_logical_shape` deliberately does not, matching
4295                // `Mem`.
4296                TensorStorage::Pbo(ref mut p) => p.view_offset = 0,
4297                // Same pair, `Shm` side. `cfg(unix)` mirrors the `Shm`
4298                // variant's own gate on `TensorStorage`, not a per-OS
4299                // multiplex -- see the comment on the `set_plane_offset`
4300                // arm.
4301                #[cfg(unix)]
4302                TensorStorage::Shm(ref mut s) => s.set_offset(0),
4303                // No-op, pairing with `set_plane_offset`'s Android arm:
4304                // nothing writes `AHardwareBufferTensor::view_offset` there
4305                // either yet, so there is nothing here for a format change
4306                // to clear. See that arm's comment for the full accounting
4307                // of the still-incomplete gap.
4308                #[cfg(target_os = "android")]
4309                TensorStorage::Dma(_) => {}
4310            }
4311        }
4312        self.format = Some(format);
4313        Ok(())
4314    }
4315
4316    /// Set this tensor's logical dimensions and pixel format to a decoded
4317    /// image, reusing the existing allocation. The shape is derived from the
4318    /// format layout; fails with `Error::InsufficientCapacity` if the
4319    /// allocation cannot hold `width`×`height` in `format`, or
4320    /// `Error::InvalidArgument` if the dimensions are invalid for the format.
4321    ///
4322    /// For NV12/NV16/NV24 the buffer width is rounded up to even (a chroma-plane
4323    /// interleaving requirement); the true odd width is reported by the decoder
4324    /// in `ImageInfo` and trimmed by a `convert()` crop. See
4325    /// [`PixelFormat::allocation_shape`].
4326    ///
4327    /// When the backing has a fixed physical row pitch (an IOSurface's
4328    /// 64-aligned `bytesPerRow`) that exceeds the new format's natural row
4329    /// stride — i.e. a reused max-sized pool tensor reconfigured to a smaller
4330    /// image — the physical pitch is preserved as the tensor's `row_stride`.
4331    /// This keeps the **physical grid** (allocation stride/surface) fixed while
4332    /// the **logical ROI** (this image's W×H) changes, so the decode writes rows
4333    /// at the surface's real stride and the GPU samples them at the same stride.
4334    /// Exact-sized buffers (pitch == natural) stay tightly packed unchanged.
4335    pub fn configure_image(
4336        &mut self,
4337        width: usize,
4338        height: usize,
4339        format: PixelFormat,
4340    ) -> Result<()> {
4341        let shape = format.allocation_shape(width, height).ok_or_else(|| {
4342            Error::InvalidArgument(format!(
4343                "invalid dimensions {width}x{height} for format {format:?}"
4344            ))
4345        })?;
4346        // Capture the pre-existing row stride before `set_format` clears it.
4347        // For pool tensors that were allocated at a larger width (e.g. 1920-wide
4348        // pool decoding a 789-wide image), this preserves the backing pitch so
4349        // rows are still written at the correct physical stride.
4350        let prior_stride = self.row_stride;
4351
4352        self.storage.set_logical_shape(&shape)?;
4353        self.set_format(format)?;
4354        // `set_format` keeps the stride when the format is unchanged; the new
4355        // geometry's stride is chosen below, never inherited by accident.
4356        self.row_stride = None;
4357
4358        // Restore the correct row pitch for the new geometry. A DMA buffer of
4359        // ANY layout, and a semi-planar buffer on any backing, MUST carry a
4360        // 64-byte-aligned pitch: Mali/Vivante reject a DMA-BUF EGLImage whose
4361        // row pitch is not 64-aligned (`EGL_BAD_ALLOC` / `EGL_BAD_ACCESS`), and
4362        // semi-planar chroma-offset math assumes it. This mirrors the pitch
4363        // `Tensor::image()` allocates, so a recycled pool buffer imports
4364        // identically to a fresh one — without it a `configure_image()`'d packed
4365        // buffer (e.g. Y800 96-wide → tight pitch 96) failed convert() on
4366        // imx95/imx8mp via the texture-upload fallback while the fresh oracle
4367        // (aligned 128) succeeded. Packed/planar on host-only memory (Mem/Shm)
4368        // keep the tight pitch so flat CPU consumers stay unaffected — matching
4369        // `image()`'s `host_stride` rule.
4370        let elem = std::mem::size_of::<T>();
4371        let channels = format.channels();
4372        let (min_stride, total_rows) = match format.layout() {
4373            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
4374            PixelLayout::Packed => (width * channels * elem, height),
4375            PixelLayout::Planar => (width * elem, channels * height),
4376        };
4377        let needs_align = self.storage.memory() == TensorMemory::DmaBuf
4378            || format.layout() == PixelLayout::SemiPlanar;
4379
4380        // A backing with its own fixed pitch (IOSurface, AHardwareBuffer, a
4381        // staged D3D11 texture) keeps it whenever it exceeds the new row.
4382        #[cfg(any(
4383            target_os = "macos",
4384            target_os = "ios",
4385            target_os = "android",
4386            target_os = "windows"
4387        ))]
4388        if let Some(pitch) = self.storage.backing_row_stride() {
4389            let active_stride = if pitch > min_stride {
4390                self.set_row_stride_unchecked(pitch);
4391                pitch
4392            } else {
4393                min_stride
4394            };
4395            if !needs_align {
4396                return Ok(());
4397            }
4398            return self.check_configured_stride(active_stride, total_rows);
4399        }
4400
4401        if !needs_align {
4402            return Ok(());
4403        }
4404        // Priority:
4405        //   1. Prior stride (pool reuse): if the pre-existing stride is
4406        //      64-aligned, >= this layout's minimum row, and fits the
4407        //      allocation, keep it. This is the hot-loop reuse case (large
4408        //      pool, small image).
4409        //   2. Compute a fresh 64-aligned stride for the current width.
4410        let aligned = min_stride.next_multiple_of(64);
4411        let capacity = self.storage.capacity_bytes();
4412
4413        let candidate = if let Some(ps) = prior_stride {
4414            if ps >= min_stride && ps % 64 == 0 && ps * total_rows <= capacity {
4415                ps
4416            } else {
4417                aligned
4418            }
4419        } else {
4420            aligned
4421        };
4422
4423        let active_stride = if candidate * total_rows <= capacity {
4424            self.set_row_stride_unchecked(candidate);
4425            candidate
4426        } else {
4427            // The tight row may still fit where the aligned one does not.
4428            min_stride
4429        };
4430        self.check_configured_stride(active_stride, total_rows)
4431    }
4432
4433    /// The last step of [`configure_image`](Self::configure_image) for an
4434    /// aligned layout: rows that do not fit the allocation are refused here,
4435    /// rather than left to fault on a later map or write.
4436    fn check_configured_stride(&self, active_stride: usize, total_rows: usize) -> Result<()> {
4437        let needed = active_stride * total_rows;
4438        let capacity = self.storage.capacity_bytes();
4439        if needed > capacity {
4440            return Err(Error::InsufficientCapacity { needed, capacity });
4441        }
4442        Ok(())
4443    }
4444
4445    /// Allocate an image tensor sized to hold up to `width`×`height` in
4446    /// `format`, reusable for any smaller image via `configure_image`.
4447    pub fn image_with_capacity(
4448        width: usize,
4449        height: usize,
4450        format: PixelFormat,
4451        memory: Option<TensorMemory>,
4452        access: CpuAccess,
4453    ) -> Result<Self>
4454    where
4455        T: 'static,
4456    {
4457        Self::image(width, height, format, memory, access)
4458    }
4459
4460    /// Pixel format (None if not an image).
4461    pub fn format(&self) -> Option<PixelFormat> {
4462        self.format
4463    }
4464
4465    /// The format and the trailing shape dims that describe one image: packed
4466    /// `[H, W, C]`, planar `[C, H, W]` or semi-planar `[H*k, W]`, optionally
4467    /// behind a leading batch `N` (see [`batch`](Self::batch)). `None` when
4468    /// unformatted or when the shape has any other rank --
4469    /// `set_logical_shape` keeps the format on a shape it no longer describes.
4470    fn image_dims(&self) -> Option<(PixelFormat, &[usize])> {
4471        let fmt = self.format?;
4472        let shape = self.shape();
4473        let rank = image_rank(fmt.layout());
4474        let batch_dims = shape.len().checked_sub(rank).filter(|&n| n <= 1)?;
4475        Some((fmt, &shape[batch_dims..]))
4476    }
4477
4478    /// Image width (None if not an image). For a batched `[N, ...]` tensor
4479    /// this is the width of one element.
4480    pub fn width(&self) -> Option<usize> {
4481        let (fmt, dims) = self.image_dims()?;
4482        match fmt.layout() {
4483            PixelLayout::Packed => Some(dims[1]),
4484            PixelLayout::Planar => Some(dims[2]),
4485            PixelLayout::SemiPlanar => Some(dims[1]),
4486        }
4487    }
4488
4489    /// Image height (None if not an image). For a batched `[N, ...]` tensor
4490    /// this is the height of one element.
4491    ///
4492    /// For semi-planar formats the combined-plane shape row count is divided
4493    /// by the format's luma-to-total ratio to recover logical height. This
4494    /// returns the exact logical height (including odd heights) only because
4495    /// the logical dimensions are tracked separately from the physical shape —
4496    /// `configure_image` stores the actual `(width, height)` in the format's
4497    /// `allocation_shape`, which round-trips losslessly via these accessors.
4498    pub fn height(&self) -> Option<usize> {
4499        let (fmt, dims) = self.image_dims()?;
4500        match fmt.layout() {
4501            PixelLayout::Packed => Some(dims[0]),
4502            PixelLayout::Planar => Some(dims[1]),
4503            PixelLayout::SemiPlanar => {
4504                if self.is_multiplane() {
4505                    Some(dims[0])
4506                } else {
4507                    match fmt {
4508                        PixelFormat::Nv12 => Some(dims[0] * 2 / 3),
4509                        PixelFormat::Nv16 => Some(dims[0] / 2),
4510                        PixelFormat::Nv24 => Some(dims[0] / 3),
4511                        _ => None,
4512                    }
4513                }
4514            }
4515        }
4516    }
4517
4518    /// Create from separate Y and UV planes (multiplane NV12/NV16).
4519    pub fn from_planes(luma: Tensor<T>, chroma: Tensor<T>, format: PixelFormat) -> Result<Self> {
4520        if format.layout() != PixelLayout::SemiPlanar {
4521            return Err(Error::InvalidArgument(format!(
4522                "from_planes requires a semi-planar format, got {format:?}"
4523            )));
4524        }
4525        if chroma.format.is_some() || chroma.chroma.is_some() {
4526            return Err(Error::InvalidArgument(
4527                "chroma tensor must be a raw tensor (no format or chroma metadata)".into(),
4528            ));
4529        }
4530        let luma_shape = luma.shape();
4531        let chroma_shape = chroma.shape();
4532        if luma_shape.len() != 2 || chroma_shape.len() != 2 {
4533            return Err(Error::InvalidArgument(format!(
4534                "from_planes expects 2D shapes, got luma={luma_shape:?} chroma={chroma_shape:?}"
4535            )));
4536        }
4537        if luma_shape[1] != chroma_shape[1] {
4538            return Err(Error::InvalidArgument(format!(
4539                "luma width {} != chroma width {}",
4540                luma_shape[1], chroma_shape[1]
4541            )));
4542        }
4543        match format {
4544            PixelFormat::Nv12 => {
4545                if luma_shape[0] % 2 != 0 {
4546                    return Err(Error::InvalidArgument(format!(
4547                        "NV12 requires even luma height, got {}",
4548                        luma_shape[0]
4549                    )));
4550                }
4551                if chroma_shape[0] != luma_shape[0] / 2 {
4552                    return Err(Error::InvalidArgument(format!(
4553                        "NV12 chroma height {} != luma height / 2 ({})",
4554                        chroma_shape[0],
4555                        luma_shape[0] / 2
4556                    )));
4557                }
4558            }
4559            PixelFormat::Nv16 => {
4560                if chroma_shape[0] != luma_shape[0] {
4561                    return Err(Error::InvalidArgument(format!(
4562                        "NV16 chroma height {} != luma height {}",
4563                        chroma_shape[0], luma_shape[0]
4564                    )));
4565                }
4566            }
4567            // NV24's chroma plane is full-resolution (2×-wide interleaved UV),
4568            // which the equal-width plane check above doesn't model. Multiplane
4569            // NV24 is unused (the JPEG decoder emits a contiguous NV24 buffer),
4570            // so it's not supported here yet.
4571            _ => {
4572                return Err(Error::InvalidArgument(format!(
4573                    "from_planes only supports NV12 and NV16 (NV24 multiplane not yet \
4574                     supported — use a contiguous NV24 tensor), got {format:?}"
4575                )));
4576            }
4577        }
4578
4579        Ok(Tensor {
4580            storage: luma.storage,
4581            format: Some(format),
4582            chroma: Some(Box::new(chroma)),
4583            row_stride: luma.row_stride,
4584            plane_offset: luma.plane_offset,
4585            quantization: luma.quantization,
4586            // A multiplane tensor spans two DMA-BUFs (luma + chroma); CUDA
4587            // external-memory import is per-fd, so there is no single device
4588            // pointer for the composite. Any CUDA handle the luma plane carried
4589            // is intentionally dropped — consumers needing CUDA access to
4590            // multiplane data must import each plane independently.
4591            cuda: None,
4592            colorimetry: luma.colorimetry,
4593            cpu_access: luma.cpu_access,
4594            compression: luma.compression,
4595            // A composed multiplane tensor is a whole image, not a sub-view.
4596            view_origin: None,
4597        })
4598    }
4599
4600    /// Whether this tensor uses separate plane allocations.
4601    pub fn is_multiplane(&self) -> bool {
4602        self.chroma.is_some()
4603    }
4604
4605    /// Access the chroma plane for multiplane semi-planar images.
4606    pub fn chroma(&self) -> Option<&Tensor<T>> {
4607        self.chroma.as_deref()
4608    }
4609
4610    /// Mutable access to the chroma plane for multiplane semi-planar images.
4611    pub fn chroma_mut(&mut self) -> Option<&mut Tensor<T>> {
4612        self.chroma.as_deref_mut()
4613    }
4614
4615    /// Independent tensor over this plane's allocation, copying geometry
4616    /// metadata. Used to answer `ef_tensor_chroma` from the handle without a
4617    /// client-side shadow field.
4618    pub(crate) fn clone_chroma_plane(&self) -> Result<Self> {
4619        // Every storage accepted by `from_planes` implements `view` as a
4620        // shared-backing lens. Using that common path keeps
4621        // `is_multiplane() => chroma().is_some()` true for SHM, PBO and
4622        // D3D11 too, instead of silently losing chroma when a hand-written
4623        // per-storage clone omits a variant.
4624        self.subview(0, self.shape())
4625    }
4626
4627    /// Row stride in bytes (`None` = tightly packed).
4628    pub fn row_stride(&self) -> Option<usize> {
4629        self.row_stride
4630    }
4631
4632    /// Effective row stride in bytes: the stored stride if set, otherwise the
4633    /// minimum stride computed from the format, width, and element size.
4634    /// Returns `None` only when no format is set and no explicit stride was
4635    /// stored via [`set_row_stride`](Self::set_row_stride).
4636    ///
4637    /// **GREY note:** `effective_row_stride()` for a GREY tensor returns the
4638    /// tight `width` bytes (no padding), which is what `normalize_to_numpy` and
4639    /// the CPU convert path expect. The codec's internal `native_row_stride`
4640    /// (64-byte-aligned) is used only during decoding and is not propagated to
4641    /// the tensor's stored stride, so callers reading via
4642    /// `effective_row_stride()` always see the tight value for GREY.
4643    pub fn effective_row_stride(&self) -> Option<usize> {
4644        if let Some(s) = self.row_stride {
4645            return Some(s);
4646        }
4647        let fmt = self.format?;
4648        let w = self.width()?;
4649        let elem = std::mem::size_of::<T>();
4650        Some(match fmt.layout() {
4651            PixelLayout::Packed => w * fmt.channels() * elem,
4652            PixelLayout::Planar => w * elem,
4653            // Semi-planar: minimum stride must cover the even width so the
4654            // interleaved chroma columns are byte-aligned on odd-width images.
4655            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4656        })
4657    }
4658
4659    /// Copy the tensor's logical bytes into `dst`, compacting away any
4660    /// recorded row-stride padding.
4661    ///
4662    /// The flatness helper for NPU handoff: consumers that need a FLAT
4663    /// layout (e.g. `[1, C, H, W]` for NNAPI/LiteRT when the runtime cannot
4664    /// take a padded pitch) call this when [`row_stride`](Self::row_stride)
4665    /// is `Some` — on a tight tensor it degenerates to one memcpy, so it is
4666    /// safe to call unconditionally. `dst.len()` must equal the tight byte
4667    /// footprint (`shape` product × element size). Zero-copy consumers
4668    /// should prefer the buffer handle + [`Tensor::effective_row_stride`]
4669    /// (Self::effective_row_stride) and skip this copy entirely.
4670    pub fn copy_to_flat(&self, dst: &mut [u8]) -> Result<()> {
4671        let tight_bytes = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(self.shape())?;
4672        if dst.len() != tight_bytes {
4673            return Err(Error::InvalidArgument(format!(
4674                "copy_to_flat: dst is {} bytes but the tensor's tight \
4675                 footprint is {tight_bytes} bytes (shape {:?})",
4676                dst.len(),
4677                self.shape()
4678            )));
4679        }
4680        // Read-only: this helper never writes back into the tensor, so a
4681        // read map suffices — and is required on backends (D3D11) that
4682        // reject a writable map on a tensor allocated CpuAccess::Read.
4683        let map = self.map_read()?;
4684        // SAFETY: T is a plain numeric type (crate-wide bound); viewing the
4685        // mapped elements as bytes is sound.
4686        let src: &[u8] = unsafe {
4687            std::slice::from_raw_parts(
4688                map.as_slice().as_ptr() as *const u8,
4689                std::mem::size_of_val(map.as_slice()),
4690            )
4691        };
4692        let Some(stride) = self.row_stride else {
4693            // Tight layout: the mapped window is exactly the logical bytes.
4694            let Some(tight) = src.get(..tight_bytes) else {
4695                return Err(Error::InvalidOperation(format!(
4696                    "copy_to_flat: mapped {} bytes < tight footprint {tight_bytes}",
4697                    src.len()
4698                )));
4699            };
4700            dst.copy_from_slice(tight);
4701            return Ok(());
4702        };
4703        // Strided: the rows the strided map spans, each holding one tight
4704        // logical row. `rows` is a factor of the element count, so it divides
4705        // the tight footprint exactly.
4706        let rows = strided_rows(self)?;
4707        if rows == 0 {
4708            return Err(Error::InvalidOperation(
4709                "copy_to_flat: a strided tensor with no rows has nothing to copy".into(),
4710            ));
4711        }
4712        let row_bytes = tight_bytes / rows;
4713        let need = (rows - 1)
4714            .checked_mul(stride)
4715            .and_then(|b| b.checked_add(row_bytes))
4716            .ok_or_else(|| {
4717                Error::InvalidOperation(format!(
4718                    "copy_to_flat: stride {stride} × rows {rows} overflows usize"
4719                ))
4720            })?;
4721        if src.len() < need {
4722            return Err(Error::InvalidOperation(format!(
4723                "copy_to_flat: mapped {} bytes but strided rows need {need}",
4724                src.len()
4725            )));
4726        }
4727        for r in 0..rows {
4728            dst[r * row_bytes..(r + 1) * row_bytes]
4729                .copy_from_slice(&src[r * stride..r * stride + row_bytes]);
4730        }
4731        Ok(())
4732    }
4733
4734    /// Set the row stride in bytes for externally allocated buffers with
4735    /// row padding (e.g. V4L2 or GStreamer allocators).
4736    ///
4737    /// The stride is propagated to the EGL DMA-BUF import attributes so
4738    /// the GPU interprets the padded buffer layout correctly. Must be
4739    /// called after [`set_format`](Self::set_format) and before the tensor
4740    /// is first passed to `ImageProcessor::convert`. The stored stride
4741    /// is cleared automatically if the pixel format is later changed.
4742    ///
4743    /// No stride-vs-buffer-size validation is performed because the
4744    /// backing allocation size is not reliably known: external DMA-BUFs
4745    /// may be over-allocated by the allocator, and internal tensors store
4746    /// a logical (unpadded) shape. An incorrect stride will be caught by
4747    /// the EGL driver at import time.
4748    ///
4749    /// # Arguments
4750    ///
4751    /// * `stride` - Row stride in bytes. Must be >= the minimum stride for
4752    ///   the format (width * channels * sizeof(T) for packed,
4753    ///   width * sizeof(T) for planar/semi-planar).
4754    ///
4755    /// # Errors
4756    ///
4757    /// * `InvalidArgument` if no pixel format is set on this tensor
4758    /// * `InvalidArgument` if `stride` is less than the minimum for the
4759    ///   format and width
4760    pub fn set_row_stride(&mut self, stride: usize) -> Result<()> {
4761        let fmt = self.format.ok_or_else(|| {
4762            Error::InvalidArgument("cannot set row_stride without a pixel format".into())
4763        })?;
4764        let w = self.width().ok_or_else(|| {
4765            Error::InvalidArgument("cannot determine width for row_stride validation".into())
4766        })?;
4767        let elem = std::mem::size_of::<T>();
4768        let min_stride = match fmt.layout() {
4769            PixelLayout::Packed => w * fmt.channels() * elem,
4770            PixelLayout::Planar => w * elem,
4771            // Semi-planar: minimum must cover even width for chroma alignment.
4772            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4773        };
4774        if stride < min_stride {
4775            return Err(Error::InvalidArgument(format!(
4776                "row_stride {stride} < minimum {min_stride} for {fmt:?} at width {w}"
4777            )));
4778        }
4779        self.row_stride = Some(stride);
4780        Ok(())
4781    }
4782
4783    /// Set the row stride without format validation.
4784    ///
4785    /// Use this for raw sub-tensors (e.g. chroma planes) that don't carry
4786    /// format metadata. The caller is responsible for ensuring the stride
4787    /// is valid.
4788    pub fn set_row_stride_unchecked(&mut self, stride: usize) {
4789        self.row_stride = Some(stride);
4790    }
4791
4792    /// Builder-style variant of [`set_row_stride`](Self::set_row_stride),
4793    /// consuming and returning `self`.
4794    ///
4795    /// # Errors
4796    ///
4797    /// Same conditions as [`set_row_stride`](Self::set_row_stride).
4798    pub fn with_row_stride(mut self, stride: usize) -> Result<Self> {
4799        self.set_row_stride(stride)?;
4800        Ok(self)
4801    }
4802
4803    /// Byte offset within the DMA-BUF where image data starts (`None` = 0).
4804    pub fn plane_offset(&self) -> Option<usize> {
4805        self.plane_offset
4806    }
4807
4808    /// The parent-image snapshot if this tensor is a [`view`](Self::view)/
4809    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. The GL
4810    /// backend keys its import on the parent geometry and renders this view as a
4811    /// `glViewport`/`glScissor` ROI at `(x, y, width, height)`. See [`ViewOrigin`].
4812    pub fn view_origin(&self) -> Option<ViewOrigin> {
4813        self.view_origin
4814    }
4815
4816    /// Set the byte offset within the DMA-BUF where image data starts.
4817    ///
4818    /// Propagated to `EGL_DMA_BUF_PLANE0_OFFSET_EXT` on GPU import.
4819    /// Unlike [`set_row_stride`](Self::set_row_stride), no format is required
4820    /// since the offset is format-independent.
4821    pub fn set_plane_offset(&mut self, offset: usize) {
4822        self.plane_offset = Some(offset);
4823        // The offset consulted by `map()` lives inside the storage variant.
4824        // Keep it in sync with the wrapper field for every backing that
4825        // honors it; see also the clear sites in `set_format` and `reshape`.
4826        //
4827        // Note that `TensorStorage::Dma` is a cfg-multiplexed *name* rather
4828        // than one type -- `DmaTensor` on Linux, `IoSurfaceTensor` on
4829        // macOS/iOS, `AHardwareBufferTensor` on Android,
4830        // `D3d11TextureTensor` on Windows -- so each platform needs its own
4831        // arm below; a missing one would silently become a fall-through
4832        // rather than a compile error, which is how macOS went without one
4833        // for a while. The match is kept exhaustive (no `_ => {}`)
4834        // specifically so that hazard is a compile error, not a silent gap.
4835        match self.storage {
4836            TensorStorage::Mem(ref mut m) => m.set_offset(offset),
4837            #[cfg(target_os = "linux")]
4838            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = offset,
4839            // The offset `IoSurfaceTensor::scoped_pin`/`map_inner` add to the
4840            // locked base address. Writing it here is what makes a
4841            // *reconstructed* view address its own sub-region instead of the
4842            // parent surface's origin; `IoSurfaceTensor::view` already set it
4843            // on the fresh-view path, which is why only the descriptor round
4844            // trip was ever wrong. Idempotent against `subview`, which sets
4845            // the same absolute value by both routes -- pinned by
4846            // `nested_iosurface_subviews_do_not_compound_their_plane_offset`.
4847            #[cfg(any(target_os = "macos", target_os = "ios"))]
4848            TensorStorage::Dma(ref mut io) => io.view_offset = offset,
4849            // The offset every `D3d11TextureTensor` pin adds to its host or
4850            // staging base, in backing (pitched) space like `row_stride()`.
4851            // `view()` bounds-checks the value it computes; this route does
4852            // not, so the pins refuse an offset past the backing instead
4853            // (`check_view_offset` in `d3d11/texture.rs`). Pinned by the
4854            // `d3d11` plane-offset tests in `crates/tensor/tests`.
4855            #[cfg(target_os = "windows")]
4856            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(offset),
4857            // The offset `PboTensor::scoped_pin` adds to the mapped base.
4858            // Writing it here is what makes a *reconstructed* view address
4859            // its own sub-region instead of the parent buffer's origin;
4860            // `PboTensor::view` already set it on the fresh-view path, which
4861            // is why only the round trip was ever wrong -- the same shape
4862            // IOSurface had. Idempotent against `subview`, which sets the
4863            // same absolute value by both routes -- pinned by
4864            // `nested_pbo_subviews_do_not_compound_their_plane_offset`.
4865            //
4866            // Not `cfg`-gated, and deliberately so: unlike
4867            // `TensorStorage::Dma` -- a cfg-multiplexed *name* whose gated
4868            // arm silently becomes `_ => {}` on the other targets, the
4869            // mechanism behind this whole issue class -- `TensorStorage::Pbo`
4870            // is one type on every platform.
4871            TensorStorage::Pbo(ref mut p) => p.view_offset = offset,
4872            // The offset `ShmTensor::map_inner`/`host_pin` add to the mmap
4873            // base, exactly like `MemTensor::offset`. `cfg(unix)` here
4874            // mirrors `TensorStorage::Shm`'s own gate on the enum
4875            // definition -- it is not a per-OS multiplex the way
4876            // `TensorStorage::Dma` is, so this arm cannot silently become
4877            // `_ => {}` on a target where the `Shm` variant still exists.
4878            #[cfg(unix)]
4879            TensorStorage::Shm(ref mut s) => s.set_offset(offset),
4880            // STILL INCOMPLETE: `AHardwareBufferTensor` (Android) has a
4881            // `view_offset` that its own `map()` adds and its own `view()`
4882            // sets, but nothing writes it here yet, so restoring an offset
4883            // onto an already-built tensor is silently a no-op on this
4884            // backing -- a caller gets a map at the parent's origin. Not
4885            // reachable through `TensorDyn::import_descriptor` today
4886            // (`import_storage`'s DMABUF arm is `cfg(target_os = "linux")`),
4887            // so it is latent rather than silent. See
4888            // `edgefirst-python-common`'s `interop::apply_plane_offset` for
4889            // the full per-backing accounting. PBO and Shm were on this list
4890            // until Stage B of the client-side-state work gave them the arms
4891            // above.
4892            #[cfg(target_os = "android")]
4893            TensorStorage::Dma(_) => {}
4894        }
4895    }
4896
4897    /// Colorimetry metadata (`None` = undefined; never auto-filled).
4898    /// The CPU access declared for this tensor at allocation (see
4899    /// [`CpuAccess`]). Views share their parent's declaration.
4900    pub fn cpu_access(&self) -> CpuAccess {
4901        self.cpu_access
4902    }
4903
4904    /// Set the declared CPU access without re-allocating — crate-private:
4905    /// the declaration must reflect the underlying allocation's real
4906    /// capabilities (constructors and importers set it; arbitrary widening
4907    /// would defeat the contract).
4908    // Only the Android AHardwareBuffer importer derives a declaration from
4909    // an existing allocation's usage bits.
4910    #[cfg(target_os = "android")]
4911    pub(crate) fn set_cpu_access_unchecked(&mut self, access: CpuAccess) {
4912        self.cpu_access = access;
4913    }
4914
4915    /// The vendor tile-compression scheme recorded at allocation, or
4916    /// `None` for a linear layout. `Some` means the pixels live in a
4917    /// proprietary tile order: the row-stride accessors describe no
4918    /// meaningful linear layout and CPU maps are best-effort (see
4919    /// [`Compression`]). Only Android hardware-only allocations that
4920    /// requested compression record a scheme.
4921    pub fn compression(&self) -> Option<CompressionScheme> {
4922        self.compression
4923    }
4924
4925    /// Whether this tensor's memory is known to be physically contiguous:
4926    /// [`Contiguity::Contiguous`] for a DMA-BUF this crate allocated from
4927    /// the CMA heap, [`Contiguity::NonContiguous`] for one from the system
4928    /// heap, and [`Contiguity::Unknown`] for an imported fd and every other
4929    /// kind of memory.
4930    pub fn contiguity(&self) -> Contiguity {
4931        match &self.storage {
4932            #[cfg(target_os = "linux")]
4933            TensorStorage::Dma(d) => d.contiguity,
4934            _ => Contiguity::Unknown,
4935        }
4936    }
4937
4938    /// Record the compression scheme — crate-private: recording is an
4939    /// allocation-time fact ([`Tensor::image_desc`] sets it; arbitrary
4940    /// mutation would misdescribe the physical layout).
4941    // Only the Android allocation path records a scheme.
4942    #[cfg(target_os = "android")]
4943    pub(crate) fn set_compression_unchecked(&mut self, scheme: Option<CompressionScheme>) {
4944        self.compression = scheme;
4945    }
4946
4947    pub fn colorimetry(&self) -> Option<crate::Colorimetry> {
4948        self.colorimetry
4949    }
4950
4951    /// Attach/clear colorimetry metadata.
4952    pub fn set_colorimetry(&mut self, c: Option<crate::Colorimetry>) {
4953        self.colorimetry = c;
4954    }
4955
4956    /// Builder-style colorimetry attach.
4957    pub fn with_colorimetry(mut self, c: crate::Colorimetry) -> Self {
4958        self.colorimetry = Some(c);
4959        self
4960    }
4961
4962    /// Create a zero-copy sub-region view of this tensor's backing buffer.
4963    ///
4964    /// The returned tensor shares this tensor's allocation (no copy) and maps
4965    /// the window `[offset_bytes, offset_bytes + shape.product()*size_of::<T>())`
4966    /// measured from this tensor's own logical start. N sub-views into one
4967    /// parent can be written independently, enabling batched assembly into a
4968    /// single buffer. Identical semantics across `Mem` (shared `Arc`) and
4969    /// `Dma` (shared fd) backings.
4970    ///
4971    /// # Disjointness
4972    ///
4973    /// Independent writes are sound *only* when the windows do not overlap. The
4974    /// shared backing uses interior mutability (`UnsafeCell` cells), so two
4975    /// sub-views whose byte ranges intersect alias the same cells: writing one
4976    /// while reading or writing the other is a data race and therefore
4977    /// **undefined behaviour**. The caller is responsible for keeping the
4978    /// windows disjoint; this method does not check for overlap.
4979    ///
4980    /// # Errors
4981    ///
4982    /// - [`Error::InvalidOperation`] if the backing is not `Mem` or `Dma`, or
4983    ///   if `offset_bytes` is not a multiple of `align_of::<T>()`.
4984    /// - [`Error::InsufficientCapacity`] / [`Error::InvalidSize`] if the window
4985    ///   exceeds the parent allocation.
4986    pub(crate) fn subview(&self, offset_bytes: usize, shape: &[usize]) -> Result<Tensor<T>> {
4987        // Offset is absolute into the backing allocation: a sub-view of a
4988        // sub-view composes by adding this tensor's own offset.
4989        let abs_offset = self
4990            .plane_offset
4991            .unwrap_or(0)
4992            .checked_add(offset_bytes)
4993            .ok_or(Error::InvalidSize(offset_bytes))?;
4994        // Every backend exposes `view(offset, shape)` via `TensorTrait`, sharing
4995        // the resource AND `BufferIdentity` (unlike `from_fd`/`from_surface`,
4996        // which mint a fresh identity). The GL backend keys the import on the
4997        // shared identity so offset-distinct sub-views of one buffer reuse a
4998        // single import and address their window via `glViewport`. `Mem`/`Shm`
4999        // share via the allocation `Arc` / a cloned fd; `Pbo` via the GL-buffer
5000        // `Arc`; Linux DMA-BUF / macOS IOSurface via the shared fd / CFRetain.
5001        // `TensorStorage::view` performs the one remaining per-variant dispatch.
5002        let mut t = Tensor::wrap(self.storage.view(offset_bytes, shape)?);
5003        // Inherit the parent's image metadata so the view is a ready-to-use
5004        // sub-image (e.g. a `convert()` destination). The offset is applied
5005        // LAST because `set_format` deliberately clears it — the offset is a
5006        // structural property of the sub-region, not format-dependent metadata.
5007        if let Some(fmt) = self.format {
5008            t.set_format(fmt)?;
5009        }
5010        if let Some(rs) = self.row_stride {
5011            t.set_row_stride_unchecked(rs);
5012        }
5013        t.quantization = self.quantization.clone();
5014        // A sub-region of an image carries the parent's colorimetry — it is the
5015        // same pixels, same color encoding. Inherit it like the other image
5016        // metadata above so a sub-view is a faithful convert() source/target.
5017        t.set_colorimetry(self.colorimetry);
5018        // The declared CPU access is a property of the underlying
5019        // allocation, so every view shares the parent's declaration.
5020        t.cpu_access = self.cpu_access;
5021        // Likewise the recorded compression scheme: the view shares the
5022        // parent's physical layout.
5023        t.compression = self.compression;
5024        if abs_offset > 0 {
5025            t.set_plane_offset(abs_offset);
5026        }
5027        Ok(t)
5028    }
5029
5030    /// Borrow batch element `n` of a batched tensor as a zero-copy view.
5031    ///
5032    /// A batched tensor prepends `N` as the leading dimension over the
5033    /// per-element image layout (`[N, H, W, C]` packed or `[N, C, H, W]`
5034    /// planar) — `N` is over the whole per-element block regardless of
5035    /// `HWC`/`CHW`. `batch(n)` returns element `n`: the contiguous per-element
5036    /// region at byte offset `n * element_size`, sharing the parent's
5037    /// `BufferIdentity` and inheriting its format / row stride / colorimetry.
5038    /// `batch(0)` on a tensor with `N == 1` is equivalent to the whole tensor.
5039    ///
5040    /// # Errors
5041    ///
5042    /// - [`Error::BatchIndexOutOfBounds`] if `n >= N`.
5043    /// - [`Error::InvalidShape`] if the tensor is not batched (a formatted
5044    ///   tensor whose rank lacks the leading `N`, or an empty shape).
5045    pub fn batch(&self, n: usize) -> Result<Tensor<T>> {
5046        let shape = self.shape();
5047        // With a format we know the exact per-element rank, so a missing leading
5048        // `N` is a misuse we reject rather than silently treating a spatial dim
5049        // as the batch. Raw tensors take shape[0] as `N` by contract.
5050        if let Some(fmt) = self.format {
5051            let elem_rank = image_rank(fmt.layout());
5052            if shape.len() != elem_rank + 1 {
5053                return Err(Error::InvalidShape(format!(
5054                    "batch(): tensor is not batched ({fmt:?} expects a leading N over a \
5055                     {elem_rank}-D element, got shape {shape:?})"
5056                )));
5057            }
5058        }
5059        let batch = *shape
5060            .first()
5061            .ok_or_else(|| Error::InvalidShape("batch(): empty shape".into()))?;
5062        if n >= batch {
5063            return Err(Error::BatchIndexOutOfBounds { index: n, batch });
5064        }
5065        let elem_shape: Vec<usize> = shape[1..].to_vec();
5066        let elem_count: usize = elem_shape.iter().product();
5067        elem_count
5068            .checked_mul(std::mem::size_of::<T>())
5069            .ok_or(Error::InvalidSize(elem_count))?;
5070        // One element's footprint including row padding: the descriptor's
5071        // stride for `N`, so a padded batch is addressed the way it is mapped.
5072        let dims: Vec<u64> = shape.iter().map(|&d| d as u64).collect();
5073        let elem_bytes = crate::protocol::c_byte_strides(
5074            &dims,
5075            std::mem::size_of::<T>() as i64,
5076            self.format.map(|f| f.layout()),
5077            self.row_stride,
5078        )[0] as usize;
5079        let offset = n.checked_mul(elem_bytes).ok_or(Error::InvalidSize(n))?;
5080        // For a packed `[N, H, W, C]` tensor the N tiles stack vertically in the
5081        // shared buffer, so the GL import sees one `(W, N*H)` parent and each
5082        // tile is the row-band at `y = n*H`. Snapshot that parent so the backend
5083        // imports once and renders the tile via `glViewport`. Non-packed
5084        // (planar/semi-planar) batching keeps the per-slot path for now (planar
5085        // NCHW tiling is a separate step); raw tensors have no pixel geometry.
5086        let view_origin = match self.format.map(|f| f.layout()) {
5087            Some(PixelLayout::Packed) => {
5088                let tile_h = elem_shape[0];
5089                let tile_w = elem_shape[1];
5090                // Per-row pitch of the tall `(W, N*H)` parent — padded stride if
5091                // set, else the tight row width. The GL import keys on this.
5092                let parent_stride = self
5093                    .effective_row_stride()
5094                    .expect("a formatted tensor of its batched rank has a width");
5095                Some(self.compose_view_origin(tile_w, batch * tile_h, parent_stride, 0, n * tile_h))
5096            }
5097            _ => None,
5098        };
5099        let mut t = self.subview(offset, &elem_shape)?;
5100        t.view_origin = view_origin;
5101        Ok(t)
5102    }
5103
5104    /// Borrow a rectangular spatial sub-region of an image tensor as a
5105    /// zero-copy view — the **destination/source crop** primitive.
5106    ///
5107    /// `region` is in pixels of the image's leading frame. The returned view
5108    /// shares the parent's `BufferIdentity` and addresses the sub-rectangle by
5109    /// offset + the **parent's** row pitch (so each row lands at the correct
5110    /// columns). `convert(src, &mut dst.view(rect), …)` renders into that
5111    /// sub-rectangle of `dst`; a letterbox fit then clears the view and renders
5112    /// the aspect-preserved content into its inner region. `view`/`batch`/the
5113    /// whole tensor are the one coherent destination model — there is no
5114    /// separate `dst_rect`.
5115    ///
5116    /// # Known limitation: bottom-right multi-row views
5117    ///
5118    /// A multi-row view is mapped as `parent_pitch × rows` bytes **from its own
5119    /// offset**, so the window it asks for overruns the allocation by
5120    /// `region.x * bpp` whenever the view is both offset horizontally *and*
5121    /// reaches the parent's last row. Such a view constructs fine but fails at
5122    /// [`map`](TensorTrait::map) time with [`Error::InsufficientCapacity`] —
5123    /// loudly, never as an out-of-bounds access. For a tiled destination this is
5124    /// the bottom row of tiles at every column but the first. The single-row
5125    /// case is already special-cased below (it keeps its own tight stride); the
5126    /// general fix is to clamp the last row's exposure to the view's own row
5127    /// bytes, which is not yet implemented.
5128    ///
5129    /// # Errors
5130    ///
5131    /// - [`Error::RegionOutOfBounds`] if `region` exceeds the image bounds.
5132    /// - [`Error::InvalidOperation`] if the tensor is not a packed-format image
5133    ///   (planar/semi-planar spatial sub-rects are not a single strided window;
5134    ///   use [`batch`](Self::batch) for batched planar tensors).
5135    pub fn view(&self, region: Region) -> Result<Tensor<T>> {
5136        let fmt = self.format.ok_or_else(|| {
5137            Error::InvalidOperation("view() requires a formatted image tensor".into())
5138        })?;
5139        if fmt.layout() != PixelLayout::Packed {
5140            return Err(Error::InvalidOperation(format!(
5141                "view() supports packed formats only (got {fmt:?}); use batch(n) for batched \
5142                 planar tensors"
5143            )));
5144        }
5145        let w = self
5146            .width()
5147            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image width".into()))?;
5148        let h = self
5149            .height()
5150            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image height".into()))?;
5151        if !region.fits_within(w, h) {
5152            return Err(Error::RegionOutOfBounds {
5153                region,
5154                bounds: (w, h),
5155            });
5156        }
5157        let elem = std::mem::size_of::<T>();
5158        let bpp = fmt.channels() * elem;
5159        let stride = self
5160            .effective_row_stride()
5161            .expect("a tensor with an image width has a row stride");
5162        let offset = region
5163            .y
5164            .checked_mul(stride)
5165            .and_then(|yo| yo.checked_add(region.x.checked_mul(bpp)?))
5166            .ok_or(Error::InvalidSize(region.y))?;
5167        let sub_shape = fmt
5168            .allocation_shape(region.width, region.height)
5169            .ok_or_else(|| Error::InvalidShape(format!("view(): invalid shape for {fmt:?}")))?;
5170        let mut t = self.subview(offset, &sub_shape)?;
5171        // A multi-row sub-rect must advance rows by the PARENT pitch so each row
5172        // addresses the correct columns. A single-row view uses its own tight
5173        // stride — the parent pitch would make the strided `map()` (which exposes
5174        // `stride × rows`) expose a trailing row that runs past the buffer tail
5175        // for an offset (x>0 / bottom) view. The GL backend does NOT rely on this
5176        // (single-row-tight) `row_stride`: it reads the parent pitch from
5177        // `view_origin.parent_row_stride` so its import/cache pitch stays
5178        // parent-consistent for views of any height — see `ViewOrigin`.
5179        let view_stride = if region.height > 1 {
5180            stride
5181        } else {
5182            region.width * bpp
5183        };
5184        t.set_row_stride_unchecked(view_stride);
5185        // Snapshot the parent `(w, h, row_stride)` so the GL backend imports the
5186        // parent once (keyed on the parent pitch, not this view's possibly-tight
5187        // single-row stride) and renders this sub-rect as a `glViewport`/
5188        // `glScissor` ROI at `(region.x, region.y)`. Composes when viewing an
5189        // existing view.
5190        t.view_origin = Some(self.compose_view_origin(w, h, stride, region.x, region.y));
5191        Ok(t)
5192    }
5193
5194    /// Build the [`ViewOrigin`] for a new sub-region of `self`. When `self` is a
5195    /// whole tensor the snapshot names `self` as the parent; when `self` is
5196    /// already a view, the snapshot keeps the **root** parent and accumulates the
5197    /// local origin so nested views still resolve to one import.
5198    fn compose_view_origin(
5199        &self,
5200        parent_width: usize,
5201        parent_height: usize,
5202        parent_row_stride: usize,
5203        x: usize,
5204        y: usize,
5205    ) -> ViewOrigin {
5206        match self.view_origin {
5207            Some(root) => ViewOrigin {
5208                parent_width: root.parent_width,
5209                parent_height: root.parent_height,
5210                parent_row_stride: root.parent_row_stride,
5211                x: root.x.saturating_add(x),
5212                y: root.y.saturating_add(y),
5213            },
5214            None => ViewOrigin {
5215                parent_width,
5216                parent_height,
5217                parent_row_stride,
5218                x,
5219                y,
5220            },
5221        }
5222    }
5223
5224    /// The wrapped `PboTensor`, for this module's own PBO accessors.
5225    ///
5226    /// Crate-private: `PboTensor<T>` is a `static`-side value with no
5227    /// `dynamic` counterpart to lend (there the buffer lives inside
5228    /// `libedgefirst_tensor.so`), so a public borrow of it could not have
5229    /// the same signature on both backends. Callers use
5230    /// [`Self::pbo_id`]/[`Self::pbo_is_mapped`], which are the whole surface
5231    /// `edgefirst-image` ever used.
5232    pub(crate) fn as_pbo(&self) -> Option<&PboTensor<T>> {
5233        match &self.storage {
5234            TensorStorage::Pbo(p) => Some(p),
5235            _ => None,
5236        }
5237    }
5238
5239    /// The GL buffer ID backing this tensor (for GL backends / the
5240    /// cross-package protocol's `handle`). `None` when the tensor is not
5241    /// PBO-backed.
5242    pub fn pbo_id(&self) -> Option<u32> {
5243        self.as_pbo().map(PboTensor::buffer_id)
5244    }
5245
5246    /// Whether this PBO currently holds -- or is establishing -- a CPU
5247    /// mapping. `None` when the tensor is not PBO-backed.
5248    ///
5249    /// The question a GL caller is really asking is "is this buffer free
5250    /// for GL operations?", so a map or unmap still in flight answers
5251    /// `Some(true)`: the GL side must not touch the buffer until the
5252    /// transition finishes. See [`PboTensor::is_mapped`].
5253    pub fn pbo_is_mapped(&self) -> Option<bool> {
5254        self.as_pbo().map(PboTensor::is_mapped)
5255    }
5256
5257    /// The C-ABI `PboOpsVtable` address backing this tensor (for the
5258    /// cross-package protocol's `ptr` under `kind::PBO`; see
5259    /// [`crate::protocol::TensorDesc::ptr`]'s doc comment). `None` when the
5260    /// tensor is not PBO-backed.
5261    pub fn pbo_vtable_ptr(&self) -> Option<*const std::ffi::c_void> {
5262        self.as_pbo()
5263            .map(|p| p.pbo_vtable() as *const _ as *const std::ffi::c_void)
5264    }
5265
5266    /// A type-erased keepalive that must stay alive for at least as long as
5267    /// [`Self::pbo_vtable_ptr`]'s address is used -- see
5268    /// [`PboTensor::pbo_keepalive`]'s own doc comment. `None` when the
5269    /// tensor is not PBO-backed.
5270    pub fn pbo_keepalive(&self) -> Option<Arc<dyn Send + Sync>> {
5271        self.as_pbo().map(PboTensor::pbo_keepalive)
5272    }
5273
5274    /// Downcast to DMA tensor reference (for EGL import, G2D).
5275    #[cfg(target_os = "linux")]
5276    pub fn as_dma(&self) -> Option<&DmaTensor<T>> {
5277        match &self.storage {
5278            TensorStorage::Dma(d) => Some(d),
5279            _ => None,
5280        }
5281    }
5282
5283    /// Borrow the DMA-BUF file descriptor backing this tensor.
5284    ///
5285    /// # Returns
5286    ///
5287    /// A borrowed reference to the DMA-BUF file descriptor, tied to `self`'s
5288    /// lifetime.
5289    ///
5290    /// # Errors
5291    ///
5292    /// Returns `Error::NotImplemented` if the tensor is not DMA-backed.
5293    #[cfg(target_os = "linux")]
5294    pub fn dmabuf(&self) -> Result<std::os::fd::BorrowedFd<'_>> {
5295        use std::os::fd::AsFd;
5296        match &self.storage {
5297            TensorStorage::Dma(dma) => Ok(dma.fd.as_fd()),
5298            _ => Err(Error::NotImplemented(format!(
5299                "dmabuf requires DMA-backed tensor, got {:?}",
5300                self.storage.memory()
5301            ))),
5302        }
5303    }
5304
5305    /// Construct a Tensor from a PBO tensor (for GL backends that allocate PBOs).
5306    ///
5307    /// Infallible in this backend (`Ok` always) -- kept as `Result` to match
5308    /// `dynamic`'s own `Tensor::from_pbo` (`dynamic_tensor.rs`), which
5309    /// genuinely can fail: it must allocate a real `ef_tensor_*` handle
5310    /// alongside `pbo` to carry shape/dtype/format metadata (this backend
5311    /// has nowhere else to put it), an allocation this backend never
5312    /// needed since it just wraps `pbo` into `TensorStorage::Pbo` directly.
5313    /// One shared, fallible signature lets `edgefirst-image`'s own call
5314    /// sites (`gl/threaded.rs`) stay backend-agnostic.
5315    pub fn from_pbo(pbo: PboTensor<T>) -> Result<Self> {
5316        Ok(Self {
5317            storage: TensorStorage::Pbo(pbo),
5318            format: None,
5319            chroma: None,
5320            row_stride: None,
5321            plane_offset: None,
5322            quantization: None,
5323            cuda: None,
5324            colorimetry: None,
5325            cpu_access: CpuAccess::ReadWrite,
5326            compression: None,
5327            view_origin: None,
5328        })
5329    }
5330
5331    /// The CUDA registration for this tensor, if any (set at creation on CUDA devices).
5332    pub fn cuda(&self) -> Option<&crate::cuda::CudaHandle> {
5333        self.cuda.as_ref()
5334    }
5335
5336    /// Whether a CUDA registration is attached to this tensor.
5337    pub fn is_cuda_attached(&self) -> bool {
5338        self.cuda.is_some()
5339    }
5340
5341    /// Attach a CUDA handle (called by ImageProcessor::create_image after registering a PBO).
5342    pub fn set_cuda_handle(&mut self, h: crate::cuda::CudaHandle) {
5343        self.cuda = Some(h);
5344    }
5345
5346    /// Fast-fail CUDA map: None (no GL routing) when no handle; else map (PBO routes to the GL worker).
5347    ///
5348    /// Returns a scoped [`CudaMap`] guard holding the raw CUDA device pointer
5349    /// for the duration of the mapping. For GL-buffer-backed tensors the unmap is deferred until the
5350    /// guard drops, freeing the PBO for the next `convert()` call. When no CUDA handle is attached
5351    /// (the common case for plain `Mem`/`DMA` tensors without CUDA registration), returns `None`
5352    /// immediately — no GL routing, no allocation.
5353    ///
5354    /// # Example — zero-copy CUDA input with host fallback
5355    ///
5356    /// ```no_run
5357    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
5358    /// # fn feed_tensorrt(_dptr: *mut std::ffi::c_void, _bytes: usize) {}
5359    /// # fn demo(t: &Tensor<f32>) {
5360    /// // Try the zero-copy CUDA device pointer first.
5361    /// if let Some(cuda) = t.cuda_map() {
5362    ///     feed_tensorrt(cuda.device_ptr(), cuda.len());
5363    ///     // `cuda` (a CudaMap guard) unmaps when it goes out of scope, freeing
5364    ///     // the GPU buffer for the next convert().
5365    /// } else {
5366    ///     // Fall back to the host mapping when no CUDA handle is attached.
5367    ///     let _host = t.map().expect("host map fallback must succeed");
5368    ///     // `_host` is a TensorMap<f32> that derefs to &[f32].
5369    /// }
5370    /// # }
5371    /// ```
5372    ///
5373    /// # D3D11 texture tensors: the mapping is tight rows
5374    ///
5375    /// On Windows the mapping is a linear copy of the texture laid out at
5376    /// `width * bytes_per_texel`, and `len()` is that times the row count --
5377    /// *not* [`row_stride`](Self::row_stride), which reports the D3D11 staging
5378    /// pitch a CPU map sees and is the larger number on a padded backing. A
5379    /// consumer that strides the device pointer by `row_stride()` reads past
5380    /// the end of the mapping.
5381    ///
5382    /// Semi-planar formats (NV12, NV16, NV24) are the exception: their texture
5383    /// is allocated as wide as its own row pitch, so the tight row *is* the
5384    /// pitch and the mapping equals `row_stride() x combined_height` bytes.
5385    /// Striding the device pointer by `row_stride()` is exactly right there.
5386    pub fn cuda_map(&self) -> Option<crate::cuda::CudaMap<'_>> {
5387        self.cuda.as_ref()?.map()
5388    }
5389
5390    /// Writable counterpart of [`cuda_map`](Self::cuda_map). See
5391    /// [`CudaHandle::map_mut`](crate::cuda::CudaHandle::map_mut) for which
5392    /// backings distinguish the two.
5393    ///
5394    /// The D3D11 arm is the one that does: the guard's release copies the
5395    /// buffer back into the texture. Its layout is [`cuda_map`](Self::cuda_map)'s
5396    /// -- tight rows of `width * bytes_per_texel`, not `row_stride()` -- and
5397    /// writing it at the wrong pitch scrambles the image rather than failing.
5398    pub fn cuda_map_mut(&self) -> Option<crate::cuda::CudaMap<'_>> {
5399        self.cuda.as_ref()?.map_mut()
5400    }
5401
5402    /// Attempt to attach a CUDA `ExternalMemory` handle for DMA-backed tensors.
5403    ///
5404    /// On a CUDA-capable host, imports the DMA-BUF fd via
5405    /// `cudaImportExternalMemory(OpaqueFd)` and maps it to a device pointer.
5406    /// Sets `self.cuda` to a persistent `ExternalMem` handle on success. No-op
5407    /// if CUDA is unavailable, the tensor is not DMA-backed, or a handle is
5408    /// already set. Import failure is silently ignored — the tensor remains
5409    /// usable without a CUDA handle.
5410    ///
5411    /// # RUNTIME-UNVALIDATED
5412    ///
5413    /// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
5414    /// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
5415    /// by gpu-probe O5 on Orin. Best-effort: tensor creation never fails here.
5416    #[cfg(target_os = "linux")]
5417    pub fn try_init_dma_cuda(&mut self) {
5418        // Fast-path: already imported, CUDA not available, or not a DMA tensor.
5419        if self.cuda.is_some() || !crate::cuda::is_cuda_available() {
5420            return;
5421        }
5422        let (raw_fd, buf_size) = match &self.storage {
5423            TensorStorage::Dma(dma) => {
5424                use std::os::fd::AsRawFd;
5425                (dma.fd.as_raw_fd(), dma.buf_size)
5426            }
5427            _ => return,
5428        };
5429        if let Some((ext, dptr)) = crate::cuda::import_dma_fd(raw_fd, buf_size) {
5430            self.cuda = Some(crate::cuda::CudaHandle::new_external(ext, dptr, buf_size));
5431        }
5432    }
5433
5434    /// Attempt to attach a CUDA external-memory handle for D3D11 texture
5435    /// tensors — the Windows counterpart of
5436    /// [`try_init_dma_cuda`](Self::try_init_dma_cuda).
5437    ///
5438    /// Imports the texture's shared NT handle via
5439    /// `cudaImportExternalMemory(D3D11Resource)` and the process device's
5440    /// fence as an external semaphore, so [`cuda_map`](Self::cuda_map) hands
5441    /// out a linear device buffer whose refresh is ordered after the D3D11
5442    /// work a producer recorded with [`set_gpu_write`](Self::set_gpu_write).
5443    ///
5444    /// No-op when a handle is already attached or the tensor is not
5445    /// texture-backed. Import failure is silently ignored: the WARP adapter
5446    /// has no CUDA device at all, and a texture whose import fails is still a
5447    /// valid tensor whose `cuda_map()` is `None`.
5448    #[cfg(target_os = "windows")]
5449    pub fn try_init_d3d11_cuda(&mut self) {
5450        if self.cuda.is_some() {
5451            return;
5452        }
5453        let TensorStorage::Dma(tex) = &self.storage else {
5454            return;
5455        };
5456        if let Some(h) = tex.try_init_cuda() {
5457            self.cuda = Some(h);
5458        }
5459    }
5460}
5461
5462/// Windows D3D11 texture accessors. No `'static` bound: none of these needs
5463/// `dtype_of::<T>()`.
5464#[cfg(all(feature = "static", target_os = "windows"))]
5465impl<T> Tensor<T>
5466where
5467    T: Num + Clone + fmt::Debug + Send + Sync,
5468{
5469    /// The borrowed `ID3D11Texture2D*` backing this tensor, valid while the
5470    /// tensor lives. `None` when the tensor is not texture-backed.
5471    pub fn d3d11_texture(&self) -> Option<*mut std::ffi::c_void> {
5472        match &self.storage {
5473            TensorStorage::Dma(t) => Some(t.texture_ptr()),
5474            _ => None,
5475        }
5476    }
5477
5478    /// The texture geometry the HAL chose for this image: DXGI format, texel
5479    /// dimensions and the matching GL internal format. `None` when the tensor
5480    /// is not texture-backed.
5481    pub fn d3d11_layout(&self) -> Option<crate::d3d11_layout::D3d11ImageLayout> {
5482        match &self.storage {
5483            TensorStorage::Dma(t) => Some(t.layout()),
5484            _ => None,
5485        }
5486    }
5487
5488    /// A duplicated NT handle the caller owns: open it on a D3D12 device,
5489    /// another D3D11 device, or CUDA, or duplicate it into another process.
5490    /// Closing it does not affect this tensor.
5491    pub fn d3d11_shared_handle(&self) -> Result<std::os::windows::io::OwnedHandle> {
5492        match &self.storage {
5493            TensorStorage::Dma(t) => t.shared_handle(),
5494            _ => Err(Error::NotImplemented(
5495                "not a D3D11 texture tensor".to_owned(),
5496            )),
5497        }
5498    }
5499
5500    /// The fence handle plus value a GPU consumer waits on before reading this
5501    /// texture, or `None` when no GPU write has been recorded on it.
5502    pub fn gpu_completion(&self) -> Result<Option<crate::d3d11::GpuCompletion>> {
5503        match &self.storage {
5504            TensorStorage::Dma(t) => t.gpu_completion(),
5505            _ => Err(Error::NotImplemented(
5506                "not a D3D11 texture tensor".to_owned(),
5507            )),
5508        }
5509    }
5510
5511    /// Record that GPU work writing this texture completes at `value` of the
5512    /// process device's fence. Monotonic: an older value never displaces a
5513    /// newer one, so recording out of order is safe rather than merely
5514    /// tolerated.
5515    ///
5516    /// Takes `&self`, not `&mut self`: the fence slot is an `AtomicU64` and
5517    /// this is a `fetch_max` on it. A producer records a write on the very
5518    /// tensor its consumers already hold and are reading, so demanding
5519    /// exclusive access here would forbid the one call this exists for.
5520    pub fn set_gpu_write(&self, value: u64) -> Result<()> {
5521        match &self.storage {
5522            TensorStorage::Dma(t) => {
5523                t.set_gpu_write(value);
5524                Ok(())
5525            }
5526            _ => Err(Error::NotImplemented(
5527                "not a D3D11 texture tensor".to_owned(),
5528            )),
5529        }
5530    }
5531
5532    /// The tensor's own NT handle value -- not a duplicate -- for descriptors
5533    /// whose consumer keeps the producing tensor alive.
5534    ///
5535    /// Borrowed rather than duplicated because the C exports (task A6) hand
5536    /// out owned duplicates, which a `Copy` descriptor cannot close; the
5537    /// cross-package protocol's `kind::D3D11_TEXTURE` reads this instead.
5538    pub(crate) fn d3d11_shared_handle_value(&self) -> Option<usize> {
5539        match &self.storage {
5540            TensorStorage::Dma(t) => Some(t.shared_handle_value()),
5541            _ => None,
5542        }
5543    }
5544}
5545
5546/// The recorded GPU write value, on every platform: the descriptor path and
5547/// the Python bindings forward it without a `cfg` of their own, and only
5548/// Windows can record one.
5549#[cfg(feature = "static")]
5550impl<T> Tensor<T>
5551where
5552    T: Num + Clone + fmt::Debug + Send + Sync,
5553{
5554    /// The fence value of the newest GPU write recorded on this tensor, or 0
5555    /// when there is none: the `value` of `gpu_completion` without the
5556    /// duplicated fence handle, for a consumer that already holds the process
5557    /// fence. 0 for a tensor that is not a D3D11 texture, and on every
5558    /// platform other than Windows.
5559    pub fn gpu_write_value(&self) -> u64 {
5560        #[cfg(target_os = "windows")]
5561        {
5562            if let TensorStorage::Dma(t) = &self.storage {
5563                return t.last_gpu_write();
5564            }
5565        }
5566        0
5567    }
5568}
5569
5570/// Records the D3D11 backing's row pitch on `t` when it exceeds the natural
5571/// stride for its format and width.
5572///
5573/// The staging pitch is the row stride CPU consumers see, and for a
5574/// semi-planar tensor it is also the texture's own width, which the GL
5575/// backend's Path B shader takes as its `tex_width`. Three constructors need
5576/// it identically -- `Tensor::image`'s Windows arm and the two wrap
5577/// constructors below -- and a wrapped texture is the case that makes it
5578/// load-bearing: without it such a tensor reports the natural `even(width)`
5579/// stride while its texture is the driver's pitch wide, and the shader samples
5580/// a padded texture at the image width.
5581///
5582/// `t.format` must already be set: the natural stride comes from it.
5583#[cfg(all(feature = "static", target_os = "windows"))]
5584fn record_d3d11_row_stride<T>(t: &mut Tensor<T>, format: PixelFormat, what: &str)
5585where
5586    T: Num + Clone + fmt::Debug + Send + Sync,
5587{
5588    let pitch = match &t.storage {
5589        TensorStorage::Dma(tex) => tex.image_backing_row_stride(),
5590        _ => None,
5591    };
5592    let (Some(pitch), Some(natural)) = (pitch, t.effective_row_stride()) else {
5593        return;
5594    };
5595    if pitch > natural {
5596        log::debug!(
5597            "{what}: the D3D11 backing pitch for the {format:?} texture is {pitch} bytes \
5598             (natural {natural}); recording row stride"
5599        );
5600        t.set_row_stride_unchecked(pitch);
5601    }
5602}
5603
5604/// The two texture-importing constructors. Separate from the accessors above
5605/// because `dtype_of::<T>()` needs `T: 'static`.
5606#[cfg(all(feature = "static", target_os = "windows"))]
5607impl<T> Tensor<T>
5608where
5609    T: Num + Clone + fmt::Debug + Send + Sync + 'static,
5610{
5611    /// Wrap an existing `ID3D11Texture2D` as a tensor. The texture is checked
5612    /// against the layout the HAL would have chosen for `format`/`width`/
5613    /// `height` and must live on the HAL's process device; ownership stays
5614    /// with the caller, since this takes its own reference.
5615    ///
5616    /// The backing's row pitch is recorded as the tensor's
5617    /// [`row_stride`](Self::row_stride) when it exceeds the natural stride, so
5618    /// a wrapped semi-planar texture -- whose width *is* the pitch -- reports
5619    /// the geometry its consumers address it by.
5620    ///
5621    /// # Safety
5622    ///
5623    /// `texture` must be null or a live `ID3D11Texture2D` created on the HAL
5624    /// device ([`crate::d3d11::device()`]).
5625    pub unsafe fn from_d3d11_texture(
5626        texture: *mut std::ffi::c_void,
5627        width: usize,
5628        height: usize,
5629        format: PixelFormat,
5630        access: CpuAccess,
5631        name: Option<&str>,
5632    ) -> Result<Self> {
5633        let dtype = dtype_of::<T>().ok_or_else(|| {
5634            Error::InvalidArgument(format!(
5635                "from_d3d11_texture: element type {} has no DType",
5636                std::any::type_name::<T>()
5637            ))
5638        })?;
5639        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5640            Error::InvalidArgument(format!(
5641                "invalid dimensions {width}x{height} for format {format:?}"
5642            ))
5643        })?;
5644        // SAFETY: the caller guarantees `texture` is null or a live
5645        // `ID3D11Texture2D`; the storage takes its own reference.
5646        let storage = unsafe {
5647            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_texture(
5648                texture, width, height, format, dtype, &shape, access, name,
5649            )
5650        }?;
5651        let mut t = Self::wrap(TensorStorage::Dma(storage));
5652        t.format = Some(format);
5653        record_d3d11_row_stride(&mut t, format, "from_d3d11_texture");
5654        t.cpu_access = access;
5655        t.try_init_d3d11_cuda();
5656        Ok(t)
5657    }
5658
5659    /// Open a shared texture by its NT handle on the HAL's device. When
5660    /// `completion` is given, its fence value is waited for on the immediate
5661    /// context, so a same-device reader needs no further ordering. Both
5662    /// handles stay owned by the caller: this duplicates what it keeps.
5663    ///
5664    /// Records the backing's row pitch as
5665    /// [`row_stride`](Self::row_stride) on the same rule as
5666    /// [`from_d3d11_texture`](Self::from_d3d11_texture).
5667    ///
5668    /// # Safety
5669    ///
5670    /// `handle` must be an NT shared handle of a D3D11 texture, valid in this
5671    /// process, and `completion`'s handle a shared fence handle.
5672    pub unsafe fn from_d3d11_shared_handle(
5673        handle: std::os::windows::io::RawHandle,
5674        width: usize,
5675        height: usize,
5676        format: PixelFormat,
5677        access: CpuAccess,
5678        completion: Option<(std::os::windows::io::RawHandle, u64)>,
5679        name: Option<&str>,
5680    ) -> Result<Self> {
5681        let dtype = dtype_of::<T>().ok_or_else(|| {
5682            Error::InvalidArgument(format!(
5683                "from_d3d11_shared_handle: element type {} has no DType",
5684                std::any::type_name::<T>()
5685            ))
5686        })?;
5687        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5688            Error::InvalidArgument(format!(
5689                "invalid dimensions {width}x{height} for format {format:?}"
5690            ))
5691        })?;
5692        // SAFETY: the caller guarantees the handles are valid in this process.
5693        let storage = unsafe {
5694            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_shared_handle(
5695                handle, width, height, format, dtype, &shape, access, completion, name,
5696            )
5697        }?;
5698        let mut t = Self::wrap(TensorStorage::Dma(storage));
5699        t.format = Some(format);
5700        record_d3d11_row_stride(&mut t, format, "from_d3d11_shared_handle");
5701        t.cpu_access = access;
5702        t.try_init_d3d11_cuda();
5703        Ok(t)
5704    }
5705}
5706
5707// Quantization accessors — type-gated to integer element types via the
5708// sealed `IntegerType` trait. Calling `.quantization()` on a `Tensor<f32>`
5709// produces a compile error, not a runtime one.
5710#[cfg(feature = "static")]
5711impl<T> Tensor<T>
5712where
5713    T: IntegerType + Num + Clone + fmt::Debug + Send + Sync,
5714{
5715    /// Quantization metadata for this tensor, if set.
5716    pub fn quantization(&self) -> Option<&Quantization> {
5717        self.quantization.as_ref()
5718    }
5719
5720    /// Attach quantization metadata to this tensor. Validates against the
5721    /// tensor's shape — returns [`Error::QuantizationInvalid`] on any
5722    /// inconsistency (mismatched scale/zp lengths, out-of-range axis, etc.).
5723    pub fn set_quantization(&mut self, q: Quantization) -> Result<()> {
5724        q.validate(self.shape())?;
5725        self.quantization = Some(q);
5726        Ok(())
5727    }
5728
5729    /// Builder-style variant of [`Self::set_quantization`]. Consumes `self`
5730    /// and returns `Result<Self>` — on success yields the tensor with the
5731    /// attached quantization; on validation failure returns
5732    /// [`Error::QuantizationInvalid`] and drops `self` (the tensor is not
5733    /// returned in the error arm).
5734    pub fn with_quantization(mut self, q: Quantization) -> Result<Self> {
5735        self.set_quantization(q)?;
5736        Ok(self)
5737    }
5738
5739    /// Clear any quantization metadata on this tensor.
5740    pub fn clear_quantization(&mut self) {
5741        self.quantization = None;
5742    }
5743}
5744
5745/// Rank of one image's shape in `layout`: `[H, W, C]` / `[C, H, W]` are 3-D,
5746/// semi-planar `[H*k, W]` is 2-D. A batched tensor has one more.
5747#[cfg(feature = "static")]
5748fn image_rank(layout: PixelLayout) -> usize {
5749    match layout {
5750        PixelLayout::SemiPlanar => 2,
5751        PixelLayout::Packed | PixelLayout::Planar => 3,
5752    }
5753}
5754
5755#[cfg(feature = "static")]
5756fn map_strided_host_view<'a, T>(
5757    tensor: &Tensor<T>,
5758    stride: usize,
5759    access: CpuAccess,
5760    non_blocking: bool,
5761) -> Result<crate::view::HostView<'a, T>>
5762where
5763    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5764{
5765    // Rows sit at `stride`-byte spacing; see `strided_rows` for the count.
5766    let rows = strided_rows(tensor)?;
5767    let total_bytes = stride.checked_mul(rows).ok_or_else(|| {
5768        Error::InvalidOperation(format!(
5769            "Tensor::map: row_stride {stride} × rows {rows} overflows usize"
5770        ))
5771    })?;
5772    map_strided_storage(tensor, stride, rows, total_bytes, access, non_blocking)
5773}
5774
5775/// Rows a strided map or flat copy of `tensor` spans at its row stride: a
5776/// raw tensor's leading dim, else every image row of every batch element --
5777/// `H` per packed image, `C * H` per planar one (C planes of H rows), and the
5778/// combined `H*k` per semi-planar one. No product here can overflow: each is
5779/// a factor of the shape's element count, which the allocation already holds.
5780#[cfg(feature = "static")]
5781fn strided_rows<T>(tensor: &Tensor<T>) -> Result<usize>
5782where
5783    T: Num + Clone + fmt::Debug + Send + Sync,
5784{
5785    let shape = tensor.shape();
5786    let Some(fmt) = tensor.format else {
5787        return shape.first().copied().ok_or_else(|| {
5788            Error::InvalidOperation("strided access requires a non-empty shape".into())
5789        });
5790    };
5791    let (_, dims) = tensor.image_dims().ok_or_else(|| {
5792        Error::InvalidOperation(format!(
5793            "strided access to a {fmt:?} tensor requires its image shape, optionally \
5794             batched; got {shape:?}"
5795        ))
5796    })?;
5797    let per_image = match fmt.layout() {
5798        PixelLayout::Planar => dims[0] * dims[1],
5799        PixelLayout::Packed | PixelLayout::SemiPlanar => dims[0],
5800    };
5801    let batch: usize = shape[..shape.len() - dims.len()].iter().product();
5802    Ok(batch * per_image)
5803}
5804
5805#[cfg(feature = "static")]
5806fn map_strided_storage<'a, T>(
5807    tensor: &Tensor<T>,
5808    _stride: usize,
5809    _rows: usize,
5810    total_bytes: usize,
5811    access: CpuAccess,
5812    _non_blocking: bool,
5813) -> Result<crate::view::HostView<'a, T>>
5814where
5815    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5816{
5817    match &tensor.storage {
5818        #[cfg(target_os = "linux")]
5819        TensorStorage::Dma(dma) => {
5820            // `set_row_stride()` only validates `stride >= min_stride`,
5821            // not that `stride × rows` fits the DMA-BUF, so re-check
5822            // here — mapping past `buf_size` would SIGBUS on access.
5823            // `buf_size` is real kernel truth either way, not the
5824            // importer's say-so: for a self-allocated tensor it is
5825            // the allocation size; for an imported one it is
5826            // `fstat`-derived when the kernel reports a usable size
5827            // (`DmaTensor::from_fd`), or the declared logical size in
5828            // the rare fallback -- either way an out-of-range mmap
5829            // against the real dma-buf is still rejected by the
5830            // kernel, so this bound stays sound regardless.
5831            let available_bytes = dma.buf_size.saturating_sub(dma.mmap_offset);
5832            if total_bytes > available_bytes {
5833                return Err(Error::InvalidOperation(format!(
5834                    "Tensor::map: strided mapping needs {total_bytes} bytes \
5835                     but DMA buffer only has {available_bytes} available \
5836                     (buf_size={}, mmap_offset={}, stride={_stride}, rows={_rows}); \
5837                     the row_stride was likely set larger than the original allocation",
5838                    dma.buf_size, dma.mmap_offset
5839                )));
5840            }
5841            dma.map_with_byte_size(total_bytes, access)
5842        }
5843        TensorStorage::Mem(mem) => {
5844            let capacity = tensor.storage.capacity_bytes();
5845            if total_bytes > capacity {
5846                return Err(Error::InsufficientCapacity {
5847                    needed: total_bytes,
5848                    capacity,
5849                });
5850            }
5851            mem.map_with_byte_size(total_bytes, access)
5852        }
5853        #[cfg(unix)]
5854        TensorStorage::Shm(shm) => {
5855            let capacity = tensor.storage.capacity_bytes();
5856            if total_bytes > capacity {
5857                return Err(Error::InsufficientCapacity {
5858                    needed: total_bytes,
5859                    capacity,
5860                });
5861            }
5862            shm.map_with_byte_size(total_bytes, access)
5863        }
5864        // macOS/iOS: `TensorStorage::Dma` is the IOSurface. The lock yields
5865        // the full surface base address, and the row pitch
5866        // (`IOSurfaceGetBytesPerRow`) is known from the API for both
5867        // self-allocated and imported surfaces — unlike a foreign
5868        // DMA-BUF — so a strided CPU view is sound and zero-copy.
5869        #[cfg(any(target_os = "macos", target_os = "ios"))]
5870        TensorStorage::Dma(io) => {
5871            // A sub-view's window is `buf_size − view_offset`; the strided
5872            // span must fit the window, not the whole surface.
5873            let available = io.buf_size.saturating_sub(io.view_offset);
5874            if total_bytes > available {
5875                return Err(Error::InsufficientCapacity {
5876                    needed: total_bytes,
5877                    capacity: available,
5878                });
5879            }
5880            io.map_with_byte_size(total_bytes, access)
5881        }
5882        // Android: `TensorStorage::Dma` is the AHardwareBuffer. The lock
5883        // yields the full buffer base address, and the row pitch is
5884        // known from the allocator-filled descriptor — so a strided CPU
5885        // view is sound and zero-copy, same as IOSurface.
5886        #[cfg(target_os = "android")]
5887        TensorStorage::Dma(ahb) => {
5888            // A sub-view's window is `buf_size − view_offset`; the strided
5889            // span must fit the window, not the whole buffer.
5890            let available = ahb.buf_size.saturating_sub(ahb.view_offset);
5891            if total_bytes > available {
5892                return Err(Error::InsufficientCapacity {
5893                    needed: total_bytes,
5894                    capacity: available,
5895                });
5896            }
5897            ahb.map_with_byte_size(total_bytes, access)
5898        }
5899        // Windows: `TensorStorage::Dma` is the D3D11 texture, whose CPU side
5900        // is the staging copy at the driver's pitch -- the very pitch
5901        // `Tensor::image` recorded as this tensor's row stride. Its
5902        // `map_with_byte_size` bounds-checks the span against this tensor's
5903        // own window, as the IOSurface arm above does. This is also the only
5904        // backing with a real non-blocking map, so it is the only arm that
5905        // reads `_non_blocking`.
5906        #[cfg(target_os = "windows")]
5907        TensorStorage::Dma(tex) => {
5908            if _non_blocking {
5909                tex.try_map_with_byte_size(total_bytes, access)
5910            } else {
5911                tex.map_with_byte_size(total_bytes, access)
5912            }
5913        }
5914        TensorStorage::Pbo(pbo) => {
5915            // PBO: the GPU-side allocation may have a padded row stride
5916            // (e.g. 64-byte aligned). Expose the full padded buffer so a
5917            // CPU producer (JPEG decoder) and a strided convert source
5918            // can iterate rows via `effective_row_stride()` without
5919            // running past the slice — the logical `pbo.map()` view would
5920            // stop after `shape.product()` and lose bytes past row 0.
5921            // A sub-view's window is `capacity − view_offset`.
5922            let available = pbo.capacity_bytes().saturating_sub(pbo.view_offset);
5923            if total_bytes > available {
5924                return Err(Error::InsufficientCapacity {
5925                    needed: total_bytes,
5926                    capacity: available,
5927                });
5928            }
5929            pbo.map_with_byte_size(total_bytes, access)
5930        }
5931        // Every `TensorStorage` variant this build can construct is
5932        // matched explicitly above (Mem/Shm/Dma/Pbo, each cfg-gated
5933        // to the platforms it exists on) -- this catch-all exists
5934        // only so the match stays exhaustive across every
5935        // OS/cfg combination without a matching arm on every one of
5936        // them, not because any variant is meant to fall through to
5937        // it. `#[allow(unreachable_patterns)]` because on any given
5938        // platform it genuinely is unreachable.
5939        #[allow(unreachable_patterns)]
5940        _ => Err(Error::InvalidOperation(
5941            "CPU mapping of strided tensors is supported only for HAL-allocated \
5942             Mem/Shm (any platform), DMA (Linux, self-allocated or imported), \
5943             IOSurface (macOS), AHardwareBuffer (Android), D3D11 textures \
5944             (Windows), and PBO"
5945                .into(),
5946        )),
5947    }
5948}
5949
5950#[cfg(feature = "static")]
5951impl<T> Tensor<T>
5952where
5953    T: Num + Clone + fmt::Debug + Send + Sync,
5954{
5955    /// The shared body of [`TensorTrait::map_with`] and
5956    /// [`TensorTrait::try_map_with`]: the direction check, the
5957    /// declared-vs-requested telemetry, the strided and plane-offset routing,
5958    /// and finally the backing's own map. `non_blocking` chooses which of the
5959    /// backing's two maps that last step takes; everything before it is the
5960    /// same either way.
5961    fn map_impl<'a>(
5962        &self,
5963        access: CpuAccess,
5964        non_blocking: bool,
5965    ) -> Result<crate::view::HostView<'a, T>>
5966    where
5967        T: 'a,
5968    {
5969        let _span = tracing::trace_span!(
5970            "tensor.map",
5971            memory = ?self.storage.memory(),
5972            ?access,
5973            non_blocking,
5974        )
5975        .entered();
5976        if access == CpuAccess::None {
5977            return Err(Error::InvalidArgument(
5978                "map_with(CpuAccess::None) is not a mappable direction — use \
5979                 map_read()/map_write()/map_mut()"
5980                    .into(),
5981            ));
5982        }
5983        // Declared-vs-requested telemetry (all platforms): mapping beyond
5984        // the allocation-time declaration is best-effort — tolerated where
5985        // the backing is CPU-mappable regardless (Mem/Shm/dma-buf/
5986        // IOSurface), refused by the Android backend for CpuAccess::None
5987        // buffers — but always loud and counted, never silent.
5988        if !self.cpu_access.covers(access) {
5989            note_unplanned_cpu_access(
5990                self.buffer_identity().id(),
5991                &format!("{:?}", self.storage.memory()),
5992                "map access exceeds the declared CpuAccess",
5993            );
5994        }
5995        // CPU mapping of a strided tensor exposes the full padded buffer
5996        // (`row_stride × rows`) so callers can iterate rows via
5997        // `effective_row_stride()` without running past the slice. This is sound
5998        // only when the HAL owns and can size-check the allocation:
5999        //
6000        //   * Self-allocated Mem / Shm tensors (any platform) — the backing
6001        //     `Vec` / shm segment is sized by `capacity_bytes()`, checked here.
6002        //   * Self-allocated DMA tensors (Linux) — pitch padding from
6003        //     `image_with_stride()`; checked against the DMA-BUF `buf_size`.
6004        //
6005        //   * Self-allocated PBO tensors (any platform with GL) — the GL buffer
6006        //     is sized by `capacity_bytes()` and may carry 64-byte row padding;
6007        //     the JPEG decoder mmaps it and convert() reads it, both iterating
6008        //     by `row_stride`. Checked against the PBO capacity below.
6009        //
6010        // A strided **imported** DMA-BUF is accepted here too (Task 10b): the
6011        // `stride × rows <= buf_size` check just below is exactly what a
6012        // self-allocated Dma tensor relies on for its own soundness, and
6013        // `buf_size` for an imported fd is `fstat`-derived when the kernel
6014        // reports a usable size (`stat.st_size > 0` and at least the
6015        // declared shape's logical size; `DmaTensor::from_fd`) -- kernel
6016        // truth, not a caller's claim, in that case. The rare fallback (a
6017        // kernel that reports `st_size <= 0` for a DMA-BUF fd) uses the
6018        // declared logical size instead; that is not a hole here either, since
6019        // an out-of-range mmap against the real dma-buf is still rejected by
6020        // the kernel regardless of what this crate believed `buf_size` was.
6021        // So the check is equally load-bearing regardless of who allocated
6022        // the buffer. What
6023        // changed is only *which* callers can now reach this path: the
6024        // cross-package capsule protocol reconstructs an imported alias of a
6025        // HAL-owned Dma tensor and then calls `configure_image`, which
6026        // computes its stride with the exact same alignment rule a
6027        // self-allocated `Tensor::image()` used — so the value being
6028        // bounds-checked here is HAL-computed and format/width-derived, not
6029        // read from an external driver. Rejecting it unconditionally used to
6030        // make every foreign-imported GPU-backed destination fail any CPU
6031        // write path (the JPEG decoder's MCU writer among them) even when the
6032        // write was provably in-bounds. `set_row_stride()` was already
6033        // "trust the caller, bounds-check the map" for every *other* backing
6034        // (Mem/Shm/self-allocated Dma/PBO) — singling out imported Dma out
6035        // for a stricter standard was the inconsistency, not the trust model
6036        // itself. IOSurface keeps its own always-allowed strided path
6037        // unconditionally (see its arm below): a live `IOSurfaceRef` reports
6038        // its real `bytesPerRow` from the OS regardless of who allocated it,
6039        // so there was never anything to distrust there.
6040        if let Some(stride) = self.row_stride {
6041            return map_strided_host_view(self, stride, access, non_blocking);
6042        }
6043        // A non-zero plane offset needs no check here: every storage applies
6044        // its own offset inside its map -- Mem/Shm adjust the slice base, the
6045        // Linux DMA-BUF and Android AHardwareBuffer the mapped base, IOSurface
6046        // and D3D11 textures add their `view_offset`, and PBO starts the
6047        // staged copy at it.
6048        if non_blocking {
6049            self.storage.try_map_with(access)
6050        } else {
6051            self.storage.map_with(access)
6052        }
6053    }
6054}
6055
6056#[cfg(all(
6057    feature = "static",
6058    any(
6059        target_os = "macos",
6060        target_os = "ios",
6061        target_os = "android",
6062        target_os = "windows"
6063    )
6064))]
6065impl<T> Tensor<T>
6066where
6067    T: Num + Clone + fmt::Debug + Send + Sync,
6068{
6069    /// The pitched-backing half of [`TensorTrait::set_logical_shape`]: record
6070    /// the backing's own pitch as the row stride of the new `shape`.
6071    fn adopt_backing_pitch(&mut self, shape: &[usize]) {
6072        // Only a backing with a pitch of its own, and only while the shape
6073        // still has the rank the format's row is measured on: a caller may
6074        // flatten an image to one dimension and leave the format behind.
6075        let Some(pitch) = self.storage.backing_row_stride() else {
6076            return;
6077        };
6078        let Some((rank, rows)) = self.format.map(|f| match f.layout() {
6079            PixelLayout::Packed => (3, shape.first().copied()),
6080            PixelLayout::Planar => (3, shape.get(1).copied()),
6081            PixelLayout::SemiPlanar => (2, shape.first().copied()),
6082        }) else {
6083            return;
6084        };
6085        if rank != shape.len() {
6086            return;
6087        }
6088        // The natural stride of the new shape, not of the stride recorded for
6089        // the old one.
6090        let prior = self.row_stride.take();
6091        let Some(natural) = self.effective_row_stride() else {
6092            self.row_stride = prior;
6093            return;
6094        };
6095        if rows == Some(1) {
6096            self.set_row_stride_unchecked(natural);
6097        } else if pitch > natural {
6098            self.set_row_stride_unchecked(pitch);
6099        } else {
6100            self.row_stride = prior;
6101        }
6102    }
6103}
6104
6105#[cfg(feature = "static")]
6106impl<T> TensorTrait<T> for Tensor<T>
6107where
6108    T: Num + Clone + fmt::Debug + Send + Sync,
6109{
6110    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
6111    where
6112        Self: Sized,
6113    {
6114        Self::new(shape, None, name)
6115    }
6116
6117    #[cfg(unix)]
6118    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
6119    where
6120        Self: Sized,
6121    {
6122        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
6123        let mut t = Self::wrap(TensorStorage::from_fd(fd, shape, name)?);
6124        // Best-effort CUDA external memory import for DMA-backed tensors.
6125        // RUNTIME-UNVALIDATED: see try_init_dma_cuda().
6126        #[cfg(target_os = "linux")]
6127        t.try_init_dma_cuda();
6128        Ok(t)
6129    }
6130
6131    #[cfg(unix)]
6132    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
6133        self.storage.clone_fd()
6134    }
6135
6136    fn memory(&self) -> TensorMemory {
6137        self.storage.memory()
6138    }
6139
6140    fn name(&self) -> String {
6141        self.storage.name()
6142    }
6143
6144    fn shape(&self) -> &[usize] {
6145        self.storage.shape()
6146    }
6147
6148    /// Bytes available in the underlying allocation, from this tensor's
6149    /// window start -- may exceed [`TensorTrait::size`] when the backing
6150    /// carries spare room beyond the current logical shape (a pitch-aligned
6151    /// image, or a pool tensor sized for a larger image than it holds
6152    /// today). See [`crate::protocol::TensorDesc::capacity`], the
6153    /// cross-package descriptor field this backs.
6154    fn capacity_bytes(&self) -> usize {
6155        self.storage.capacity_bytes()
6156    }
6157
6158    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
6159        if self.chroma.is_some() {
6160            return Err(Error::InvalidOperation(
6161                "cannot reshape a multiplane tensor — decompose planes first".into(),
6162            ));
6163        }
6164        self.storage.reshape(shape)?;
6165        self.format = None;
6166        self.row_stride = None;
6167        self.plane_offset = None;
6168        match self.storage {
6169            TensorStorage::Mem(ref mut m) => m.set_offset(0),
6170            #[cfg(target_os = "linux")]
6171            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
6172            // No-op: `IoSurfaceTensor::reshape` (iosurface.rs) zeroes its
6173            // own `view_offset` before this match runs.
6174            #[cfg(any(target_os = "macos", target_os = "ios"))]
6175            TensorStorage::Dma(_) => {}
6176            // `D3d11TextureTensor::reshape` does not self-zero, so the clear
6177            // that pairs with `set_plane_offset`'s D3D11 arm lives here.
6178            #[cfg(target_os = "windows")]
6179            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
6180            // No-op: `AHardwareBufferTensor::reshape` (ahardwarebuffer.rs)
6181            // zeroes its own `view_offset` before this match runs, the same
6182            // as `IoSurfaceTensor`. Unlike `set_plane_offset`'s Android arm,
6183            // this one is a genuine self-zeroing no-op, not a still-
6184            // incomplete gap: `view_offset` never survives a reshape here
6185            // regardless of whether `set_plane_offset` can restore it.
6186            #[cfg(target_os = "android")]
6187            TensorStorage::Dma(_) => {}
6188            // No-op: `PboTensor::reshape` (pbo.rs) zeroes its own
6189            // `view_offset` before this match runs, the same as
6190            // `IoSurfaceTensor`/`AHardwareBufferTensor` above -- which is
6191            // why, unlike `set_format`'s clear block, this match never
6192            // needed a `Pbo` arm that writes anything.
6193            TensorStorage::Pbo(_) => {}
6194            // Unlike `PboTensor::reshape`, `ShmTensor::reshape` (shm.rs)
6195            // only revalidates the element count and swaps `self.shape` --
6196            // it never touches `self.offset` -- so this clear site needs
6197            // the arm `set_format`'s does. `cfg(unix)` mirrors the `Shm`
6198            // variant's own gate, not a per-OS multiplex.
6199            #[cfg(unix)]
6200            TensorStorage::Shm(ref mut s) => s.set_offset(0),
6201        }
6202        Ok(())
6203    }
6204
6205    /// Set the logical shape to any shape whose bytes fit the allocation,
6206    /// without [`reshape`](TensorTrait::reshape)'s equal-count constraint.
6207    ///
6208    /// Forwards to the storage's capacity-aware implementation -- the same
6209    /// one [`Tensor::configure_image`] uses for pool reuse, a decode into an
6210    /// oversized reusable buffer reconfiguring it to a smaller image without
6211    /// reallocating -- so the two agree. The pixel format is kept.
6212    ///
6213    /// A narrower image inside a pitched backing (an IOSurface, an
6214    /// AHardwareBuffer, a D3D11 texture) still advances rows by the backing's
6215    /// pitch, so that pitch is recorded as the row stride whenever it exceeds
6216    /// the new shape's natural stride -- the same adoption `configure_image`
6217    /// makes. Without it a texture narrowed from 64 to 16 texels wide reports
6218    /// a 64-byte stride over rows that are 256 bytes apart. A single-row
6219    /// shape records its own tight stride instead, by [`view`](Self::view)'s
6220    /// rule: a strided `map()` spans `stride * rows`, and one pitched row
6221    /// runs past the backing when the window sits in the last row.
6222    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
6223        self.storage.set_logical_shape(shape)?;
6224        #[cfg(any(
6225            target_os = "macos",
6226            target_os = "ios",
6227            target_os = "android",
6228            target_os = "windows"
6229        ))]
6230        self.adopt_backing_pitch(shape);
6231        Ok(())
6232    }
6233
6234    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6235    where
6236        T: 'a,
6237    {
6238        self.map_impl(access, false)
6239    }
6240
6241    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6242    where
6243        T: 'a,
6244    {
6245        self.map_impl(access, true)
6246    }
6247
6248    fn buffer_identity(&self) -> &BufferIdentity {
6249        self.storage.buffer_identity()
6250    }
6251}
6252
6253// ============================================================================
6254// Platform availability helpers
6255// ============================================================================
6256
6257/// Cached result of the Linux DMA-BUF availability probe.
6258#[cfg(target_os = "linux")]
6259static DMA_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6260/// Cached result of the macOS/iOS IOSurface availability probe.
6261#[cfg(any(target_os = "macos", target_os = "ios"))]
6262static IOSURFACE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6263
6264/// Check if Linux DMA-BUF allocation is available on this system.
6265///
6266/// Linux-specific availability check (typically requires `/dev/dma_heap`
6267/// access — running as root or membership in a video/render group). For
6268/// portable code that wants "any zero-copy GPU buffer", use
6269/// [`is_gpu_buffer_available`] which also covers IOSurface on macOS.
6270///
6271/// This function caches its result after the first call.
6272#[cfg(target_os = "linux")]
6273pub fn is_dma_available() -> bool {
6274    *DMA_AVAILABLE
6275        .get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok())
6276}
6277
6278/// Always returns `false` on non-Linux platforms.
6279#[cfg(not(target_os = "linux"))]
6280pub fn is_dma_available() -> bool {
6281    false
6282}
6283
6284/// Check if macOS/iOS IOSurface allocation is available on this system.
6285///
6286/// IOSurface is part of the macOS/iOS OS and is essentially always present;
6287/// this probe catches degraded scenarios such as memory pressure or
6288/// sandboxed contexts where `IOSurfaceCreate` fails. The result is
6289/// cached after the first call.
6290///
6291/// Always returns `false` on non-Apple platforms.
6292#[cfg(any(target_os = "macos", target_os = "ios"))]
6293pub fn is_iosurface_available() -> bool {
6294    *IOSURFACE_AVAILABLE.get_or_init(|| {
6295        // Probe via the same Dma path — on macOS/iOS this routes through
6296        // IoSurfaceTensor::new.
6297        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6298    })
6299}
6300
6301#[cfg(not(any(target_os = "macos", target_os = "ios")))]
6302pub fn is_iosurface_available() -> bool {
6303    false
6304}
6305
6306/// Cached result of the Android AHardwareBuffer availability probe.
6307#[cfg(target_os = "android")]
6308static AHARDWAREBUFFER_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6309
6310/// Check if Android AHardwareBuffer allocation is available on this system.
6311///
6312/// AHardwareBuffer is part of the Android OS (public NDK ABI since API
6313/// 26) and is essentially always present; this probe catches degraded
6314/// scenarios such as memory pressure or gralloc failures. The result is
6315/// cached after the first call.
6316#[cfg(target_os = "android")]
6317pub fn is_ahardwarebuffer_available() -> bool {
6318    *AHARDWAREBUFFER_AVAILABLE.get_or_init(|| {
6319        // Probe via the same Dma path — on Android this routes through
6320        // AHardwareBufferTensor::new.
6321        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6322    })
6323}
6324
6325/// Always returns `false` on non-Android platforms.
6326#[cfg(not(target_os = "android"))]
6327pub fn is_ahardwarebuffer_available() -> bool {
6328    false
6329}
6330
6331/// Portable probe for the platform's native zero-copy GPU buffer allocator
6332/// (DMA-BUF on Linux, IOSurface on macOS/iOS, AHardwareBuffer on Android,
6333/// D3D11 textures on Windows). Returns `false` on platforms with no
6334/// equivalent. Use this when writing cross-platform code that cares whether
6335/// the `Dma` tensor variant will work, not which underlying mechanism is used.
6336///
6337/// On Windows the `Dma` variant is image-formatted: `true` here means
6338/// [`Tensor::image`] can serve `TensorMemory::DmaBuf`, not [`Tensor::new`].
6339///
6340/// # Ordering on Windows
6341///
6342/// This probe **creates the process D3D11 device**, because the only honest
6343/// answer to "can this host allocate one" is to try. A host that must supply
6344/// its own device calls `d3d11::use_external_device` **before** this probe or
6345/// any allocation: the device is cached on first creation and installing one
6346/// afterwards is refused.
6347pub fn is_gpu_buffer_available() -> bool {
6348    #[cfg(target_os = "linux")]
6349    {
6350        is_dma_available()
6351    }
6352    #[cfg(any(target_os = "macos", target_os = "ios"))]
6353    {
6354        is_iosurface_available()
6355    }
6356    #[cfg(target_os = "android")]
6357    {
6358        is_ahardwarebuffer_available()
6359    }
6360    // No cached probe: `d3d11::device()` is itself a `OnceLock` over the
6361    // process device, so asking twice costs one atomic load.
6362    #[cfg(target_os = "windows")]
6363    {
6364        crate::d3d11::device().is_ok()
6365    }
6366    #[cfg(not(any(
6367        target_os = "linux",
6368        target_os = "macos",
6369        target_os = "ios",
6370        target_os = "android",
6371        target_os = "windows"
6372    )))]
6373    {
6374        false
6375    }
6376}
6377
6378/// Check if POSIX shared memory allocation is available on this system.
6379///
6380/// Returns `true` on Unix systems (Linux, macOS, BSD) where POSIX shared memory
6381/// is supported. Always returns `false` on non-Unix platforms (Windows).
6382///
6383/// This function caches its result after the first call for efficiency.
6384#[cfg(unix)]
6385static SHM_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6386
6387/// Check if POSIX shared memory allocation is available on this system.
6388#[cfg(unix)]
6389pub fn is_shm_available() -> bool {
6390    *SHM_AVAILABLE.get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok())
6391}
6392
6393/// Check if POSIX shared memory allocation is available on this system.
6394///
6395/// Always returns `false` on non-Unix platforms since POSIX SHM is Unix-specific.
6396#[cfg(not(unix))]
6397pub fn is_shm_available() -> bool {
6398    false
6399}
6400
6401#[cfg(test)]
6402mod dtype_tests {
6403    use super::*;
6404
6405    #[test]
6406    fn dtype_size() {
6407        assert_eq!(DType::U8.size(), 1);
6408        assert_eq!(DType::I8.size(), 1);
6409        assert_eq!(DType::U16.size(), 2);
6410        assert_eq!(DType::I16.size(), 2);
6411        assert_eq!(DType::U32.size(), 4);
6412        assert_eq!(DType::I32.size(), 4);
6413        assert_eq!(DType::U64.size(), 8);
6414        assert_eq!(DType::I64.size(), 8);
6415        assert_eq!(DType::F16.size(), 2);
6416        assert_eq!(DType::F32.size(), 4);
6417        assert_eq!(DType::F64.size(), 8);
6418    }
6419
6420    #[test]
6421    fn dtype_name() {
6422        assert_eq!(DType::U8.name(), "u8");
6423        assert_eq!(DType::F16.name(), "f16");
6424        assert_eq!(DType::F32.name(), "f32");
6425    }
6426
6427    #[test]
6428    fn dtype_serde_roundtrip() {
6429        use serde_json;
6430        let dt = DType::F16;
6431        let json = serde_json::to_string(&dt).unwrap();
6432        let back: DType = serde_json::from_str(&json).unwrap();
6433        assert_eq!(dt, back);
6434    }
6435}
6436
6437#[cfg(test)]
6438mod image_tests {
6439    use super::*;
6440
6441    #[test]
6442    fn image_dims_from_shape_inverts_allocation_shape() {
6443        for (format, w, h) in [
6444            (PixelFormat::Rgba, 640usize, 480usize),
6445            (PixelFormat::Rgb, 641, 3),
6446            (PixelFormat::Grey, 32, 32),
6447            (PixelFormat::PlanarRgb, 640, 480),
6448            (PixelFormat::PlanarRgba, 8, 5),
6449            (PixelFormat::Nv12, 640, 481),
6450            (PixelFormat::Nv16, 33, 17),
6451            (PixelFormat::Nv24, 16, 9),
6452        ] {
6453            let shape = format.allocation_shape(w, h).unwrap();
6454            assert_eq!(
6455                image_dims_from_shape(format, &shape).unwrap(),
6456                (w, h),
6457                "{format:?} {w}x{h} via {shape:?}"
6458            );
6459        }
6460    }
6461
6462    #[test]
6463    fn image_dims_from_shape_rejects_the_wrong_rank() {
6464        // Packed and planar want rank 3, semi-planar rank 2.
6465        for (format, shape) in [
6466            (PixelFormat::Rgba, vec![480usize, 640usize]),
6467            (PixelFormat::PlanarRgb, vec![3, 480]),
6468            (PixelFormat::Nv12, vec![720, 640, 1]),
6469        ] {
6470            let err = image_dims_from_shape(format, &shape).unwrap_err();
6471            assert!(
6472                matches!(err, Error::InvalidShape(ref m) if m.contains("rank")),
6473                "{format:?} {shape:?}: {err}"
6474            );
6475        }
6476    }
6477
6478    #[test]
6479    fn image_dims_from_shape_rejects_an_unproduced_combined_height() {
6480        // NV12 maps 480 -> 720 and 481 -> 722; nothing maps to 721, so the
6481        // shape is rejected rather than rounded to 480.
6482        let err = image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).unwrap_err();
6483        assert!(
6484            matches!(err, Error::InvalidShape(ref m) if m.contains("combined plane height")),
6485            "{err}"
6486        );
6487    }
6488
6489    #[test]
6490    fn allocation_shape_per_layout() {
6491        assert_eq!(
6492            PixelFormat::Rgb.allocation_shape(640, 480),
6493            Some(vec![480, 640, 3])
6494        );
6495        assert_eq!(
6496            PixelFormat::Grey.allocation_shape(640, 480),
6497            Some(vec![480, 640, 1])
6498        );
6499        assert_eq!(
6500            PixelFormat::Nv12.allocation_shape(640, 480),
6501            Some(vec![720, 640])
6502        );
6503        // Odd height: combined-plane height is `481 + ceil(481/2)` = 481 + 241
6504        // = 722 rows. Logical height is recovered as `722 * 2 / 3` = 481.
6505        assert_eq!(
6506            PixelFormat::Nv12.allocation_shape(640, 481),
6507            Some(vec![722, 640])
6508        );
6509        // Odd width: shape carries the LOGICAL width (641).
6510        // The 64-aligned stride (>= 642) is stored separately on the Tensor.
6511        assert_eq!(
6512            PixelFormat::Nv12.allocation_shape(641, 480),
6513            Some(vec![720, 641])
6514        );
6515        // NV16 odd width: same — logical width in shape, stride separate.
6516        assert_eq!(
6517            PixelFormat::Nv16.allocation_shape(641, 480),
6518            Some(vec![960, 641])
6519        );
6520        assert_eq!(
6521            PixelFormat::PlanarRgb.allocation_shape(640, 480),
6522            Some(vec![3, 480, 640])
6523        );
6524        assert_eq!(
6525            PixelFormat::Nv16.allocation_shape(640, 480),
6526            Some(vec![960, 640])
6527        );
6528    }
6529
6530    #[test]
6531    fn raw_tensor_has_no_format() {
6532        let t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6533        assert!(t.format().is_none());
6534        assert!(t.width().is_none());
6535        assert!(t.height().is_none());
6536        assert!(!t.is_multiplane());
6537        assert!(t.chroma().is_none());
6538    }
6539
6540    #[test]
6541    fn image_tensor_packed() {
6542        let t = Tensor::<u8>::image(
6543            640,
6544            480,
6545            PixelFormat::Rgba,
6546            None,
6547            crate::CpuAccess::ReadWrite,
6548        )
6549        .unwrap();
6550        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6551        assert_eq!(t.width(), Some(640));
6552        assert_eq!(t.height(), Some(480));
6553        assert_eq!(t.shape(), &[480, 640, 4]);
6554        assert!(!t.is_multiplane());
6555    }
6556
6557    #[test]
6558    fn image_tensor_planar() {
6559        let t = Tensor::<u8>::image(
6560            640,
6561            480,
6562            PixelFormat::PlanarRgb,
6563            None,
6564            crate::CpuAccess::ReadWrite,
6565        )
6566        .unwrap();
6567        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6568        assert_eq!(t.width(), Some(640));
6569        assert_eq!(t.height(), Some(480));
6570        assert_eq!(t.shape(), &[3, 480, 640]);
6571    }
6572
6573    #[test]
6574    #[cfg(target_os = "macos")]
6575    fn image_tensor_dma_non_aligned_packed_width_pads_zero_copy() {
6576        // Declares this test as an fd-opener; see FD_LOCK.
6577        let _lock = crate::tests::fd_lock_shared();
6578        // RGBA u8 at width=4 → 4*4 = 16 bytes/row, not 64-byte aligned. RGBA has
6579        // a real IOSurface FourCC, so an explicit `Some(TensorMemory::DmaBuf)`
6580        // request now allocates a padded image IOSurface (64-aligned
6581        // `bytes_per_row`) and records the stride — a fully zero-copy buffer GL
6582        // can bind and the CPU can map via the strided path. (Previously this
6583        // failed loudly to avoid an 'L008' byte-bag downgrade; with a real
6584        // FourCC surface that concern no longer applies.)
6585        let t = Tensor::<u8>::image(
6586            4,
6587            4,
6588            PixelFormat::Rgba,
6589            Some(TensorMemory::DmaBuf),
6590            crate::CpuAccess::ReadWrite,
6591        )
6592        .expect("padded RGBA IOSurface should allocate");
6593        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6594        assert_eq!(t.width(), Some(4));
6595        assert_eq!(t.height(), Some(4));
6596        let stride = t.effective_row_stride().expect("stride");
6597        assert_eq!(stride % 64, 0, "padded to 64-byte row alignment");
6598        assert!(stride >= 16);
6599        // A CPU map exposes the full padded surface for strided iteration.
6600        let m = t.map().expect("strided IOSurface map");
6601        assert_eq!(m.as_slice().len(), stride * 4);
6602    }
6603
6604    /// `per_pixel_bytes` that doesn't divide 64 evenly (e.g. RGB u8 with
6605    /// 3 B/pixel) makes a "Pad width to N" suggestion structurally
6606    /// impossible — there is no integer width whose `width * 3` is a
6607    /// multiple of 64. The error must still fire (no silent SHM
6608    /// fallback for explicit-DMA requests) and must spell out the
6609    /// alignment requirement; it just omits the misleading "pad to N"
6610    /// hint instead of printing a number whose row pitch still won't
6611    /// align.
6612    #[test]
6613    #[cfg(target_os = "macos")]
6614    fn image_tensor_dma_rejects_indivisible_pixel_pitch_without_pad_hint() {
6615        // Declares this test as an fd-opener; see FD_LOCK.
6616        let _lock = crate::tests::fd_lock_shared();
6617        // Width=10 RGB f32 → 120 B/row, not 64-byte aligned, and (Rgb,
6618        // F32) has no IOSurface mapping so the padded-stride tolerance
6619        // does not apply. The next 64-multiple (128 B) isn't an integer
6620        // multiple of 12 B/pixel, so the "pad width to N" hint can't
6621        // produce a valid number and must be omitted. (Rgb u8 used to be
6622        // this test's subject but now has a real RGBA8888 mapping with
6623        // padded-stride tolerance — see the test below.)
6624        let err = Tensor::<f32>::image(
6625            10,
6626            10,
6627            PixelFormat::Rgb,
6628            Some(TensorMemory::DmaBuf),
6629            crate::CpuAccess::ReadWrite,
6630        )
6631        .expect_err("RGB f32 with 12 B/pixel and non-aligned width must be rejected");
6632        match err {
6633            Error::InvalidArgument(msg) => {
6634                assert!(
6635                    msg.contains("64-byte aligned"),
6636                    "error must still name the alignment requirement: {msg}"
6637                );
6638                assert!(
6639                    !msg.contains("Pad width"),
6640                    "indivisible per-pixel pitch makes a width suggestion impossible; \
6641                     hint must be omitted, got: {msg}"
6642                );
6643                assert!(
6644                    msg.contains("memory=None") && msg.contains("TensorMemory::Mem"),
6645                    "error must still list the always-applicable alternatives: {msg}"
6646                );
6647            }
6648            other => panic!("expected InvalidArgument, got {other:?}"),
6649        }
6650    }
6651
6652    #[test]
6653    #[cfg(target_os = "macos")]
6654    fn image_tensor_dma_packed_rgb_u8_contract() {
6655        // Declares this test as an fd-opener; see FD_LOCK.
6656        let _lock = crate::tests::fd_lock_shared();
6657        // Packed RGB u8 @Dma is a designed RGBA8888 mapping at
6658        // (W*3/4, H) — the INT8 NPU input layout, shared with Android.
6659        // width%4 != 0 cannot form whole texels → loud InvalidArgument…
6660        let err = Tensor::<u8>::image(
6661            10,
6662            10,
6663            PixelFormat::Rgb,
6664            Some(TensorMemory::DmaBuf),
6665            crate::CpuAccess::ReadWrite,
6666        )
6667        .expect_err("Rgb u8 width%4!=0 must be rejected");
6668        assert!(
6669            matches!(&err, Error::InvalidArgument(m) if m.contains("width%4==0")),
6670            "got {err:?}"
6671        );
6672        // …width%4 == 0 with a non-64-aligned pitch allocates PADDED
6673        // (36 B rows → 64 B surface pitch, recorded on the tensor)…
6674        let t = Tensor::<u8>::image(
6675            12,
6676            4,
6677            PixelFormat::Rgb,
6678            Some(TensorMemory::DmaBuf),
6679            crate::CpuAccess::ReadWrite,
6680        )
6681        .expect("width 12 Rgb u8 must allocate padded");
6682        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6683        assert!(
6684            t.row_stride()
6685                .is_some_and(|s| s >= 64 && s.is_multiple_of(64)),
6686            "padded pitch must be recorded: {:?}",
6687            t.row_stride()
6688        );
6689        // …and the aligned model-input width stays flat (640*3 = 1920 is
6690        // 64-aligned → no recorded stride, the buffer IS [H, W, 3]).
6691        let t = Tensor::<u8>::image(
6692            640,
6693            8,
6694            PixelFormat::Rgb,
6695            Some(TensorMemory::DmaBuf),
6696            crate::CpuAccess::ReadWrite,
6697        )
6698        .expect("width 640 Rgb u8 must allocate flat");
6699        assert_eq!(t.row_stride(), None);
6700        // I8 shares the layout (INT8 shader bias, not a format change).
6701        let t = Tensor::<i8>::image(
6702            640,
6703            8,
6704            PixelFormat::Rgb,
6705            Some(TensorMemory::DmaBuf),
6706            crate::CpuAccess::ReadWrite,
6707        )
6708        .expect("Rgb i8 shares the RGBA8888 mapping");
6709        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6710    }
6711
6712    #[test]
6713    #[cfg(target_os = "macos")]
6714    fn image_tensor_dma_planar_f16_alignment() {
6715        // Declares this test as an fd-opener; see FD_LOCK.
6716        let _lock = crate::tests::fd_lock_shared();
6717        // PlanarRgb F16 uses single-channel row pitch (width * 2 bytes).
6718        // Width=16 → 32 bytes/row (not aligned); width=32 → 64 bytes/row (aligned).
6719        let err = Tensor::<half::f16>::image(
6720            16,
6721            16,
6722            PixelFormat::PlanarRgb,
6723            Some(TensorMemory::DmaBuf),
6724            crate::CpuAccess::ReadWrite,
6725        )
6726        .expect_err("width=16 PlanarRgb F16 is 32-byte row, must reject");
6727        assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}");
6728        // 32 wide should work.
6729        let t = Tensor::<half::f16>::image(
6730            32,
6731            8,
6732            PixelFormat::PlanarRgb,
6733            Some(TensorMemory::DmaBuf),
6734            crate::CpuAccess::ReadWrite,
6735        )
6736        .expect("width=32 PlanarRgb F16 is 64-byte row, must succeed");
6737        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6738    }
6739
6740    #[test]
6741    fn image_tensor_semi_planar_contiguous() {
6742        let t = Tensor::<u8>::image(
6743            640,
6744            480,
6745            PixelFormat::Nv12,
6746            None,
6747            crate::CpuAccess::ReadWrite,
6748        )
6749        .unwrap();
6750        assert_eq!(t.format(), Some(PixelFormat::Nv12));
6751        assert_eq!(t.width(), Some(640));
6752        assert_eq!(t.height(), Some(480));
6753        // NV12: H*3/2 = 720
6754        assert_eq!(t.shape(), &[720, 640]);
6755        assert!(!t.is_multiplane());
6756    }
6757
6758    #[test]
6759    #[cfg(target_os = "linux")]
6760    fn image_tensor_with_stride_preserves_logical_width() {
6761        // Declares this test as an fd-opener; see FD_LOCK.
6762        let _lock = crate::tests::fd_lock_shared();
6763        if !crate::test_support::dma_or_skip("image_tensor_with_stride_preserves_logical_width") {
6764            return;
6765        }
6766        // 3004×1688 RGBA8: natural pitch 12016, padded to 12032 (64-aligned).
6767        let stride = 12032;
6768        let t = Tensor::<u8>::image_with_stride(
6769            3004,
6770            1688,
6771            PixelFormat::Rgba,
6772            stride,
6773            Some(TensorMemory::DmaBuf),
6774            crate::CpuAccess::ReadWrite,
6775        )
6776        .unwrap();
6777        // Logical dimensions unchanged by padding — this is the contract.
6778        assert_eq!(t.width(), Some(3004));
6779        assert_eq!(t.height(), Some(1688));
6780        assert_eq!(t.shape(), &[1688, 3004, 4]);
6781        // Stride is carried separately and reports the padded pitch.
6782        assert_eq!(t.effective_row_stride(), Some(stride));
6783        // Buffer is sized to stride × height so the full padded layout fits,
6784        // and CPU map() works for self-allocated strided DMA tensors.
6785        use crate::TensorMapTrait;
6786        {
6787            let map = t.map().unwrap();
6788            assert!(
6789                map.as_slice().len() >= stride * 1688,
6790                "mapped buffer {} bytes < expected {}",
6791                map.as_slice().len(),
6792                stride * 1688
6793            );
6794        }
6795        // CPU write access works too — iterate rows using the padded stride,
6796        // touch only the active `width × bpp` region, verify it round-trips.
6797        {
6798            let mut map = t.map().unwrap();
6799            let slice = map.as_mut_slice();
6800            for y in 0..1688 {
6801                let row_start = y * stride;
6802                for x in 0..3004 {
6803                    let p = row_start + x * 4;
6804                    slice[p] = (y & 0xFF) as u8;
6805                    slice[p + 1] = (x & 0xFF) as u8;
6806                    slice[p + 2] = 0x42;
6807                    slice[p + 3] = 0xFF;
6808                }
6809            }
6810        }
6811        {
6812            let map = t.map().unwrap();
6813            let slice = map.as_slice();
6814            // Sample a few pixels to confirm the round-trip.
6815            assert_eq!(slice[0], 0x00);
6816            assert_eq!(slice[1], 0x00);
6817            assert_eq!(slice[2], 0x42);
6818            assert_eq!(slice[3], 0xFF);
6819            let mid = 100 * stride + 50 * 4;
6820            assert_eq!(slice[mid], 100);
6821            assert_eq!(slice[mid + 1], 50);
6822            assert_eq!(slice[mid + 2], 0x42);
6823        }
6824    }
6825
6826    #[test]
6827    #[cfg(target_os = "linux")]
6828    fn image_tensor_foreign_strided_map_is_bounds_checked_not_refused() {
6829        // An imported (foreign) DMA-BUF with a row stride used to be refused
6830        // outright. That changed deliberately: the cross-package capsule
6831        // protocol reconstructs an imported alias of a HAL-owned Dma tensor,
6832        // and refusing every strided CPU map broke the flagship
6833        // create_image() -> decode_file_into() -> convert() pipeline on Linux.
6834        //
6835        // What replaced the refusal is a bounds check against `buf_size`,
6836        // which for an imported fd is `fstat`-derived kernel truth rather
6837        // than the caller's claim. So the property worth testing is no longer
6838        // "foreign strided maps fail" but "they succeed exactly when they
6839        // fit" -- which is what actually keeps the mapping sound.
6840        //
6841        // This test previously asserted the old refusal and went red on every
6842        // board with a dma-heap when the behaviour changed, unnoticed because
6843        // it needs both Linux and a dma-heap: it skips on macOS and on Orin
6844        // (nvmap, no CONFIG_DMABUF_HEAPS).
6845        if !crate::test_support::dma_or_skip(
6846            "image_tensor_foreign_strided_map_is_bounds_checked_not_refused",
6847        ) {
6848            return;
6849        }
6850        let backing =
6851            Tensor::<u8>::new(&[240 * 320 * 4], Some(TensorMemory::DmaBuf), None).unwrap();
6852
6853        // In bounds: the natural stride for the declared image fits the
6854        // allocation, so the map must succeed.
6855        {
6856            let fd = backing.clone_fd().unwrap();
6857            let shape = [240usize, 320, 4];
6858            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6859            let mut t = Tensor::<u8>::wrap(storage);
6860            t.set_format(PixelFormat::Bgra).unwrap();
6861            t.set_row_stride(320 * 4).unwrap();
6862            assert!(
6863                t.map().is_ok(),
6864                "an in-bounds strided map of an imported DMA-BUF must be \
6865                 accepted -- refusing it is what broke decode-into-GPU-tensor"
6866            );
6867        }
6868
6869        // Out of bounds: a stride the allocation cannot back must still be
6870        // refused, and the error must say so. This is the half that carries
6871        // the soundness.
6872        {
6873            let fd = backing.clone_fd().unwrap();
6874            let shape = [240usize, 320, 4];
6875            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6876            let mut t = Tensor::<u8>::wrap(storage);
6877            t.set_format(PixelFormat::Bgra).unwrap();
6878            // Double the pitch: 240 rows x 2560 bytes is twice the allocation.
6879            t.set_row_stride(320 * 4 * 2).unwrap();
6880            let err = t.map();
6881            assert!(
6882                matches!(err, Err(Error::InvalidOperation(_))),
6883                "a strided map larger than the DMA allocation must be refused"
6884            );
6885        }
6886    }
6887
6888    #[test]
6889    #[cfg(target_os = "linux")]
6890    fn image_tensor_with_stride_map_rejects_tampered_stride() {
6891        // Declares this test as an fd-opener; see FD_LOCK.
6892        let _lock = crate::tests::fd_lock_shared();
6893        // Round-3 PR feedback (C1): `set_row_stride` is public and only
6894        // validates `stride >= min_stride`, not that the new stride × height
6895        // fits the underlying buffer. A caller that tampers with the stride
6896        // after allocation must not be able to coerce `Tensor::map()` into
6897        // returning a slice larger than the backing mmap (that would be UB
6898        // in `DmaMap::as_slice`).
6899        if !crate::test_support::dma_or_skip("image_tensor_with_stride_map_rejects_tampered_stride")
6900        {
6901            return;
6902        }
6903        // Allocate a 640×480 RGBA8 padded canvas (stride = 3072 = 768 px).
6904        // Backing buffer is 3072 × 480 = 1,474,560 bytes.
6905        let mut t = Tensor::<u8>::image_with_stride(
6906            640,
6907            480,
6908            PixelFormat::Rgba,
6909            3072,
6910            Some(TensorMemory::DmaBuf),
6911            crate::CpuAccess::ReadWrite,
6912        )
6913        .unwrap();
6914        // Tamper: push the stride up to 4 × the original. This is >=
6915        // min_stride (2560), so `set_row_stride` accepts it.
6916        t.set_row_stride(12288).unwrap();
6917        // Map must now refuse — 12288 × 480 = 5,898,240 > 1,474,560.
6918        let err = t.map();
6919        assert!(
6920            matches!(err, Err(Error::InvalidOperation(_))),
6921            "map() with oversized stride must return InvalidOperation"
6922        );
6923    }
6924
6925    #[test]
6926    fn dma_tensor_new_with_byte_size_rejects_shape_overflow() {
6927        // Round-3 PR feedback (C3): shape.product() * sizeof(T) must use
6928        // checked arithmetic so a pathological shape can't wrap usize and
6929        // make the byte_size-vs-logical-size comparison incorrect.
6930        //
6931        // This test only exercises the overflow rejection path, which is
6932        // pure-Rust and doesn't touch dma_heap — safe to run on any target.
6933        #[cfg(target_os = "linux")]
6934        {
6935            let err = crate::dma::DmaTensor::<u64>::new_with_byte_size(
6936                &[usize::MAX, 2, 2],
6937                usize::MAX,
6938                None,
6939                false,
6940            );
6941            assert!(
6942                matches!(err, Err(Error::InvalidArgument(_))),
6943                "new_with_byte_size must detect shape.product() overflow"
6944            );
6945        }
6946    }
6947
6948    #[test]
6949    #[cfg(target_os = "linux")]
6950    fn image_tensor_with_stride_rejects_too_small_stride() {
6951        // Declares this test as an fd-opener; see FD_LOCK.
6952        let _lock = crate::tests::fd_lock_shared();
6953        // 640×480 RGBA8 natural pitch = 2560, request 2400 → should error.
6954        let err = Tensor::<u8>::image_with_stride(
6955            640,
6956            480,
6957            PixelFormat::Rgba,
6958            2400,
6959            Some(TensorMemory::DmaBuf),
6960            crate::CpuAccess::ReadWrite,
6961        );
6962        assert!(matches!(err, Err(Error::InvalidArgument(_))));
6963    }
6964
6965    #[test]
6966    #[cfg(target_os = "linux")]
6967    fn image_tensor_with_stride_rejects_non_packed() {
6968        // Declares this test as an fd-opener; see FD_LOCK.
6969        let _lock = crate::tests::fd_lock_shared();
6970        // NV12 is SemiPlanar → not supported. (Linux-only because
6971        // `TensorMemory::DmaBuf` itself is a Linux-only enum variant.)
6972        let err = Tensor::<u8>::image_with_stride(
6973            640,
6974            480,
6975            PixelFormat::Nv12,
6976            640,
6977            Some(TensorMemory::DmaBuf),
6978            crate::CpuAccess::ReadWrite,
6979        );
6980        assert!(matches!(err, Err(Error::NotImplemented(_))));
6981    }
6982
6983    #[test]
6984    fn set_format_valid() {
6985        let mut t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6986        assert!(t.format().is_none());
6987        t.set_format(PixelFormat::Rgb).unwrap();
6988        assert_eq!(t.format(), Some(PixelFormat::Rgb));
6989        assert_eq!(t.width(), Some(640));
6990        assert_eq!(t.height(), Some(480));
6991    }
6992
6993    #[test]
6994    fn set_format_invalid_shape() {
6995        let mut t = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
6996        // RGB expects 3 channels, not 4
6997        let err = t.set_format(PixelFormat::Rgb);
6998        assert!(err.is_err());
6999        // Original tensor is unmodified
7000        assert!(t.format().is_none());
7001    }
7002
7003    #[test]
7004    fn reshape_clears_format() {
7005        let mut t = Tensor::<u8>::image(
7006            640,
7007            480,
7008            PixelFormat::Rgba,
7009            None,
7010            crate::CpuAccess::ReadWrite,
7011        )
7012        .unwrap();
7013        assert_eq!(t.format(), Some(PixelFormat::Rgba));
7014        // Reshape to flat — format cleared
7015        t.reshape(&[480 * 640 * 4]).unwrap();
7016        assert!(t.format().is_none());
7017    }
7018
7019    #[test]
7020    fn from_planes_nv12() {
7021        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7022        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7023        let img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
7024        assert_eq!(img.format(), Some(PixelFormat::Nv12));
7025        assert!(img.is_multiplane());
7026        assert!(img.chroma().is_some());
7027        assert_eq!(img.width(), Some(640));
7028        assert_eq!(img.height(), Some(480));
7029    }
7030
7031    #[test]
7032    fn from_planes_rejects_non_semiplanar() {
7033        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7034        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7035        let err = Tensor::from_planes(y, uv, PixelFormat::Rgb);
7036        assert!(err.is_err());
7037    }
7038
7039    #[test]
7040    fn reshape_multiplane_errors() {
7041        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
7042        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
7043        let mut img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
7044        let err = img.reshape(&[480 * 640 + 240 * 640]);
7045        assert!(err.is_err());
7046    }
7047}
7048
7049#[cfg(test)]
7050mod compression_tests {
7051    use super::*;
7052
7053    #[test]
7054    fn desc_builder_roundtrips() {
7055        let desc = ImageDesc::new(640, 480, PixelFormat::Rgba, DType::U8)
7056            .with_memory(Some(TensorMemory::Mem))
7057            .with_access(CpuAccess::Read)
7058            .with_compression(Compression::Any);
7059        assert_eq!(desc.width(), 640);
7060        assert_eq!(desc.height(), 480);
7061        assert_eq!(desc.format(), PixelFormat::Rgba);
7062        assert_eq!(desc.dtype(), DType::U8);
7063        assert_eq!(desc.memory(), Some(TensorMemory::Mem));
7064        assert_eq!(desc.access(), CpuAccess::Read);
7065        assert_eq!(desc.compression(), Some(Compression::Any));
7066
7067        // Defaults: auto memory, hardware-only, no request.
7068        let plain = ImageDesc::new(2, 2, PixelFormat::Grey, DType::U8);
7069        assert_eq!(plain.memory(), None);
7070        assert_eq!(plain.access(), CpuAccess::None);
7071        assert_eq!(plain.compression(), None);
7072    }
7073
7074    #[test]
7075    fn desc_dtype_must_match_element_type() {
7076        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::F32);
7077        match Tensor::<u8>::image_desc(&desc) {
7078            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("dtype")),
7079            other => panic!("expected InvalidArgument, got {other:?}"),
7080        }
7081    }
7082
7083    #[test]
7084    fn compression_with_cpu_access_is_invalid() {
7085        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
7086            .with_access(CpuAccess::ReadWrite)
7087            .with_compression(Compression::Any);
7088        match Tensor::<u8>::image_desc(&desc) {
7089            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("CpuAccess::None")),
7090            other => panic!("expected InvalidArgument, got {other:?}"),
7091        }
7092    }
7093
7094    #[cfg(not(target_os = "android"))]
7095    #[test]
7096    fn scheme_request_off_android_is_not_implemented() {
7097        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
7098            .with_compression(Compression::Scheme(CompressionScheme::Ubwc));
7099        match Tensor::<u8>::image_desc(&desc) {
7100            Err(Error::NotImplemented(msg)) => assert!(msg.contains("Ubwc")),
7101            other => panic!("expected NotImplemented, got {other:?}"),
7102        }
7103    }
7104
7105    #[cfg(not(target_os = "android"))]
7106    #[test]
7107    fn any_request_off_android_resolves_linear_and_counts() {
7108        let before = compression_fallback_count();
7109        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
7110            .with_memory(Some(TensorMemory::Mem))
7111            .with_compression(Compression::Any);
7112        let t = Tensor::<u8>::image_desc(&desc).unwrap();
7113        assert_eq!(t.compression(), None);
7114        assert!(compression_fallback_count() > before);
7115    }
7116
7117    #[test]
7118    fn desc_without_request_matches_classic_constructor() {
7119        let desc = ImageDesc::new(32, 32, PixelFormat::Rgba, DType::U8)
7120            .with_memory(Some(TensorMemory::Mem))
7121            .with_access(CpuAccess::ReadWrite);
7122        let t = Tensor::<u8>::image_desc(&desc).unwrap();
7123        assert_eq!(t.compression(), None);
7124        assert_eq!(t.cpu_access(), CpuAccess::ReadWrite);
7125        assert_eq!(t.width(), Some(32));
7126        // Mappable exactly like the classic constructor's result.
7127        let m = t.map_read().unwrap();
7128        assert_eq!(m.as_slice().len(), 32 * 32 * 4);
7129    }
7130
7131    #[test]
7132    fn configure_image_preserves_compression_and_views_inherit() {
7133        // No host platform records a scheme, so emulate the recording to
7134        // pin the preserve/inherit semantics (the physical layout does
7135        // not change when the logical image is reconfigured).
7136        let mut t = Tensor::<u8>::image(
7137            64,
7138            64,
7139            PixelFormat::Rgba,
7140            Some(TensorMemory::Mem),
7141            CpuAccess::None,
7142        )
7143        .unwrap();
7144        t.compression = Some(CompressionScheme::Ubwc);
7145        t.configure_image(32, 32, PixelFormat::Rgba).unwrap();
7146        assert_eq!(t.compression(), Some(CompressionScheme::Ubwc));
7147        let view = t.subview(0, &[16, 32, 4]).unwrap();
7148        assert_eq!(view.compression(), Some(CompressionScheme::Ubwc));
7149    }
7150
7151    #[test]
7152    fn tensor_dyn_dispatches_desc_and_compression() {
7153        let desc = ImageDesc::new(16, 16, PixelFormat::Rgba, DType::U8)
7154            .with_memory(Some(TensorMemory::Mem))
7155            .with_access(CpuAccess::ReadWrite);
7156        let t = TensorDyn::image_desc(&desc).unwrap();
7157        assert_eq!(t.compression(), None);
7158        assert!(matches!(t, TensorDyn::U8(_)));
7159    }
7160}
7161
7162#[cfg(all(test, feature = "static"))]
7163mod vocabulary_and_probe_tests {
7164    use super::*;
7165
7166    #[test]
7167    fn dtype_displays_its_wire_name() {
7168        for (dt, name) in [
7169            (DType::U8, "u8"),
7170            (DType::I8, "i8"),
7171            (DType::U16, "u16"),
7172            (DType::I16, "i16"),
7173            (DType::U32, "u32"),
7174            (DType::I32, "i32"),
7175            (DType::U64, "u64"),
7176            (DType::I64, "i64"),
7177            (DType::F16, "f16"),
7178            (DType::F32, "f32"),
7179            (DType::F64, "f64"),
7180        ] {
7181            assert_eq!(dt.to_string(), name);
7182        }
7183    }
7184
7185    #[test]
7186    fn dtype_of_maps_every_element_type_and_nothing_else() {
7187        assert_eq!(dtype_of::<u8>(), Some(DType::U8));
7188        assert_eq!(dtype_of::<i8>(), Some(DType::I8));
7189        assert_eq!(dtype_of::<u16>(), Some(DType::U16));
7190        assert_eq!(dtype_of::<i16>(), Some(DType::I16));
7191        assert_eq!(dtype_of::<u32>(), Some(DType::U32));
7192        assert_eq!(dtype_of::<i32>(), Some(DType::I32));
7193        assert_eq!(dtype_of::<u64>(), Some(DType::U64));
7194        assert_eq!(dtype_of::<i64>(), Some(DType::I64));
7195        assert_eq!(dtype_of::<half::f16>(), Some(DType::F16));
7196        assert_eq!(dtype_of::<f32>(), Some(DType::F32));
7197        assert_eq!(dtype_of::<f64>(), Some(DType::F64));
7198        assert_eq!(dtype_of::<bool>(), None);
7199    }
7200
7201    #[test]
7202    fn tensor_memory_converts_to_and_from_its_wire_string() {
7203        assert_eq!(String::from(TensorMemory::DmaBuf), "dmabuf");
7204        assert_eq!(String::from(TensorMemory::Shm), "shm");
7205        assert_eq!(
7206            TensorMemory::try_from("dmabuf").unwrap(),
7207            TensorMemory::DmaBuf
7208        );
7209        assert_eq!(
7210            TensorMemory::try_from("dma").unwrap(),
7211            TensorMemory::DmaBuf,
7212            "the legacy spelling still parses"
7213        );
7214        assert_eq!(TensorMemory::try_from("mem").unwrap(), TensorMemory::Mem);
7215        match TensorMemory::try_from("bogus") {
7216            Err(Error::InvalidMemoryType(s)) => assert_eq!(s, "bogus"),
7217            other => panic!("expected InvalidMemoryType, got {other:?}"),
7218        }
7219    }
7220
7221    #[test]
7222    fn tensor_memory_is_available_matches_a_real_allocation() {
7223        let _lock = crate::tests::fd_lock_shared();
7224        assert!(TensorMemory::Mem.is_available());
7225        assert!(!TensorMemory::IoSurface.is_available());
7226        assert!(!TensorMemory::Pbo.is_available());
7227        assert!(!TensorMemory::Cuda.is_available());
7228        #[cfg(unix)]
7229        assert_eq!(
7230            TensorMemory::Shm.is_available(),
7231            Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok()
7232        );
7233        #[cfg(not(target_os = "windows"))]
7234        assert_eq!(
7235            TensorMemory::DmaBuf.is_available(),
7236            Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
7237        );
7238    }
7239
7240    #[test]
7241    #[cfg(target_os = "linux")]
7242    fn linux_probes_report_what_linux_can_allocate() {
7243        let _lock = crate::tests::fd_lock_shared();
7244        let dma = DmaTensor::<u8>::new(&[64], None).is_ok();
7245        assert_eq!(is_dma_available(), dma);
7246        assert_eq!(is_gpu_buffer_available(), dma);
7247        assert!(!is_iosurface_available());
7248        assert!(!is_ahardwarebuffer_available());
7249        // Linux (unlike Android's bionic) always has POSIX shared memory.
7250        assert!(is_shm_available());
7251    }
7252}
7253
7254#[cfg(all(test, feature = "static"))]
7255mod quantization_serde_tests {
7256    use super::*;
7257
7258    fn parse(json: &str) -> Quantization {
7259        serde_json::from_str(json).unwrap_or_else(|e| panic!("{json}: {e}"))
7260    }
7261
7262    #[test]
7263    fn scale_accepts_every_json_number_form() {
7264        assert_eq!(parse(r#"{"scale": 0.5}"#).scale(), &[0.5]);
7265        assert_eq!(parse(r#"{"scale": 2}"#).scale(), &[2.0]);
7266        assert_eq!(parse(r#"{"scale": -2}"#).scale(), &[-2.0]);
7267        assert_eq!(parse(r#"{"scale": [0.25, 4]}"#).scale(), &[0.25, 4.0]);
7268    }
7269
7270    #[test]
7271    fn zero_point_accepts_null_scalar_and_array() {
7272        assert_eq!(
7273            parse(r#"{"scale": 0.1, "zero_point": -5}"#).zero_point(),
7274            Some(&[-5][..])
7275        );
7276        assert_eq!(
7277            parse(r#"{"scale": 0.1, "zero_point": 3}"#).zero_point(),
7278            Some(&[3][..])
7279        );
7280        assert_eq!(
7281            parse(r#"{"scale": [0.1, 0.2], "zero_point": [1, -2], "axis": 0}"#).zero_point(),
7282            Some(&[1, -2][..])
7283        );
7284        assert_eq!(
7285            parse(r#"{"scale": 0.1, "zero_point": null}"#).zero_point(),
7286            None
7287        );
7288        assert_eq!(parse(r#"{"scale": 0.1}"#).zero_point(), None);
7289    }
7290
7291    /// Deserializers whose `deserialize_option` forwards to
7292    /// `deserialize_any` (serde's value deserializers among them) reach the
7293    /// zero-point visitor's scalar and sequence arms directly, not through
7294    /// `visit_some`.
7295    #[test]
7296    fn zero_point_visitor_accepts_values_without_an_option_wrapper() {
7297        use serde::de::value::{
7298            Error as ValueError, I64Deserializer, SeqDeserializer, U64Deserializer,
7299        };
7300        assert_eq!(
7301            deserialize_opt_scalar_or_vec_i32(I64Deserializer::<ValueError>::new(-7)).unwrap(),
7302            Some(vec![-7])
7303        );
7304        assert_eq!(
7305            deserialize_opt_scalar_or_vec_i32(U64Deserializer::<ValueError>::new(9)).unwrap(),
7306            Some(vec![9])
7307        );
7308        let seq = SeqDeserializer::<_, ValueError>::new(vec![4i32, -4].into_iter());
7309        assert_eq!(
7310            deserialize_opt_scalar_or_vec_i32(seq).unwrap(),
7311            Some(vec![4, -4])
7312        );
7313    }
7314
7315    #[test]
7316    fn per_channel_is_not_per_tensor() {
7317        let q = Quantization::per_channel(vec![0.1, 0.2], vec![0, 1], 0).unwrap();
7318        assert!(!q.is_per_tensor());
7319        assert!(Quantization::per_tensor(0.1, 0).is_per_tensor());
7320    }
7321
7322    #[test]
7323    fn validate_accepts_matching_per_channel_and_names_the_failing_field() {
7324        let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
7325        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3, 0.4], vec![0, 1, 2, 3], 2).unwrap();
7326        t.set_quantization(q)
7327            .expect("four channels on axis 2 of [1, 1, 4]");
7328        assert_eq!(
7329            t.quantization().and_then(Quantization::zero_point),
7330            Some(&[0, 1, 2, 3][..])
7331        );
7332
7333        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3, 0.4], vec![0, 1, 2, 3], 3).unwrap();
7334        match t.set_quantization(q) {
7335            Err(Error::QuantizationInvalid { field, got, .. }) => {
7336                assert_eq!(field, "axis");
7337                assert_eq!(got, "axis=3");
7338            }
7339            other => panic!("axis 3 of a rank-3 tensor must be refused, got {other:?}"),
7340        }
7341
7342        let q = Quantization::per_channel_symmetric(vec![0.1, 0.2, 0.3, 0.4], 1).unwrap();
7343        match t.set_quantization(q) {
7344            Err(Error::QuantizationInvalid { field, got, .. }) => {
7345                assert_eq!(field, "scale.len");
7346                assert_eq!(got, "length 4");
7347            }
7348            other => panic!("four scales on a length-1 axis must be refused, got {other:?}"),
7349        }
7350    }
7351}
7352
7353#[cfg(all(test, feature = "static"))]
7354mod image_geometry_tests {
7355    use super::*;
7356
7357    fn mem_image<T>(width: usize, height: usize, format: PixelFormat) -> Tensor<T>
7358    where
7359        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7360    {
7361        Tensor::<T>::image(
7362            width,
7363            height,
7364            format,
7365            Some(TensorMemory::Mem),
7366            CpuAccess::ReadWrite,
7367        )
7368        .unwrap_or_else(|e| panic!("Mem {format:?} {width}x{height}: {e}"))
7369    }
7370
7371    fn raw_mem<T>(shape: &[usize]) -> Tensor<T>
7372    where
7373        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7374    {
7375        Tensor::<T>::new(shape, Some(TensorMemory::Mem), None).unwrap()
7376    }
7377
7378    #[test]
7379    fn semi_planar_image_stride_scales_with_the_element_size() {
7380        // u16 NV12 at width 100: 200 B rows, aligned to 256.
7381        let t = mem_image::<u16>(100, 64, PixelFormat::Nv12);
7382        assert_eq!(t.row_stride(), Some(256));
7383        assert_eq!(t.capacity_bytes(), 256 * 96);
7384    }
7385
7386    #[test]
7387    fn host_planar_image_is_tight_at_every_element_size() {
7388        let t = mem_image::<u8>(320, 240, PixelFormat::PlanarRgb);
7389        assert_eq!(t.row_stride(), None);
7390        assert_eq!(t.effective_row_stride(), Some(320));
7391        assert_eq!(t.capacity_bytes(), 320 * 240 * 3);
7392
7393        let t = mem_image::<f32>(100, 40, PixelFormat::PlanarRgb);
7394        assert_eq!(t.row_stride(), None);
7395        assert_eq!(t.effective_row_stride(), Some(400));
7396        assert_eq!(t.capacity_bytes(), 400 * 120);
7397    }
7398
7399    #[test]
7400    #[cfg(target_os = "linux")]
7401    fn dma_packed_image_records_the_aligned_pitch_only_when_padded() {
7402        let _lock = crate::tests::fd_lock_shared();
7403        if !crate::test_support::dma_or_skip(
7404            "dma_packed_image_records_the_aligned_pitch_only_when_padded",
7405        ) {
7406            return;
7407        }
7408        let t = Tensor::<u8>::image(
7409            100,
7410            10,
7411            PixelFormat::Rgba,
7412            Some(TensorMemory::DmaBuf),
7413            CpuAccess::ReadWrite,
7414        )
7415        .unwrap();
7416        assert_eq!(t.row_stride(), Some(448), "400 B rows pad to 448");
7417        let t = Tensor::<u8>::image(
7418            64,
7419            10,
7420            PixelFormat::Rgba,
7421            Some(TensorMemory::DmaBuf),
7422            CpuAccess::ReadWrite,
7423        )
7424        .unwrap();
7425        assert_eq!(t.row_stride(), None, "256 B rows are already aligned");
7426    }
7427
7428    #[test]
7429    #[cfg(target_os = "linux")]
7430    fn image_with_stride_validates_the_stride_before_the_memory() {
7431        match Tensor::<u8>::image_with_stride(
7432            640,
7433            480,
7434            PixelFormat::Rgba,
7435            2559,
7436            Some(TensorMemory::Mem),
7437            CpuAccess::ReadWrite,
7438        ) {
7439            Err(Error::InvalidArgument(msg)) => {
7440                assert!(msg.contains("2559 < minimum 2560"), "{msg}")
7441            }
7442            other => panic!("expected InvalidArgument, got {other:?}"),
7443        }
7444        match Tensor::<u8>::image_with_stride(
7445            640,
7446            480,
7447            PixelFormat::Rgba,
7448            2560,
7449            Some(TensorMemory::Mem),
7450            CpuAccess::ReadWrite,
7451        ) {
7452            Err(Error::NotImplemented(msg)) => assert!(msg.contains("only TensorMemory::DmaBuf")),
7453            other => panic!("a valid stride on Mem must reach the memory check, got {other:?}"),
7454        }
7455    }
7456
7457    #[test]
7458    fn set_format_checks_the_planar_and_semi_planar_shape() {
7459        let mut t = raw_mem::<u8>(&[480, 640, 3]);
7460        match t.set_format(PixelFormat::PlanarRgb) {
7461            Err(Error::InvalidShape(msg)) => assert!(msg.contains("[3, H, W]"), "{msg}"),
7462            other => panic!("[480, 640, 3] is not planar, got {other:?}"),
7463        }
7464        assert!(raw_mem::<u8>(&[3, 480, 640])
7465            .set_format(PixelFormat::PlanarRgb)
7466            .is_ok());
7467
7468        for (format, bad, good) in [
7469            (PixelFormat::Nv12, 721, [720usize, 722]),
7470            (PixelFormat::Nv16, 481, [480, 482]),
7471            (PixelFormat::Nv24, 1441, [1440, 1443]),
7472        ] {
7473            match raw_mem::<u8>(&[bad, 640]).set_format(format) {
7474                Err(Error::InvalidShape(msg)) => {
7475                    assert!(msg.contains(&bad.to_string()), "{format:?}: {msg}")
7476                }
7477                other => panic!("{format:?} [{bad}, 640] must be refused, got {other:?}"),
7478            }
7479            for rows in good {
7480                raw_mem::<u8>(&[rows, 640])
7481                    .set_format(format)
7482                    .unwrap_or_else(|e| panic!("{format:?} [{rows}, 640]: {e}"));
7483            }
7484        }
7485    }
7486
7487    #[test]
7488    fn contiguous_semi_planar_height_divides_the_combined_rows() {
7489        assert_eq!(
7490            mem_image::<u8>(320, 240, PixelFormat::Nv24).height(),
7491            Some(240)
7492        );
7493        assert_eq!(
7494            mem_image::<u8>(320, 240, PixelFormat::Nv16).height(),
7495            Some(240)
7496        );
7497        assert_eq!(
7498            mem_image::<u8>(320, 241, PixelFormat::Nv12).height(),
7499            Some(241)
7500        );
7501    }
7502
7503    #[test]
7504    fn effective_row_stride_scales_with_the_element_size() {
7505        assert_eq!(
7506            mem_image::<f32>(10, 4, PixelFormat::Rgb).effective_row_stride(),
7507            Some(120)
7508        );
7509        assert_eq!(
7510            mem_image::<f32>(10, 4, PixelFormat::PlanarRgb).effective_row_stride(),
7511            Some(40)
7512        );
7513        let mut t = raw_mem::<u16>(&[360, 320]);
7514        t.set_format(PixelFormat::Nv12).unwrap();
7515        assert_eq!(t.effective_row_stride(), Some(640));
7516        let mut t = raw_mem::<u16>(&[360, 321]);
7517        t.set_format(PixelFormat::Nv12).unwrap();
7518        assert_eq!(
7519            t.effective_row_stride(),
7520            Some(644),
7521            "odd width rounds up to even"
7522        );
7523    }
7524
7525    #[test]
7526    fn set_row_stride_minimum_scales_with_the_element_size() {
7527        let refuse = |mut t: Tensor<f32>, stride: usize, min: usize| match t.set_row_stride(stride)
7528        {
7529            Err(Error::InvalidArgument(msg)) => {
7530                assert!(msg.contains(&format!("{stride} < minimum {min}")), "{msg}")
7531            }
7532            other => panic!("stride {stride} under {min} must be refused, got {other:?}"),
7533        };
7534        refuse(mem_image::<f32>(10, 4, PixelFormat::Rgb), 119, 120);
7535        refuse(mem_image::<f32>(10, 4, PixelFormat::PlanarRgb), 39, 40);
7536        let mut t = mem_image::<f32>(10, 4, PixelFormat::Rgb);
7537        t.set_row_stride(120).unwrap();
7538        assert_eq!(t.row_stride(), Some(120));
7539        let mut t = mem_image::<f32>(10, 4, PixelFormat::PlanarRgb);
7540        t.set_row_stride(40).unwrap();
7541        assert_eq!(t.row_stride(), Some(40));
7542
7543        let mut t = mem_image::<u16>(101, 64, PixelFormat::Nv12);
7544        match t.set_row_stride(203) {
7545            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("203 < minimum 204"), "{msg}"),
7546            other => panic!("expected InvalidArgument, got {other:?}"),
7547        }
7548        t.set_row_stride(204).unwrap();
7549    }
7550
7551    #[test]
7552    fn configure_image_semi_planar_stride_scales_with_the_element_size() {
7553        let mut t = mem_image::<u16>(640, 480, PixelFormat::Rgba);
7554        t.configure_image(100, 64, PixelFormat::Nv12).unwrap();
7555        assert_eq!(t.row_stride(), Some(256));
7556    }
7557
7558    #[test]
7559    fn configure_image_keeps_host_packed_odd_widths_tight() {
7560        let mut t = mem_image::<u8>(640, 480, PixelFormat::Rgb);
7561        t.configure_image(321, 240, PixelFormat::Rgb).unwrap();
7562        assert_eq!(t.row_stride(), None);
7563        assert_eq!(t.effective_row_stride(), Some(963));
7564    }
7565
7566    #[test]
7567    fn configure_image_does_not_inherit_a_stride_under_the_same_format() {
7568        // Host packed: a stride set for one geometry must not survive into
7569        // the next, where it is below the new minimum row.
7570        let mut t = mem_image::<u8>(640, 480, PixelFormat::Rgb);
7571        t.configure_image(100, 100, PixelFormat::Rgb).unwrap();
7572        t.set_row_stride(400).unwrap();
7573        t.configure_image(300, 100, PixelFormat::Rgb).unwrap();
7574        assert_eq!(t.row_stride(), None);
7575        assert_eq!(t.effective_row_stride(), Some(900));
7576
7577        // Semi-planar whose aligned stride does not fit: the tight row does,
7578        // and must be what the tensor reports -- not the old 64.
7579        let mut t = mem_image::<u8>(64, 1000, PixelFormat::Nv12);
7580        assert_eq!(t.row_stride(), Some(64));
7581        t.configure_image(200, 300, PixelFormat::Nv12).unwrap();
7582        assert_eq!(t.row_stride(), None);
7583        assert_eq!(t.effective_row_stride(), Some(200));
7584    }
7585
7586    #[test]
7587    fn configure_image_pool_reuse_picks_the_stride_that_fits() {
7588        // A prior stride narrower than the new row is replaced by the
7589        // aligned one.
7590        let mut t = mem_image::<u8>(64, 1024, PixelFormat::Nv12);
7591        t.configure_image(200, 100, PixelFormat::Nv12).unwrap();
7592        assert_eq!(t.row_stride(), Some(256));
7593
7594        // An unaligned prior stride is replaced too.
7595        let mut t = mem_image::<u8>(64, 64, PixelFormat::Nv12);
7596        t.set_row_stride(100).unwrap();
7597        t.configure_image(64, 64, PixelFormat::Nv12).unwrap();
7598        assert_eq!(t.row_stride(), Some(64));
7599
7600        // A wide prior stride whose rows would overrun the allocation gives
7601        // way to the aligned stride of the new width.
7602        let mut t = mem_image::<u8>(640, 480, PixelFormat::Nv12);
7603        t.configure_image(320, 900, PixelFormat::Nv12).unwrap();
7604        assert_eq!(t.row_stride(), Some(320));
7605
7606        // Neither the aligned stride fits nor is it needed: the tight row
7607        // exactly fills a raw allocation.
7608        let mut t = raw_mem::<u8>(&[150 * 100]);
7609        t.configure_image(100, 100, PixelFormat::Nv12).unwrap();
7610        assert_eq!(t.row_stride(), None);
7611        assert_eq!(t.effective_row_stride(), Some(100));
7612    }
7613
7614    #[test]
7615    fn configure_image_refuses_rows_that_overrun_the_allocation() {
7616        // Odd width 99: the shape [150, 99] fits 14850 bytes, but NV12 rows
7617        // are at least the even width, 100 * 150 = 15000 bytes.
7618        let mut t = raw_mem::<u8>(&[150 * 99]);
7619        match t.configure_image(99, 100, PixelFormat::Nv12) {
7620            Err(Error::InsufficientCapacity { needed, capacity }) => {
7621                assert_eq!((needed, capacity), (15000, 14850))
7622            }
7623            other => panic!("expected InsufficientCapacity, got {other:?}"),
7624        }
7625    }
7626
7627    #[test]
7628    #[cfg(target_os = "linux")]
7629    fn configure_image_on_dma_aligns_packed_and_planar_rows() {
7630        let _lock = crate::tests::fd_lock_shared();
7631        if !crate::test_support::dma_or_skip("configure_image_on_dma_aligns_packed_and_planar_rows")
7632        {
7633            return;
7634        }
7635        // Packed u16 Rgb at width 100: 600 B rows align to 640.
7636        let mut t = Tensor::<u16>::image(
7637            640,
7638            480,
7639            PixelFormat::Rgba,
7640            Some(TensorMemory::DmaBuf),
7641            CpuAccess::ReadWrite,
7642        )
7643        .unwrap();
7644        t.configure_image(100, 50, PixelFormat::Rgb).unwrap();
7645        assert_eq!(t.row_stride(), Some(640));
7646
7647        // Planar u16 at width 100: 200 B rows would align to 256, but 150
7648        // rows of 256 B overrun the 32 KiB buffer while 150 rows of 200 B fit.
7649        let mut t = Tensor::<u16>::new(&[16 * 1024], Some(TensorMemory::DmaBuf), None).unwrap();
7650        assert_eq!(t.capacity_bytes(), 32 * 1024);
7651        t.configure_image(100, 50, PixelFormat::PlanarRgb).unwrap();
7652        assert_eq!(t.row_stride(), None);
7653        assert_eq!(t.effective_row_stride(), Some(200));
7654        t.configure_image(100, 40, PixelFormat::PlanarRgb).unwrap();
7655        assert_eq!(t.row_stride(), Some(256), "120 rows of 256 B fit");
7656    }
7657
7658    #[test]
7659    fn from_planes_requires_a_raw_two_dimensional_chroma() {
7660        let formatted_chroma = mem_image::<u8>(640, 240, PixelFormat::Grey);
7661        match Tensor::from_planes(
7662            raw_mem::<u8>(&[480, 640]),
7663            formatted_chroma,
7664            PixelFormat::Nv12,
7665        ) {
7666            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("raw tensor"), "{msg}"),
7667            other => panic!("a formatted chroma plane must be refused, got {other:?}"),
7668        }
7669        match Tensor::from_planes(
7670            raw_mem::<u8>(&[480, 640, 1]),
7671            raw_mem::<u8>(&[240, 640]),
7672            PixelFormat::Nv12,
7673        ) {
7674            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("2D shapes"), "{msg}"),
7675            other => panic!("a 3-D luma plane must be refused, got {other:?}"),
7676        }
7677    }
7678
7679    #[test]
7680    fn from_planes_nv16_needs_full_height_chroma() {
7681        let img = Tensor::from_planes(
7682            raw_mem::<u8>(&[480, 640]),
7683            raw_mem::<u8>(&[480, 640]),
7684            PixelFormat::Nv16,
7685        )
7686        .expect("NV16 chroma is full height");
7687        assert_eq!(img.format(), Some(PixelFormat::Nv16));
7688        assert_eq!(img.height(), Some(480));
7689
7690        match Tensor::from_planes(
7691            raw_mem::<u8>(&[480, 640]),
7692            raw_mem::<u8>(&[240, 640]),
7693            PixelFormat::Nv16,
7694        ) {
7695            Err(Error::InvalidArgument(msg)) => {
7696                assert!(msg.contains("NV16 chroma height"), "{msg}")
7697            }
7698            other => panic!("half-height NV16 chroma must be refused, got {other:?}"),
7699        }
7700    }
7701
7702    #[test]
7703    fn chroma_mut_writes_the_chroma_plane() {
7704        let mut img = Tensor::from_planes(
7705            raw_mem::<u8>(&[4, 8]),
7706            raw_mem::<u8>(&[2, 8]),
7707            PixelFormat::Nv12,
7708        )
7709        .unwrap();
7710        let chroma = img.chroma_mut().expect("a multiplane image has chroma");
7711        chroma.map().unwrap().as_mut_slice().fill(0x80);
7712        let chroma = img.chroma().unwrap();
7713        assert!(chroma.map().unwrap().as_slice().iter().all(|&b| b == 0x80));
7714    }
7715
7716    #[test]
7717    fn view_offset_and_stride_scale_with_the_element_size() {
7718        let t = mem_image::<u8>(100, 80, PixelFormat::Rgba);
7719        let v = t.view(Region::new(10, 20, 30, 40)).unwrap();
7720        assert_eq!(v.plane_offset(), Some(20 * 400 + 10 * 4));
7721        assert_eq!(v.row_stride(), Some(400));
7722
7723        let t = mem_image::<u16>(100, 80, PixelFormat::Rgba);
7724        let v = t.view(Region::new(10, 20, 30, 40)).unwrap();
7725        assert_eq!(v.plane_offset(), Some(20 * 800 + 10 * 8));
7726        assert_eq!(v.row_stride(), Some(800));
7727        assert_eq!(v.view_origin().map(|o| o.parent_row_stride), Some(800));
7728    }
7729
7730    #[test]
7731    fn single_row_view_keeps_a_tight_stride() {
7732        let t = mem_image::<u8>(8, 4, PixelFormat::Rgba);
7733        let row = t.view(Region::new(2, 3, 4, 1)).unwrap();
7734        assert_eq!(row.effective_row_stride(), Some(16));
7735        assert_eq!(row.map().unwrap().as_slice().len(), 16);
7736        assert_eq!(row.view_origin().map(|o| o.parent_row_stride), Some(32));
7737
7738        let two_rows = t.view(Region::new(2, 2, 4, 2)).unwrap();
7739        assert_eq!(two_rows.effective_row_stride(), Some(32));
7740    }
7741
7742    #[test]
7743    fn view_refuses_a_region_past_either_edge() {
7744        let t = mem_image::<u8>(64, 40, PixelFormat::Rgba);
7745        for region in [Region::new(0, 30, 10, 20), Region::new(60, 0, 10, 1)] {
7746            match t.view(region) {
7747                Err(Error::RegionOutOfBounds { region: r, bounds }) => {
7748                    assert_eq!((r, bounds), (region, (64, 40)))
7749                }
7750                other => panic!("{region:?} must be refused, got {other:?}"),
7751            }
7752        }
7753        assert!(Region::new(0, 0, 64, 40).fits_within(64, 40));
7754        assert!(!Region::new(1, 0, 64, 40).fits_within(64, 40));
7755        assert!(!Region::new(0, 1, 64, 40).fits_within(64, 40));
7756    }
7757}
7758
7759#[cfg(all(test, feature = "static"))]
7760mod strided_access_tests {
7761    use super::*;
7762
7763    /// A patterned byte view of `t`'s whole mapping: byte `i` is `i % 251`.
7764    fn fill_pattern<T>(t: &Tensor<T>)
7765    where
7766        T: Num + Clone + fmt::Debug + Send + Sync,
7767    {
7768        let mut m = t.map().unwrap();
7769        let s = m.as_mut_slice();
7770        // SAFETY: plain numeric elements; every byte pattern is a valid value
7771        // for the integer and float types these tests use.
7772        let bytes = unsafe {
7773            std::slice::from_raw_parts_mut(s.as_mut_ptr() as *mut u8, std::mem::size_of_val(s))
7774        };
7775        for (i, b) in bytes.iter_mut().enumerate() {
7776            *b = (i % 251) as u8;
7777        }
7778    }
7779
7780    fn expected_rows(rows: usize, stride: usize, row_bytes: usize) -> Vec<u8> {
7781        (0..rows)
7782            .flat_map(|r| (r * stride..r * stride + row_bytes).map(|i| (i % 251) as u8))
7783            .collect()
7784    }
7785
7786    fn padded<T>(format: PixelFormat, stride: usize) -> Tensor<T>
7787    where
7788        T: Num + Clone + fmt::Debug + Send + Sync + 'static,
7789    {
7790        let mut t = Tensor::<T>::image_with_capacity(
7791            16,
7792            4,
7793            format,
7794            Some(TensorMemory::Mem),
7795            CpuAccess::ReadWrite,
7796        )
7797        .unwrap();
7798        t.configure_image(8, 4, format).unwrap();
7799        t.set_row_stride(stride).unwrap();
7800        t
7801    }
7802
7803    #[test]
7804    fn copy_to_flat_compacts_padded_packed_rows() {
7805        // 8 px * 3 B = 24 B rows at a 32 B pitch.
7806        let t = padded::<u8>(PixelFormat::Rgb, 32);
7807        fill_pattern(&t);
7808        let mut dst = vec![0u8; 96];
7809        t.copy_to_flat(&mut dst).unwrap();
7810        assert_eq!(dst, expected_rows(4, 32, 24));
7811    }
7812
7813    #[test]
7814    fn copy_to_flat_compacts_padded_planar_rows() {
7815        // f32 planar: 3 planes x 4 rows of 32 B at a 64 B pitch.
7816        let t = padded::<f32>(PixelFormat::PlanarRgb, 64);
7817        fill_pattern(&t);
7818        let mut dst = vec![0u8; 384];
7819        t.copy_to_flat(&mut dst).unwrap();
7820        assert_eq!(dst, expected_rows(12, 64, 32));
7821    }
7822
7823    #[test]
7824    fn copy_to_flat_of_a_tight_stride_is_the_mapping() {
7825        let t = padded::<u8>(PixelFormat::Rgb, 24);
7826        fill_pattern(&t);
7827        let mut dst = vec![0u8; 96];
7828        t.copy_to_flat(&mut dst).unwrap();
7829        assert_eq!(dst, expected_rows(1, 96, 96));
7830
7831        let t = Tensor::<u8>::image(
7832            8,
7833            4,
7834            PixelFormat::Rgb,
7835            Some(TensorMemory::Mem),
7836            CpuAccess::ReadWrite,
7837        )
7838        .unwrap();
7839        fill_pattern(&t);
7840        let mut dst = vec![0u8; 96];
7841        t.copy_to_flat(&mut dst).unwrap();
7842        assert_eq!(dst, expected_rows(1, 96, 96));
7843    }
7844
7845    #[test]
7846    fn copy_to_flat_needs_a_tight_sized_destination() {
7847        let t = padded::<u8>(PixelFormat::Rgb, 32);
7848        let mut dst = vec![0u8; 95];
7849        match t.copy_to_flat(&mut dst) {
7850            Err(Error::InvalidArgument(msg)) => {
7851                assert!(
7852                    msg.contains("dst is 95 bytes") && msg.contains("96 bytes"),
7853                    "{msg}"
7854                )
7855            }
7856            other => panic!("expected InvalidArgument, got {other:?}"),
7857        }
7858    }
7859
7860    #[test]
7861    fn copy_to_flat_compacts_a_padded_batch() {
7862        // Two 8x2 RGB tiles at a 32 B pitch: four rows in all.
7863        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
7864        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
7865        fill_pattern(&t);
7866        let mut dst = vec![0u8; 96];
7867        t.copy_to_flat(&mut dst).unwrap();
7868        assert_eq!(dst, expected_rows(4, 32, 24));
7869    }
7870
7871    #[test]
7872    fn strided_planar_map_spans_every_plane_row() {
7873        let t = padded::<u8>(PixelFormat::PlanarRgb, 16);
7874        assert_eq!(t.map().unwrap().as_slice().len(), 16 * 12);
7875    }
7876
7877    #[test]
7878    fn batch_of_a_padded_batch_starts_at_its_padded_tile() {
7879        // Tile 1 starts after two 32 B rows, not after 48 tight bytes.
7880        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
7881        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
7882        fill_pattern(&t);
7883        let e = t.batch(1).unwrap();
7884        assert_eq!(e.plane_offset(), Some(64));
7885        let mut dst = vec![0u8; 48];
7886        e.copy_to_flat(&mut dst).unwrap();
7887        let tile1: Vec<u8> = expected_rows(4, 32, 24).split_off(48);
7888        assert_eq!(dst, tile1);
7889    }
7890
7891    #[test]
7892    fn strided_packed_batch_map_spans_every_tile_row() {
7893        let mut t = padded::<u8>(PixelFormat::Rgb, 32);
7894        t.set_logical_shape(&[2, 2, 8, 3]).unwrap();
7895        assert_eq!(t.map().unwrap().as_slice().len(), 32 * 4);
7896    }
7897
7898    #[test]
7899    fn strided_access_to_a_flattened_image_is_refused() {
7900        let mut t = padded::<u8>(PixelFormat::PlanarRgb, 16);
7901        t.set_logical_shape(&[96]).unwrap();
7902        match t.map() {
7903            Err(Error::InvalidOperation(msg)) => assert!(msg.contains("[96]"), "{msg}"),
7904            other => panic!("expected InvalidOperation, got {other:?}"),
7905        }
7906        let mut dst = vec![0u8; 96];
7907        match t.copy_to_flat(&mut dst) {
7908            Err(Error::InvalidOperation(msg)) => assert!(msg.contains("[96]"), "{msg}"),
7909            other => panic!("expected InvalidOperation, got {other:?}"),
7910        }
7911    }
7912
7913    #[test]
7914    #[cfg(target_os = "linux")]
7915    fn strided_shm_map_is_bounded_by_the_segment() {
7916        let _lock = crate::tests::fd_lock_shared();
7917        let mut t = Tensor::<u8>::image(
7918            640,
7919            480,
7920            PixelFormat::Nv12,
7921            Some(TensorMemory::Shm),
7922            CpuAccess::ReadWrite,
7923        )
7924        .expect("Linux has POSIX shared memory");
7925        assert_eq!(t.capacity_bytes(), 640 * 720);
7926        assert_eq!(t.map().unwrap().as_slice().len(), 640 * 720);
7927
7928        t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
7929        assert_eq!(t.row_stride(), Some(640), "the pool pitch fits and is kept");
7930        assert_eq!(t.map().unwrap().as_slice().len(), 640 * 360);
7931
7932        t.configure_image(640, 480, PixelFormat::Nv12).unwrap();
7933        t.set_row_stride(704).unwrap();
7934        match t.map() {
7935            Err(Error::InsufficientCapacity { needed, capacity }) => {
7936                assert_eq!((needed, capacity), (704 * 720, 640 * 720))
7937            }
7938            other => panic!("expected InsufficientCapacity, got {other:?}"),
7939        }
7940    }
7941
7942    /// Minimal host-memory `PboOps`: a fixed buffer, never resized.
7943    struct HostPbo(Vec<u8>);
7944
7945    // SAFETY: the pointer addresses `self.0`, allocated once and never
7946    // resized, and the tensor holds the `Arc` to `self` for every map's life.
7947    unsafe impl PboOps for HostPbo {
7948        fn map_buffer(&self, _buffer_id: u32, size: usize) -> Result<PboMapping> {
7949            assert!(size <= self.0.len());
7950            Ok(PboMapping {
7951                ptr: self.0.as_ptr() as *mut u8,
7952                size,
7953            })
7954        }
7955        fn unmap_buffer(&self, _buffer_id: u32) -> Result<()> {
7956            Ok(())
7957        }
7958        fn delete_buffer(&self, _buffer_id: u32) {}
7959    }
7960
7961    fn pbo_rgba(width: usize, height: usize) -> Tensor<u8> {
7962        let bytes = width * height * 4;
7963        let pbo = PboTensor::<u8>::from_pbo(
7964            7,
7965            bytes,
7966            &[height, width, 4],
7967            None,
7968            Arc::new(HostPbo(vec![0; bytes])),
7969        )
7970        .unwrap();
7971        let mut t = Tensor::from_pbo(pbo).unwrap();
7972        t.set_format(PixelFormat::Rgba).unwrap();
7973        t
7974    }
7975
7976    #[test]
7977    fn strided_pbo_map_is_bounded_by_the_buffer() {
7978        let mut t = pbo_rgba(8, 4);
7979        t.set_row_stride(32).unwrap();
7980        assert_eq!(t.map().unwrap().as_slice().len(), 128);
7981
7982        t.set_row_stride(64).unwrap();
7983        match t.map() {
7984            Err(Error::InsufficientCapacity { needed, capacity }) => {
7985                assert_eq!((needed, capacity), (256, 128))
7986            }
7987            other => panic!("expected InsufficientCapacity, got {other:?}"),
7988        }
7989    }
7990
7991    #[test]
7992    fn pbo_accessors_answer_only_for_a_pbo_tensor() {
7993        let mem = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
7994        assert_eq!(mem.pbo_is_mapped(), None);
7995        assert!(mem.pbo_keepalive().is_none());
7996
7997        let t = pbo_rgba(8, 4);
7998        assert_eq!(t.pbo_is_mapped(), Some(false));
7999        assert!(t.pbo_keepalive().is_some());
8000        let m = t.map().unwrap();
8001        assert_eq!(t.pbo_is_mapped(), Some(true));
8002        drop(m);
8003        assert_eq!(t.pbo_is_mapped(), Some(false));
8004    }
8005}
8006
8007#[cfg(all(test, feature = "static"))]
8008mod accessor_tests {
8009    use super::*;
8010    use std::sync::atomic::{AtomicUsize, Ordering};
8011
8012    #[test]
8013    #[cfg(unix)]
8014    fn plane_descriptor_carries_its_stride_and_offset() {
8015        use std::os::fd::AsFd;
8016        let _lock = crate::tests::fd_lock_shared();
8017        let file = std::fs::File::open("/dev/null").unwrap();
8018        let pd = PlaneDescriptor::new(file.as_fd()).unwrap();
8019        assert_eq!((pd.stride(), pd.offset()), (None, None));
8020        let pd = pd.with_stride(832).with_offset(4096);
8021        assert_eq!((pd.stride(), pd.offset()), (Some(832), Some(4096)));
8022    }
8023
8024    #[test]
8025    #[cfg(target_os = "linux")]
8026    fn as_dma_answers_only_for_a_dma_buffer() {
8027        use std::os::fd::AsRawFd;
8028        let _lock = crate::tests::fd_lock_shared();
8029        let mem = Tensor::<u8>::new(&[64], Some(TensorMemory::Mem), None).unwrap();
8030        assert!(mem.as_dma().is_none());
8031        if !crate::test_support::dma_or_skip("as_dma_answers_only_for_a_dma_buffer") {
8032            return;
8033        }
8034        let t = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).unwrap();
8035        let dma = t.as_dma().expect("a DMA tensor downcasts");
8036        assert_eq!(
8037            dma.fd.as_raw_fd(),
8038            t.dmabuf().unwrap().as_raw_fd(),
8039            "the downcast is this tensor's own buffer"
8040        );
8041    }
8042
8043    #[test]
8044    fn derived_identity_mixes_the_kind_into_the_key() {
8045        let id = BufferIdentity::derived(IdentityKind::Shm, u64::MAX);
8046        assert_eq!(id.id(), ((IdentityKind::Shm as u64) << 56) ^ u64::MAX);
8047        assert_ne!(
8048            id.id(),
8049            BufferIdentity::derived(IdentityKind::DmaBuf, u64::MAX).id()
8050        );
8051    }
8052
8053    #[test]
8054    fn empty_is_a_zero_element_shape() {
8055        let mut t = Tensor::<f32>::new(&[2, 3], Some(TensorMemory::Mem), None).unwrap();
8056        assert!(!TensorTrait::is_empty(&t));
8057        {
8058            let m = t.map().unwrap();
8059            assert_eq!(TensorMapTrait::len(&m), 6);
8060            assert_eq!(TensorMapTrait::size(&m), 24);
8061            assert!(!TensorMapTrait::is_empty(&m));
8062        }
8063        t.set_logical_shape(&[0]).unwrap();
8064        assert!(TensorTrait::is_empty(&t));
8065        let m = t.map().unwrap();
8066        assert_eq!(TensorMapTrait::size(&m), 0);
8067        assert!(TensorMapTrait::is_empty(&m));
8068    }
8069
8070    struct CountingCudaOps {
8071        maps: AtomicUsize,
8072    }
8073
8074    impl CudaGlOps for CountingCudaOps {
8075        fn map(&self, _r: crate::cuda::GraphicsResource) -> Option<(*mut std::ffi::c_void, usize)> {
8076            self.maps.fetch_add(1, Ordering::SeqCst);
8077            Some((0x1000usize as *mut _, 64))
8078        }
8079        fn unmap(&self, _r: crate::cuda::GraphicsResource) {}
8080        fn unregister(&self, _r: crate::cuda::GraphicsResource) {}
8081    }
8082
8083    #[test]
8084    fn cuda_accessors_follow_the_attached_handle() {
8085        let mut t = Tensor::<f32>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
8086        assert!(!t.is_cuda_attached());
8087        assert!(t.cuda().is_none());
8088        assert!(t.cuda_map().is_none());
8089        assert!(t.cuda_map_mut().is_none());
8090
8091        let ops = Arc::new(CountingCudaOps {
8092            maps: AtomicUsize::new(0),
8093        });
8094        t.set_cuda_handle(CudaHandle::new_gl(
8095            0x1usize as crate::cuda::GraphicsResource,
8096            64,
8097            ops.clone(),
8098        ));
8099        assert!(t.is_cuda_attached());
8100        assert!(t.cuda().is_some());
8101        assert_eq!(t.cuda_map().map(|m| m.device_ptr() as usize), Some(0x1000));
8102        assert_eq!(t.cuda_map_mut().map(|m| m.len()), Some(64));
8103        assert_eq!(ops.maps.load(Ordering::SeqCst), 2);
8104    }
8105
8106    #[test]
8107    fn from_slice_copies_values_of_a_matching_shape() {
8108        let t = Tensor::<u16>::from_slice(&[1, 2, 3, 4, 5, 6], &[2, 3]).unwrap();
8109        assert_eq!(t.shape(), &[2, 3]);
8110        assert_eq!(t.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6]);
8111        match Tensor::<u16>::from_slice(&[1, 2, 3, 4, 5], &[2, 3]) {
8112            Err(Error::InvalidShape(msg)) => assert!(msg.contains("values.len()=5"), "{msg}"),
8113            other => panic!("expected InvalidShape, got {other:?}"),
8114        }
8115    }
8116
8117    /// An `Any` compression request with no pinned memory tries the
8118    /// platform's zero-copy image first and falls back when it cannot serve
8119    /// the format (packed RGB f32 has no IOSurface layout, for one).
8120    #[cfg(not(target_os = "android"))]
8121    #[test]
8122    fn any_compression_request_falls_back_when_zero_copy_cannot_serve() {
8123        let _lock = crate::tests::fd_lock_shared();
8124        let desc =
8125            ImageDesc::new(10, 10, PixelFormat::Rgb, DType::F32).with_compression(Compression::Any);
8126        let t = Tensor::<f32>::image_desc(&desc).expect("Any never fails for compression reasons");
8127        assert_eq!(t.shape(), &[10, 10, 3]);
8128        assert_eq!(t.compression(), None);
8129    }
8130}
8131
8132#[cfg(test)]
8133mod cpu_access_tests {
8134    use super::*;
8135
8136    #[test]
8137    fn covers_matrix() {
8138        use CpuAccess::*;
8139        // Every declaration covers a narrower or equal request…
8140        for a in [None, Read, Write, ReadWrite] {
8141            assert!(a.covers(None), "{a:?} must cover None");
8142            assert!(ReadWrite.covers(a), "ReadWrite must cover {a:?}");
8143        }
8144        assert!(Read.covers(Read));
8145        assert!(Write.covers(Write));
8146        // …and never a wider one.
8147        assert!(!None.covers(Read));
8148        assert!(!None.covers(Write));
8149        assert!(!Read.covers(Write));
8150        assert!(!Read.covers(ReadWrite));
8151        assert!(!Write.covers(Read));
8152        assert!(!Write.covers(ReadWrite));
8153    }
8154
8155    #[test]
8156    fn map_with_none_is_invalid() {
8157        let t = Tensor::<u8>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
8158        match t.map_with(CpuAccess::None) {
8159            Err(Error::InvalidArgument(_)) => {}
8160            Err(other) => panic!("expected InvalidArgument, got {other:?}"),
8161            Ok(_) => panic!("map_with(CpuAccess::None) must not succeed"),
8162        }
8163    }
8164
8165    #[test]
8166    fn read_map_rejects_mutation_uniformly() {
8167        // Mem backend: map_read yields a working read view whose mutable
8168        // accessor panics (the uniform cross-backend contract).
8169        let t = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8170        t.map_mut().unwrap().as_mut_slice().copy_from_slice(&[7; 8]);
8171        let ro = t.map_read().unwrap();
8172        assert_eq!(ro.as_slice(), &[7; 8]);
8173        drop(ro);
8174        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8175            let mut ro = t.map_read().unwrap();
8176            let _ = ro.as_mut_slice();
8177        }));
8178        assert!(result.is_err(), "as_mut_slice through map_read must panic");
8179    }
8180
8181    #[test]
8182    fn write_and_rw_maps_stay_mutable() {
8183        let t = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
8184        t.map_write()
8185            .unwrap()
8186            .as_mut_slice()
8187            .copy_from_slice(&[1; 4]);
8188        t.map().unwrap().as_mut_slice().copy_from_slice(&[2; 4]);
8189        assert_eq!(t.map_read().unwrap().as_slice(), &[2; 4]);
8190    }
8191
8192    /// The read-only IOSurface lock path: a `map_read` must observe data
8193    /// written through a prior read-write lock, and its unlock (which
8194    /// skips the cache flush) must not disturb subsequent reads.
8195    #[test]
8196    #[cfg(target_os = "macos")]
8197    fn iosurface_read_only_lock_roundtrip() {
8198        // Declares this test as an fd-opener; see FD_LOCK.
8199        let _lock = crate::tests::fd_lock_shared();
8200        let Ok(t) = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None) else {
8201            crate::test_support::report_skip("IOSurface unavailable");
8202            return;
8203        };
8204        {
8205            let mut m = t.map_mut().unwrap();
8206            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
8207                *b = (i * 3) as u8;
8208            }
8209        }
8210        for _ in 0..2 {
8211            let ro = t.map_read().unwrap();
8212            for (i, b) in ro.as_slice().iter().enumerate() {
8213                assert_eq!(*b, (i * 3) as u8, "byte {i} through read-only lock");
8214            }
8215        }
8216        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8217            let mut ro = t.map_read().unwrap();
8218            let _ = ro.as_mut_slice();
8219        }));
8220        assert!(result.is_err(), "IOSurface read map must reject mutation");
8221    }
8222}
8223
8224#[cfg(test)]
8225mod tests {
8226    use std::sync::{PoisonError, RwLock};
8227
8228    use super::*;
8229
8230    #[ctor::ctor(unsafe)]
8231    fn init() {
8232        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
8233    }
8234
8235    /// Macro to get the current function name for logging in tests.
8236    #[cfg(target_os = "linux")]
8237    macro_rules! function {
8238        () => {{
8239            fn f() {}
8240            fn type_name_of<T>(_: T) -> &'static str {
8241                std::any::type_name::<T>()
8242            }
8243            let name = type_name_of(f);
8244
8245            // Find and cut the rest of the path
8246            match &name[..name.len() - 3].rfind(':') {
8247                Some(pos) => &name[pos + 1..name.len() - 3],
8248                None => &name[..name.len() - 3],
8249            }
8250        }};
8251    }
8252
8253    #[test]
8254    #[cfg(target_os = "linux")]
8255    fn test_tensor() {
8256        let _lock = crate::tests::fd_lock_shared();
8257        let shape = vec![1];
8258        let tensor = DmaTensor::<f32>::new(&shape, Some("dma_tensor"));
8259        let dma_enabled = tensor.is_ok();
8260
8261        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8262        // Auto-select priority is Dma > Mem; Shm is never auto-selected.
8263        match dma_enabled {
8264            true => assert_eq!(tensor.memory(), TensorMemory::DmaBuf),
8265            false => assert_eq!(tensor.memory(), TensorMemory::Mem),
8266        }
8267    }
8268
8269    #[test]
8270    #[cfg(any(target_os = "macos", target_os = "ios"))]
8271    fn test_tensor() {
8272        let shape = vec![1];
8273        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8274        // macOS/iOS auto-fallback chain: IOSurface (Dma) → Mem. Healthy systems
8275        // return Dma; Mem only appears under memory pressure or sandboxed
8276        // contexts where IOSurfaceCreate fails. Shm is never auto-selected.
8277        let m = tensor.memory();
8278        assert!(
8279            matches!(m, TensorMemory::DmaBuf | TensorMemory::Mem),
8280            "Unexpected auto-fallback result on macOS/iOS: {m:?}"
8281        );
8282    }
8283
8284    #[test]
8285    #[cfg(all(
8286        unix,
8287        not(any(target_os = "linux", target_os = "macos", target_os = "ios"))
8288    ))]
8289    fn test_tensor() {
8290        let shape = vec![1];
8291        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8292        // Other Unix (BSD): no DMA, so auto-selection is Mem (Shm is
8293        // explicit-only, never auto-selected).
8294        assert_eq!(tensor.memory(), TensorMemory::Mem);
8295    }
8296
8297    #[test]
8298    #[cfg(not(unix))]
8299    fn test_tensor() {
8300        let shape = vec![1];
8301        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8302        assert_eq!(tensor.memory(), TensorMemory::Mem);
8303    }
8304
8305    #[test]
8306    #[cfg(target_os = "linux")]
8307    fn test_dma_tensor() {
8308        let _lock = crate::tests::fd_lock_shared();
8309        if !crate::test_support::dma_or_skip("test_dma_tensor") {
8310            return;
8311        }
8312
8313        let shape = vec![2, 3, 4];
8314        let tensor =
8315            DmaTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
8316
8317        const DUMMY_VALUE: f32 = 12.34;
8318
8319        assert_eq!(tensor.memory(), TensorMemory::DmaBuf);
8320        assert_eq!(tensor.name(), "test_tensor");
8321        assert_eq!(tensor.shape(), &shape);
8322        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
8323        assert_eq!(tensor.len(), 2 * 3 * 4);
8324
8325        {
8326            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8327            tensor_map.fill(42.0);
8328            assert!(tensor_map.iter().all(|&x| x == 42.0));
8329        }
8330
8331        {
8332            let shared = Tensor::<f32>::from_fd(
8333                tensor
8334                    .clone_fd()
8335                    .expect("Failed to duplicate tensor file descriptor"),
8336                &shape,
8337                Some("test_tensor_shared"),
8338            )
8339            .expect("Failed to create tensor from fd");
8340
8341            assert_eq!(shared.memory(), TensorMemory::DmaBuf);
8342            assert_eq!(shared.name(), "test_tensor_shared");
8343            assert_eq!(shared.shape(), &shape);
8344
8345            let mut tensor_map = shared.map().expect("Failed to map DMA memory from fd");
8346            tensor_map.fill(DUMMY_VALUE);
8347            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8348        }
8349
8350        {
8351            let tensor_map = tensor.map().expect("Failed to map DMA memory");
8352            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8353        }
8354
8355        let mut tensor = DmaTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
8356        assert_eq!(tensor.shape(), &shape);
8357        let new_shape = vec![3, 4, 4];
8358        assert!(
8359            tensor.reshape(&new_shape).is_err(),
8360            "Reshape should fail due to size mismatch"
8361        );
8362        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
8363
8364        let new_shape = vec![2, 3, 4];
8365        tensor.reshape(&new_shape).expect("Reshape should succeed");
8366        assert_eq!(
8367            tensor.shape(),
8368            &new_shape,
8369            "Shape should be updated after successful reshape"
8370        );
8371
8372        {
8373            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8374            tensor_map.fill(1);
8375            assert!(tensor_map.iter().all(|&x| x == 1));
8376        }
8377
8378        {
8379            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
8380            tensor_map[2] = 42;
8381            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
8382            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
8383        }
8384    }
8385
8386    #[test]
8387    #[cfg(unix)]
8388    fn test_shm_tensor() {
8389        let _lock = crate::tests::fd_lock_shared();
8390        if !is_shm_available() {
8391            log::warn!("SKIPPED: test_shm_tensor - SHM allocation unavailable on this platform");
8392            return;
8393        }
8394        let shape = vec![2, 3, 4];
8395        let tensor =
8396            ShmTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
8397        assert_eq!(tensor.shape(), &shape);
8398        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
8399        assert_eq!(tensor.name(), "test_tensor");
8400
8401        const DUMMY_VALUE: f32 = 12.34;
8402        {
8403            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8404            tensor_map.fill(42.0);
8405            assert!(tensor_map.iter().all(|&x| x == 42.0));
8406        }
8407
8408        {
8409            let shared = Tensor::<f32>::from_fd(
8410                tensor
8411                    .clone_fd()
8412                    .expect("Failed to duplicate tensor file descriptor"),
8413                &shape,
8414                Some("test_tensor_shared"),
8415            )
8416            .expect("Failed to create tensor from fd");
8417
8418            assert_eq!(shared.memory(), TensorMemory::Shm);
8419            assert_eq!(shared.name(), "test_tensor_shared");
8420            assert_eq!(shared.shape(), &shape);
8421
8422            let mut tensor_map = shared.map().expect("Failed to map shared memory from fd");
8423            tensor_map.fill(DUMMY_VALUE);
8424            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8425        }
8426
8427        {
8428            let tensor_map = tensor.map().expect("Failed to map shared memory");
8429            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
8430        }
8431
8432        let mut tensor = ShmTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
8433        assert_eq!(tensor.shape(), &shape);
8434        let new_shape = vec![3, 4, 4];
8435        assert!(
8436            tensor.reshape(&new_shape).is_err(),
8437            "Reshape should fail due to size mismatch"
8438        );
8439        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
8440
8441        let new_shape = vec![2, 3, 4];
8442        tensor.reshape(&new_shape).expect("Reshape should succeed");
8443        assert_eq!(
8444            tensor.shape(),
8445            &new_shape,
8446            "Shape should be updated after successful reshape"
8447        );
8448
8449        {
8450            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8451            tensor_map.fill(1);
8452            assert!(tensor_map.iter().all(|&x| x == 1));
8453        }
8454
8455        {
8456            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
8457            tensor_map[2] = 42;
8458            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
8459            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
8460        }
8461    }
8462
8463    #[test]
8464    fn mem_subview_partitions_parent_buffer() {
8465        // One heap [2,4] u8 parent (8 bytes). Two [1,4] sub-views at byte
8466        // offsets 0 and 4 must share the parent allocation (zero-copy) and be
8467        // independently writable: view 0 owns bytes [0,4), view 1 owns [4,8).
8468        // Today this is impossible — heap offset is rejected and there is no
8469        // shared sub-view constructor.
8470        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Mem), None).unwrap();
8471        let view0 = parent.subview(0, &[1, 4]).expect("subview at offset 0");
8472        let view1 = parent.subview(4, &[1, 4]).expect("subview at offset 4");
8473
8474        view1
8475            .map()
8476            .unwrap()
8477            .as_mut_slice()
8478            .copy_from_slice(&[10, 20, 30, 40]);
8479        view0
8480            .map()
8481            .unwrap()
8482            .as_mut_slice()
8483            .copy_from_slice(&[1, 2, 3, 4]);
8484
8485        // Each view sees only its own window.
8486        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
8487        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
8488        // The parent buffer is correctly partitioned (shared, zero-copy).
8489        assert_eq!(
8490            parent.map().unwrap().as_slice(),
8491            &[1, 2, 3, 4, 10, 20, 30, 40]
8492        );
8493    }
8494
8495    #[test]
8496    fn batch_partitions_leading_dim() {
8497        // Raw [4,2,2,3] u8 batched tensor: 4 elements of 12 bytes each. batch(n)
8498        // yields element n at offset n*12, sharing the parent buffer (zero-copy).
8499        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8500        for i in 0..4u8 {
8501            let e = parent.batch(i as usize).expect("batch element");
8502            assert_eq!(e.shape(), &[2, 2, 3]);
8503            // A batch element shares the parent's BufferIdentity.
8504            assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
8505            for b in e.map().unwrap().as_mut_slice() {
8506                *b = i + 1;
8507            }
8508        }
8509        // Each element occupies its own 12-byte band of the parent.
8510        let whole = parent.map().unwrap();
8511        let s = whole.as_slice();
8512        for i in 0..4usize {
8513            assert!(
8514                s[i * 12..(i + 1) * 12].iter().all(|&b| b == (i as u8 + 1)),
8515                "band {i} not partitioned: {:?}",
8516                &s[i * 12..(i + 1) * 12]
8517            );
8518        }
8519    }
8520
8521    #[test]
8522    fn view_origin_snapshots_parent_and_composes() {
8523        // view() on a whole image snapshots the parent dims + the view's origin.
8524        let parent = Tensor::<u8>::image(
8525            100,
8526            80,
8527            PixelFormat::Rgba,
8528            Some(TensorMemory::Mem),
8529            crate::CpuAccess::ReadWrite,
8530        )
8531        .unwrap();
8532        assert_eq!(
8533            parent.view_origin(),
8534            None,
8535            "whole tensor has no view_origin"
8536        );
8537        let v = parent.view(Region::new(10, 20, 30, 40)).unwrap();
8538        assert_eq!(
8539            v.view_origin(),
8540            Some(ViewOrigin {
8541                parent_width: 100,
8542                parent_height: 80,
8543                parent_row_stride: 100 * 4, // tight RGBA pitch
8544                x: 10,
8545                y: 20
8546            })
8547        );
8548        // A view of a view keeps the ROOT parent and accumulates the origin.
8549        let v2 = v.view(Region::new(5, 5, 10, 10)).unwrap();
8550        assert_eq!(
8551            v2.view_origin(),
8552            Some(ViewOrigin {
8553                parent_width: 100,
8554                parent_height: 80,
8555                parent_row_stride: 100 * 4,
8556                x: 15,
8557                y: 25
8558            }),
8559            "nested view composes onto the root parent"
8560        );
8561    }
8562
8563    #[test]
8564    fn view_origin_none_for_raw_batch() {
8565        // A raw (unformatted) batched tensor has no pixel geometry, so batch()
8566        // leaves view_origin None (the per-slot path, not the one-import pivot).
8567        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8568        assert_eq!(parent.batch(2).unwrap().view_origin(), None);
8569    }
8570
8571    #[test]
8572    fn batch_rejects_out_of_bounds_index() {
8573        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8574        match parent.batch(4) {
8575            Err(Error::BatchIndexOutOfBounds { index, batch }) => {
8576                assert_eq!((index, batch), (4, 4));
8577            }
8578            other => panic!("expected BatchIndexOutOfBounds, got {other:?}"),
8579        }
8580    }
8581
8582    #[test]
8583    fn batch_zero_on_unit_n_is_whole() {
8584        // N == 1: batch(0) is the whole per-element block at offset 0 (no plane_offset).
8585        let parent = Tensor::<u8>::new(&[1, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
8586        let e = parent.batch(0).unwrap();
8587        assert_eq!(e.shape(), &[2, 2, 3]);
8588        assert_eq!(e.plane_offset(), None);
8589        assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
8590    }
8591
8592    #[test]
8593    fn mem_subview_rejects_unaligned_offset() {
8594        // f32 has align 4; a byte offset of 2 cannot back a valid `*const f32`.
8595        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8596        assert!(parent.subview(2, &[1]).is_err());
8597        // A correctly aligned offset is accepted.
8598        assert!(parent.subview(4, &[1]).is_ok());
8599    }
8600
8601    #[test]
8602    fn mem_subview_rejects_out_of_bounds() {
8603        let parent = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8604        // offset 6 + 4 bytes = 10 exceeds the 8-byte allocation.
8605        assert!(parent.subview(6, &[4]).is_err());
8606    }
8607
8608    /// Regression guard for the `TensorTrait::view` promotion (R2): a `subview`
8609    /// must share the parent's `BufferIdentity` on **every** backend, not mint a
8610    /// fresh one. Identity-keyed caches (the GL EGLImage import) rely on this to
8611    /// treat offset-distinct windows of one buffer as a single import; a fresh
8612    /// identity would silently break that and regress zero-copy import reuse.
8613    ///
8614    /// Runs each backend that can be allocated without a GPU/GL context on the
8615    /// test host: `Mem` (always); `Shm` (when POSIX shm is available); the
8616    /// platform-native zero-copy buffer `Dma` (DMA-BUF on Linux / IOSurface on
8617    /// macOS, when available). `Pbo` shares its identity the same way (see
8618    /// `pbo.rs` `view`) but needs a live GL context, so it is exercised by the
8619    /// image-crate GL tests rather than here.
8620    #[test]
8621    fn subview_shares_buffer_identity_all_backends() {
8622        // Declares this test as an fd-opener; see FD_LOCK.
8623        let _lock = crate::tests::fd_lock_shared();
8624        // u8 has align 1, so every byte offset is valid for the alignment check;
8625        // this isolates the identity-sharing contract from alignment concerns.
8626        // `window` is the view shape; a formatted parent validates it against
8627        // its own format, so an image parent needs an image-shaped window.
8628        let assert_parent_shares = |parent: Tensor<u8>, window: &[usize], label: &str| {
8629            let parent_id = parent.buffer_identity().id();
8630            // Two offset-distinct windows must both carry the parent's identity.
8631            let v0 = parent
8632                .subview(0, window)
8633                .unwrap_or_else(|e| panic!("{label}: subview(0) failed: {e:?}"));
8634            let v1 = parent
8635                .subview(16, window)
8636                .unwrap_or_else(|e| panic!("{label}: subview(16) failed: {e:?}"));
8637            assert_eq!(
8638                v0.buffer_identity().id(),
8639                parent_id,
8640                "{label}: subview(0) minted a fresh BufferIdentity"
8641            );
8642            assert_eq!(
8643                v1.buffer_identity().id(),
8644                parent_id,
8645                "{label}: subview(16) minted a fresh BufferIdentity"
8646            );
8647        };
8648        let assert_shares = |memory: TensorMemory, label: &str| {
8649            let parent = Tensor::<u8>::new(&[64], Some(memory), None)
8650                .unwrap_or_else(|e| panic!("{label}: parent alloc failed: {e:?}"));
8651            assert_parent_shares(parent, &[16], label);
8652        };
8653
8654        assert_shares(TensorMemory::Mem, "Mem");
8655
8656        #[cfg(unix)]
8657        if crate::is_shm_available() {
8658            assert_shares(TensorMemory::Shm, "Shm");
8659        }
8660
8661        // Dma == DMA-BUF on Linux, IOSurface on macOS, AHardwareBuffer on
8662        // Android; same public variant.
8663        #[cfg(target_os = "linux")]
8664        if crate::test_support::dma_or_skip("subview_shares_buffer_identity_all_backends (Dma)") {
8665            assert_shares(TensorMemory::DmaBuf, "Dma");
8666        }
8667        #[cfg(not(any(target_os = "linux", target_os = "windows")))]
8668        if crate::is_gpu_buffer_available() {
8669            assert_shares(TensorMemory::DmaBuf, "Dma");
8670        }
8671        // Windows' Dma is an image-formatted D3D11 texture, which
8672        // `Tensor::new` deliberately refuses -- allocate it the only way it
8673        // can be allocated and hold it to the same contract.
8674        #[cfg(target_os = "windows")]
8675        if crate::is_gpu_buffer_available() {
8676            let parent = Tensor::<u8>::image(
8677                64,
8678                1,
8679                PixelFormat::Grey,
8680                Some(TensorMemory::DmaBuf),
8681                CpuAccess::ReadWrite,
8682            )
8683            .expect("D3D11 texture alloc failed");
8684            assert_parent_shares(parent, &[1, 16, 1], "Dma");
8685        }
8686    }
8687
8688    /// A sub-view records a plane offset, and on Windows the D3D11 storage
8689    /// already applied that offset itself when the view was made -- so the
8690    /// map's plane-offset gate must accept it, exactly as it does for the
8691    /// other zero-copy backings. It used to refuse, with a message that named
8692    /// every backend but this one.
8693    #[cfg(target_os = "windows")]
8694    #[test]
8695    fn subview_of_a_texture_tensor_maps_at_its_offset() {
8696        if !crate::is_gpu_buffer_available() {
8697            return;
8698        }
8699        let t = Tensor::<u8>::image(
8700            32,
8701            8,
8702            PixelFormat::Rgba,
8703            Some(TensorMemory::DmaBuf),
8704            CpuAccess::ReadWrite,
8705        )
8706        .unwrap();
8707        // For a texture tensor this is the backing pitch either way: the
8708        // allocation records the staging pitch when it exceeds the tight row,
8709        // and the two are equal when it does not.
8710        let stride = t.effective_row_stride().unwrap();
8711        let v = t.subview(stride * 2, &[2, 32, 4]).unwrap();
8712        assert!(
8713            v.plane_offset().is_some_and(|o| o > 0),
8714            "a sub-view at a row offset must carry one"
8715        );
8716        let m = v
8717            .map_read()
8718            .expect("a sub-view of a texture tensor must map");
8719        assert_eq!(m.as_slice().len(), 2 * stride, "two rows of the window");
8720    }
8721
8722    #[test]
8723    fn mem_subview_four_views_no_aliasing() {
8724        // One [4,3] f32 parent; four [1,3] views at 12-byte strides, each
8725        // written independently. Exercises a multi-byte element type (offsets
8726        // must stay element-aligned) and N-way zero-copy sharing.
8727        let parent = Tensor::<f32>::new(&[4, 3], Some(TensorMemory::Mem), None).unwrap();
8728        let frame = 3 * std::mem::size_of::<f32>();
8729        for i in 0..4 {
8730            let v = parent.subview(i * frame, &[1, 3]).unwrap();
8731            let val = i as f32 + 1.0;
8732            v.map()
8733                .unwrap()
8734                .as_mut_slice()
8735                .copy_from_slice(&[val, val, val]);
8736        }
8737        assert_eq!(
8738            parent.map().unwrap().as_slice(),
8739            &[1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0]
8740        );
8741    }
8742
8743    #[test]
8744    fn mem_subview_inherits_format_and_row_stride() {
8745        // A sub-view is a ready-to-use sub-image: it inherits the parent's
8746        // pixel format and (crucially) its padded row stride, so a strided
8747        // parent yields strided windows. Set a stride wider than the tight row
8748        // to exercise the row_stride inheritance path specifically.
8749        let mut parent = Tensor::<u8>::image(
8750            100,
8751            100,
8752            PixelFormat::Rgba,
8753            Some(TensorMemory::Mem),
8754            crate::CpuAccess::ReadWrite,
8755        )
8756        .unwrap();
8757        parent.set_row_stride_unchecked(512); // padded stride (> 100*4)
8758        let view = parent.subview(4096, &[10, 10, 4]).unwrap();
8759        assert_eq!(view.format(), Some(PixelFormat::Rgba), "format inherited");
8760        assert_eq!(view.row_stride(), Some(512), "row_stride inherited");
8761    }
8762
8763    #[test]
8764    fn mem_strided_subview_maps_offset_and_byte_size() {
8765        // Integration of the sub-region offset (PR #89) and the strided-map
8766        // `byte_size_override` (PR #90): a strided sub-view exposes its full
8767        // padded window (`row_stride × rows`) starting at the view's byte
8768        // offset, mapped zero-copy into the parent.
8769        let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::Mem), None).unwrap();
8770        let mut view = parent.subview(128, &[8, 16]).unwrap(); // 8 rows × 16 @ off 128
8771        assert_eq!(view.plane_offset(), Some(128));
8772        view.set_row_stride_unchecked(32); // padded stride (> 16)
8773
8774        {
8775            let mut m = view.map().unwrap();
8776            let s = m.as_mut_slice();
8777            // Strided map exposes the padded window: stride(32) × rows(8) = 256.
8778            assert_eq!(
8779                s.len(),
8780                256,
8781                "strided map exposes the full padded byte window"
8782            );
8783            s[0] = 0xAA; // row 0, col 0
8784            s[32] = 0xBB; // row 1, col 0 (one stride in)
8785        }
8786
8787        // Zero-copy: the writes land in the parent at the view's offset.
8788        let p = parent.map().unwrap();
8789        let pb = p.as_slice();
8790        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
8791        assert_eq!(
8792            pb[128 + 32],
8793            0xBB,
8794            "row 1 writes at parent offset 128 + stride"
8795        );
8796    }
8797
8798    #[test]
8799    #[cfg(unix)]
8800    fn shm_subview_partitions_parent_buffer() {
8801        // Declares this test as an fd-opener; see FD_LOCK.
8802        let _lock = crate::tests::fd_lock_shared();
8803        // Mirrors `mem_subview_partitions_parent_buffer` for Shm: one [2,4] u8
8804        // parent shared segment (8 bytes); two [1,4] sub-views at byte offsets 0
8805        // and 4 must share the segment (zero-copy, via cloned fd) and be
8806        // independently writable — view 0 owns [0,4), view 1 owns [4,8).
8807        if !crate::is_shm_available() {
8808            crate::test_support::report_skip("shm not available");
8809            return;
8810        }
8811        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Shm), None).unwrap();
8812        let view0 = parent.subview(0, &[1, 4]).expect("shm subview at offset 0");
8813        let view1 = parent.subview(4, &[1, 4]).expect("shm subview at offset 4");
8814
8815        view1
8816            .map()
8817            .unwrap()
8818            .as_mut_slice()
8819            .copy_from_slice(&[10, 20, 30, 40]);
8820        view0
8821            .map()
8822            .unwrap()
8823            .as_mut_slice()
8824            .copy_from_slice(&[1, 2, 3, 4]);
8825
8826        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
8827        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
8828        // The parent sees the full partitioned segment (shared, zero-copy).
8829        assert_eq!(
8830            parent.map().unwrap().as_slice(),
8831            &[1, 2, 3, 4, 10, 20, 30, 40]
8832        );
8833        // A sub-view of a sub-view composes the offset.
8834        let nested = view1.subview(2, &[1, 2]).expect("nested shm subview");
8835        assert_eq!(nested.map().unwrap().as_slice(), &[30, 40]);
8836    }
8837
8838    #[test]
8839    #[cfg(unix)]
8840    fn shm_subview_rejects_unaligned_and_oob() {
8841        // Declares this test as an fd-opener; see FD_LOCK.
8842        let _lock = crate::tests::fd_lock_shared();
8843        if !crate::is_shm_available() {
8844            crate::test_support::report_skip("shm not available");
8845            return;
8846        }
8847        // f32 align 4: a 2-byte offset cannot back a valid `*const f32`.
8848        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
8849        assert!(parent.subview(2, &[1]).is_err());
8850        assert!(parent.subview(4, &[1]).is_ok());
8851        // Out of bounds: offset 6 + 4 bytes = 10 > 8-byte (u8) segment.
8852        let p2 = Tensor::<u8>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
8853        assert!(p2.subview(6, &[4]).is_err());
8854    }
8855
8856    #[test]
8857    #[cfg(target_os = "linux")]
8858    fn dma_subview_matches_mem_subview() {
8859        // Serialize against the fd-leak tests: this test opens DMA fds (alloc +
8860        // clone_fd), which would otherwise perturb their fd counts.
8861        let _lock = crate::tests::fd_lock_shared();
8862        // Identical sub-view semantics across Dma (shared fd) and Mem (shared
8863        // Arc): same offsets → same logical windows → same partition.
8864        if !crate::test_support::dma_or_skip("dma_subview_matches_mem_subview") {
8865            return;
8866        }
8867        let dma = Tensor::<u8>::new(&[8], Some(TensorMemory::DmaBuf), None).unwrap();
8868        let mem = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
8869        for parent in [&dma, &mem] {
8870            let v0 = parent.subview(0, &[4]).unwrap();
8871            let v1 = parent.subview(4, &[4]).unwrap();
8872            v0.map()
8873                .unwrap()
8874                .as_mut_slice()
8875                .copy_from_slice(&[1, 2, 3, 4]);
8876            v1.map()
8877                .unwrap()
8878                .as_mut_slice()
8879                .copy_from_slice(&[5, 6, 7, 8]);
8880            assert_eq!(parent.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
8881        }
8882    }
8883
8884    #[test]
8885    #[cfg(target_os = "linux")]
8886    fn dma_strided_subview_maps_padded_window() {
8887        // The strided-map path differs by backing: DMA maps through
8888        // `mmap_offset` + the `byte_size_override`, not the Mem `Arc` slice. A
8889        // padded sub-view of a DMA buffer must still expose its full
8890        // `row_stride × rows` window zero-copy at the view's offset (the GPU
8891        // batched-render-to-DMA case). Mirrors
8892        // `mem_strided_subview_maps_offset_and_byte_size` on a Dma parent.
8893        let _lock = crate::tests::fd_lock_shared();
8894        if !crate::test_support::dma_or_skip("dma_strided_subview_maps_padded_window") {
8895            return;
8896        }
8897        let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::DmaBuf), None).unwrap();
8898        let mut view = parent.subview(128, &[8, 16]).unwrap();
8899        assert_eq!(view.plane_offset(), Some(128));
8900        view.set_row_stride_unchecked(32); // padded stride (> 16)
8901
8902        {
8903            let mut m = view.map().unwrap();
8904            let s = m.as_mut_slice();
8905            assert_eq!(s.len(), 256, "strided DMA map exposes stride(32) × rows(8)");
8906            s[0] = 0xAA; // row 0, col 0
8907            s[32] = 0xBB; // row 1, col 0 (one stride in)
8908        }
8909
8910        let p = parent.map().unwrap();
8911        let pb = p.as_slice();
8912        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
8913        assert_eq!(
8914            pb[128 + 32],
8915            0xBB,
8916            "row 1 writes at parent offset 128 + stride"
8917        );
8918    }
8919
8920    #[test]
8921    #[cfg(target_os = "linux")]
8922    fn view_single_row_snapshots_parent_stride() {
8923        // A single-row `view()` keeps a TIGHT `row_stride` for map-span safety,
8924        // but its `view_origin` snapshots the PARENT row stride — the GL backend
8925        // keys its EGLImage import/pitch on that snapshot (not the view's tight
8926        // stride), so single-row and multi-row sibling views collapse onto the
8927        // same parent import.
8928        let _lock = crate::tests::fd_lock_shared();
8929        // 8x4 RGBA with a padded 64-byte row stride (tight row = 8*4 = 32).
8930        if !crate::test_support::dma_or_skip("view_single_row_snapshots_parent_stride") {
8931            return;
8932        }
8933        let parent = Tensor::<u8>::image_with_stride(
8934            8,
8935            4,
8936            PixelFormat::Rgba,
8937            64,
8938            Some(TensorMemory::DmaBuf),
8939            crate::CpuAccess::ReadWrite,
8940        )
8941        .unwrap();
8942        assert_eq!(parent.effective_row_stride(), Some(64));
8943        // Bottom row (y=3) at x>0 — the case the tight single-row stride guards.
8944        let row = parent.view(Region::new(2, 3, 4, 1)).unwrap();
8945        // The view's own stride is tight (4*4 = 16) so its strided map stays in
8946        // bounds; the GL-facing parent pitch (64) lives in `view_origin`.
8947        assert_eq!(row.effective_row_stride(), Some(16));
8948        let vo = row.view_origin().expect("a view carries a view_origin");
8949        assert_eq!(
8950            vo.parent_row_stride, 64,
8951            "GL keys/pitches a view on the parent stride, not its tight one"
8952        );
8953        // The tight stride keeps map() in-bounds for the bottom / x>0 single row.
8954        assert_eq!(row.map().unwrap().as_slice().len(), 16);
8955    }
8956
8957    #[test]
8958    fn test_mem_tensor() {
8959        let shape = vec![2, 3, 4];
8960        let tensor =
8961            MemTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
8962        assert_eq!(tensor.shape(), &shape);
8963        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
8964        assert_eq!(tensor.name(), "test_tensor");
8965
8966        {
8967            let mut tensor_map = tensor.map().expect("Failed to map memory");
8968            tensor_map.fill(42.0);
8969            assert!(tensor_map.iter().all(|&x| x == 42.0));
8970        }
8971
8972        let mut tensor = MemTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
8973        assert_eq!(tensor.shape(), &shape);
8974        let new_shape = vec![3, 4, 4];
8975        assert!(
8976            tensor.reshape(&new_shape).is_err(),
8977            "Reshape should fail due to size mismatch"
8978        );
8979        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
8980
8981        let new_shape = vec![2, 3, 4];
8982        tensor.reshape(&new_shape).expect("Reshape should succeed");
8983        assert_eq!(
8984            tensor.shape(),
8985            &new_shape,
8986            "Shape should be updated after successful reshape"
8987        );
8988
8989        {
8990            let mut tensor_map = tensor.map().expect("Failed to map memory");
8991            tensor_map.fill(1);
8992            assert!(tensor_map.iter().all(|&x| x == 1));
8993        }
8994
8995        {
8996            let mut tensor_map = tensor.map().expect("Failed to map memory");
8997            tensor_map[2] = 42;
8998            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
8999            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
9000        }
9001    }
9002
9003    #[test]
9004    #[cfg(target_os = "linux")]
9005    fn test_dma_no_fd_leaks() {
9006        let _lock = fd_lock_exclusive();
9007        if !crate::test_support::dma_or_skip(function!()) {
9008            return;
9009        }
9010
9011        let baseline = open_fd_count(DMA_FD_TARGETS);
9012
9013        // Self-check — see `test_shm_no_fd_leaks`.
9014        {
9015            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
9016                .expect("Failed to create tensor");
9017            assert!(
9018                open_fd_count(DMA_FD_TARGETS) > baseline,
9019                "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
9020                 otherwise this test cannot detect a leak"
9021            );
9022            drop(probe);
9023        }
9024
9025        for _ in 0..100 {
9026            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
9027                .expect("Failed to create tensor");
9028            let mut map = tensor.map().unwrap();
9029            map.as_mut_slice().fill(233);
9030        }
9031
9032        let after = open_fd_count(DMA_FD_TARGETS);
9033        assert_eq!(
9034            baseline, after,
9035            "DMA-BUF file descriptor leak detected: {baseline} -> {after}"
9036        );
9037    }
9038
9039    #[test]
9040    #[cfg(target_os = "linux")]
9041    fn test_dma_from_fd_no_fd_leaks() {
9042        let _lock = fd_lock_exclusive();
9043        if !crate::test_support::dma_or_skip(function!()) {
9044            return;
9045        }
9046
9047        let baseline = open_fd_count(DMA_FD_TARGETS);
9048        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None).unwrap();
9049
9050        // Self-check — see `test_shm_no_fd_leaks`.
9051        let held = open_fd_count(DMA_FD_TARGETS);
9052        assert!(
9053            held > baseline,
9054            "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
9055             otherwise this test cannot detect a leak"
9056        );
9057
9058        for _ in 0..100 {
9059            let tensor =
9060                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9061            assert_eq!(
9062                tensor.memory(),
9063                TensorMemory::DmaBuf,
9064                "DMA-BUF fd must import as Dma, not be silently downgraded"
9065            );
9066            let mut map = tensor.map().unwrap();
9067            map.as_mut_slice().fill(233);
9068        }
9069
9070        let after_loop = open_fd_count(DMA_FD_TARGETS);
9071        assert_eq!(
9072            held, after_loop,
9073            "DMA-BUF file descriptor leak detected: {held} -> {after_loop}"
9074        );
9075
9076        // And the source tensor's own fd goes back on drop.
9077        drop(orig);
9078        let after_drop = open_fd_count(DMA_FD_TARGETS);
9079        assert_eq!(
9080            baseline, after_drop,
9081            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
9082        );
9083    }
9084
9085    /// A filesystem magic must report its true 32-bit value regardless of
9086    /// how wide, and how signed, `fstatfs`'s `f_type` is on the target.
9087    ///
9088    /// `fs_type_t` is `__fsword_t` on Linux/gnu — `i64` on 64-bit but `i32`
9089    /// on 32-bit (armv7, i686, aarch64-ilp32) — `c_int` on uclibc, and
9090    /// unsigned on musl and s390x. Widening a *signed* 32-bit `f_type`
9091    /// sign-extends every magic with bit 31 set, so a naive widening cast
9092    /// reports e.g. `0xffffffff958458f6` for hugetlbfs. The docs tell
9093    /// callers to look the reported value up in `include/uapi/linux/magic.h`,
9094    /// so a sign-extended value is actively misleading.
9095    ///
9096    /// This cannot be reproduced on a 64-bit host — the defect only exists
9097    /// where `f_type` is a signed 32-bit type — so the test drives the
9098    /// conversion directly with the value such a target would produce.
9099    #[test]
9100    #[cfg(target_os = "linux")]
9101    fn test_fs_magic_normalizes_sign_extended_values() {
9102        // Declares this test as an fd-opener; see FD_LOCK.
9103        let _lock = crate::tests::fd_lock_shared();
9104        // Magics whose bit 31 is set. HUGETLBFS is the one that matters
9105        // most in practice: a MFD_HUGETLB memfd is the likeliest fd to land
9106        // in the UnknownBufferType arm.
9107        const HUGETLBFS_MAGIC: u32 = 0x9584_58f6;
9108        const F2FS_MAGIC: u32 = 0xf2f5_2010;
9109        const BTRFS_MAGIC: u32 = 0x9123_683e;
9110
9111        for magic in [HUGETLBFS_MAGIC, F2FS_MAGIC, BTRFS_MAGIC] {
9112            // 64-bit gnu: f_type is i64 and already holds the true value.
9113            assert_eq!(fs_magic(i64::from(magic)), magic);
9114
9115            // 32-bit gnu / uclibc: f_type is i32, so the value arrives
9116            // sign-extended once widened. It must still report its true
9117            // 32 bits.
9118            let sign_extended = i64::from(magic as i32);
9119            assert!(sign_extended < 0, "{magic:#x} should have bit 31 set");
9120            assert_eq!(
9121                fs_magic(sign_extended),
9122                magic,
9123                "{magic:#x} must survive a signed 32-bit f_type"
9124            );
9125        }
9126
9127        // The two magics we actually classify on are below 2^31, so they are
9128        // unaffected by signedness either way — this is why the DMA-vs-SHM
9129        // decision is correct on 32-bit even without this normalization.
9130        for magic in [DMA_BUF_MAGIC, TMPFS_MAGIC] {
9131            assert!(magic < 0x8000_0000);
9132            assert_eq!(fs_magic(i64::from(magic as i32)), magic);
9133        }
9134    }
9135
9136    /// A DMA-BUF fd must import as [`TensorMemory::DmaBuf`].
9137    ///
9138    /// Regression test for the `st_dev` minor-number classifier, which
9139    /// hardcoded `9 | 10` as "this is DMA". Those minors come from
9140    /// `get_anon_bdev()` and are assigned first-come-first-served at boot,
9141    /// so they vary by kernel build and boot order — a real DMA-BUF is
9142    /// minor 12 on x86 desktop and minor 8 on the ADIS Verdin. Both fell
9143    /// into the `_` arm and imported as SHM, which "works" (a DMA-BUF is
9144    /// mmap-able) but silently forfeits zero-copy.
9145    #[test]
9146    #[cfg(target_os = "linux")]
9147    fn test_from_fd_dma_imports_as_dma() {
9148        let _lock = crate::tests::fd_lock_shared();
9149        if !crate::test_support::dma_or_skip(function!()) {
9150            return;
9151        }
9152
9153        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::DmaBuf), None).unwrap();
9154        assert_eq!(orig.memory(), TensorMemory::DmaBuf);
9155
9156        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9157
9158        assert_eq!(
9159            imported.memory(),
9160            TensorMemory::DmaBuf,
9161            "a DMA-BUF fd must import as Dma"
9162        );
9163    }
9164
9165    /// A tmpfs/SHM fd must import as [`TensorMemory::Shm`].
9166    ///
9167    /// The companion to `test_from_fd_dma_imports_as_dma`: confirms the
9168    /// magic-based classifier identifies SHM positively rather than by
9169    /// falling through.
9170    #[test]
9171    #[cfg(target_os = "linux")]
9172    fn test_from_fd_shm_imports_as_shm() {
9173        let _lock = crate::tests::fd_lock_shared();
9174        if !is_shm_available() {
9175            log::warn!("SKIPPED: {} - SHM memory not available", function!());
9176            return;
9177        }
9178
9179        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::Shm), None).unwrap();
9180        assert_eq!(orig.memory(), TensorMemory::Shm);
9181
9182        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9183
9184        assert_eq!(
9185            imported.memory(),
9186            TensorMemory::Shm,
9187            "a tmpfs fd must import as Shm"
9188        );
9189    }
9190
9191    /// An fd that is neither a DMA-BUF nor tmpfs must be rejected.
9192    ///
9193    /// A pipe is the convenient probe: it lives on `pipefs`, another
9194    /// `get_anon_bdev()` pseudo-filesystem, so it shares major 0 with the
9195    /// buffer types we do support and is only distinguishable by magic.
9196    /// Importing one as SHM is meaningless — `mmap` on a pipe fails — so
9197    /// the classifier must say "unknown" rather than guess.
9198    #[test]
9199    #[cfg(target_os = "linux")]
9200    fn test_from_fd_rejects_unknown_filesystem() {
9201        let _lock = crate::tests::fd_lock_shared();
9202
9203        let (read_end, _write_end) = nix::unistd::pipe().unwrap();
9204
9205        let result = Tensor::<u8>::from_fd(read_end, &[64], None);
9206
9207        match result {
9208            Err(Error::UnknownBufferType(magic)) => {
9209                // PIPEFS_MAGIC, from include/uapi/linux/magic.h
9210                assert_eq!(magic, 0x5049_5045, "expected PIPEFS_MAGIC");
9211            }
9212            other => panic!("expected UnknownBufferType for a pipe fd, got {other:?}"),
9213        }
9214    }
9215
9216    #[test]
9217    #[cfg(target_os = "linux")]
9218    fn test_shm_no_fd_leaks() {
9219        let _lock = fd_lock_exclusive();
9220        if !is_shm_available() {
9221            log::warn!(
9222                "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
9223                function!()
9224            );
9225            return;
9226        }
9227
9228        let baseline = open_fd_count(SHM_FD_TARGETS);
9229
9230        // Self-check: the counter must actually see a live shm tensor. If
9231        // it does not — a changed backing object, a different `/proc`
9232        // rendering — the leak assertion below would hold no matter how
9233        // much leaked, so fail loudly here instead.
9234        {
9235            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9236                .expect("Failed to create tensor");
9237            assert!(
9238                open_fd_count(SHM_FD_TARGETS) > baseline,
9239                "a live shm tensor must be visible to the shm fd counter, \
9240                 otherwise this test cannot detect a leak"
9241            );
9242            drop(probe);
9243        }
9244
9245        for _ in 0..100 {
9246            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9247                .expect("Failed to create tensor");
9248            let mut map = tensor.map().unwrap();
9249            map.as_mut_slice().fill(233);
9250        }
9251
9252        let after = open_fd_count(SHM_FD_TARGETS);
9253        assert_eq!(
9254            baseline, after,
9255            "shm file descriptor leak detected: {baseline} -> {after}"
9256        );
9257    }
9258
9259    #[test]
9260    #[cfg(target_os = "linux")]
9261    fn test_shm_from_fd_no_fd_leaks() {
9262        let _lock = fd_lock_exclusive();
9263        if !is_shm_available() {
9264            log::warn!(
9265                "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
9266                function!()
9267            );
9268            return;
9269        }
9270
9271        let baseline = open_fd_count(SHM_FD_TARGETS);
9272        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None).unwrap();
9273
9274        // Self-check — see `test_shm_no_fd_leaks`.
9275        let held = open_fd_count(SHM_FD_TARGETS);
9276        assert!(
9277            held > baseline,
9278            "a live shm tensor must be visible to the shm fd counter, \
9279             otherwise this test cannot detect a leak"
9280        );
9281
9282        for _ in 0..100 {
9283            let tensor =
9284                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
9285            let mut map = tensor.map().unwrap();
9286            map.as_mut_slice().fill(233);
9287        }
9288
9289        let after_loop = open_fd_count(SHM_FD_TARGETS);
9290        assert_eq!(
9291            held, after_loop,
9292            "shm file descriptor leak detected: {held} -> {after_loop}"
9293        );
9294
9295        // And the source tensor's own fd goes back on drop.
9296        drop(orig);
9297        let after_drop = open_fd_count(SHM_FD_TARGETS);
9298        assert_eq!(
9299            baseline, after_drop,
9300            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
9301        );
9302    }
9303
9304    #[cfg(feature = "ndarray")]
9305    #[test]
9306    fn test_ndarray() {
9307        let _lock = crate::tests::fd_lock_shared();
9308        let shape = vec![2, 3, 4];
9309        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
9310
9311        let mut tensor_map = tensor.map().expect("Failed to map tensor memory");
9312        tensor_map.fill(1.0);
9313
9314        let view = tensor_map.view().expect("Failed to get ndarray view");
9315        assert_eq!(view.shape(), &[2, 3, 4]);
9316        assert!(view.iter().all(|&x| x == 1.0));
9317
9318        let mut view_mut = tensor_map
9319            .view_mut()
9320            .expect("Failed to get mutable ndarray view");
9321        view_mut[[0, 0, 0]] = 42.0;
9322        assert_eq!(view_mut[[0, 0, 0]], 42.0);
9323        assert_eq!(tensor_map[0], 42.0, "Value at index 0 should be 42");
9324    }
9325
9326    #[test]
9327    fn test_buffer_identity_derived_equality() {
9328        // Two identities derived from the same kind and key must be equal —
9329        // that is the whole point of `derived`: two independently-linked
9330        // copies of this crate, each importing the same underlying buffer,
9331        // have to agree on its id without talking to each other.
9332        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9333        let id2 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9334        assert_eq!(
9335            id1.id(),
9336            id2.id(),
9337            "Identities derived from the same kind and key should match"
9338        );
9339
9340        // A different key still yields a different id.
9341        let id3 = BufferIdentity::derived(IdentityKind::DmaBuf, 4243);
9342        assert_ne!(
9343            id1.id(),
9344            id3.id(),
9345            "Identities derived from different keys should differ"
9346        );
9347    }
9348
9349    #[test]
9350    fn test_buffer_identity_clone_shares_guard() {
9351        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
9352        let weak = id1.weak();
9353        assert!(
9354            weak.upgrade().is_some(),
9355            "Weak should be alive while original exists"
9356        );
9357
9358        let id2 = id1.clone();
9359        assert_eq!(id1.id(), id2.id(), "Cloned identity should have same id");
9360
9361        drop(id1);
9362        assert!(
9363            weak.upgrade().is_some(),
9364            "Weak should still be alive (clone holds Arc)"
9365        );
9366
9367        drop(id2);
9368        assert!(
9369            weak.upgrade().is_none(),
9370            "Weak should be dead after all clones dropped"
9371        );
9372    }
9373
9374    #[test]
9375    fn test_tensor_buffer_identity() {
9376        let t1 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t1")).unwrap();
9377        let t2 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t2")).unwrap();
9378        assert_ne!(
9379            t1.buffer_identity().id(),
9380            t2.buffer_identity().id(),
9381            "Different tensors should have different buffer ids"
9382        );
9383    }
9384
9385    // ------------------------------------------------------------------------
9386    // Quantization — constructor validation + accessor correctness.
9387    // ------------------------------------------------------------------------
9388
9389    #[test]
9390    fn test_quantization_per_tensor_constructors() {
9391        let q = Quantization::per_tensor(0.1, -5);
9392        assert!(q.is_per_tensor());
9393        assert!(!q.is_per_channel());
9394        assert!(!q.is_symmetric());
9395        assert_eq!(q.scale(), &[0.1]);
9396        assert_eq!(q.zero_point(), Some(&[-5][..]));
9397
9398        let qs = Quantization::per_tensor_symmetric(0.05);
9399        assert!(qs.is_per_tensor());
9400        assert!(qs.is_symmetric());
9401        assert_eq!(qs.zero_point(), None);
9402    }
9403
9404    #[test]
9405    fn test_quantization_per_channel_constructors() {
9406        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3], vec![0, -1, 1], 2).unwrap();
9407        assert!(q.is_per_channel());
9408        assert!(!q.is_symmetric());
9409        assert_eq!(q.axis(), Some(2));
9410        assert_eq!(q.scale().len(), 3);
9411
9412        let qs = Quantization::per_channel_symmetric(vec![0.054, 0.089, 0.195], 0).unwrap();
9413        assert!(qs.is_per_channel());
9414        assert!(qs.is_symmetric());
9415        assert_eq!(qs.axis(), Some(0));
9416    }
9417
9418    #[test]
9419    fn test_quantization_per_channel_length_mismatch_rejected() {
9420        // len(scales) != len(zero_points) → rejected at construction.
9421        let err = Quantization::per_channel(vec![0.1, 0.2], vec![0, 0, 0], 0).unwrap_err();
9422        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9423    }
9424
9425    #[test]
9426    fn test_quantization_per_channel_empty_rejected() {
9427        let err = Quantization::per_channel_symmetric(vec![], 0).unwrap_err();
9428        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9429    }
9430
9431    /// Constructors guard scale/zero_point length invariants, but
9432    /// `Quantization` is `Deserialize`, so malformed JSON (e.g. an
9433    /// empty `scale` array, or `zero_point` length that disagrees with
9434    /// `scale`) bypasses the constructor checks. `set_quantization`
9435    /// must reject these via `validate()` so they don't poison
9436    /// downstream `mode()` selection or per-channel kernel indexing.
9437    #[test]
9438    fn test_quantization_validate_rejects_malformed_deserialize() {
9439        let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
9440
9441        // Empty scale array: must be rejected.
9442        let q: Quantization = serde_json::from_str(r#"{"scale": []}"#).unwrap();
9443        assert!(matches!(
9444            t.set_quantization(q).unwrap_err(),
9445            Error::QuantizationInvalid { .. }
9446        ));
9447
9448        // Per-tensor with multi-element zero_point: must be rejected.
9449        let q: Quantization =
9450            serde_json::from_str(r#"{"scale": 0.1, "zero_point": [0, 0, 0]}"#).unwrap();
9451        assert!(matches!(
9452            t.set_quantization(q).unwrap_err(),
9453            Error::QuantizationInvalid { .. }
9454        ));
9455
9456        // Per-channel zero_point length != scale length: must be rejected.
9457        let q: Quantization = serde_json::from_str(
9458            r#"{"scale": [0.1, 0.2, 0.3, 0.4], "zero_point": [0, 0], "axis": 2}"#,
9459        )
9460        .unwrap();
9461        assert!(matches!(
9462            t.set_quantization(q).unwrap_err(),
9463            Error::QuantizationInvalid { .. }
9464        ));
9465    }
9466
9467    #[test]
9468    fn test_quantization_mode_dispatch() {
9469        let pt = Quantization::per_tensor(0.1, -5);
9470        assert!(matches!(
9471            pt.mode(),
9472            QuantMode::PerTensor { scale, zero_point } if scale == 0.1 && zero_point == -5
9473        ));
9474
9475        let pts = Quantization::per_tensor_symmetric(0.05);
9476        assert!(matches!(
9477            pts.mode(),
9478            QuantMode::PerTensorSymmetric { scale } if scale == 0.05
9479        ));
9480
9481        let pc = Quantization::per_channel(vec![0.1, 0.2], vec![0, -1], 2).unwrap();
9482        assert!(matches!(pc.mode(), QuantMode::PerChannel { axis: 2, .. }));
9483
9484        let pcs = Quantization::per_channel_symmetric(vec![0.1, 0.2], 0).unwrap();
9485        assert!(matches!(
9486            pcs.mode(),
9487            QuantMode::PerChannelSymmetric { axis: 0, .. }
9488        ));
9489    }
9490
9491    #[test]
9492    fn test_tensor_quantization_roundtrip_integer() {
9493        let mut t = Tensor::<i8>::new(&[2, 3, 4], Some(TensorMemory::Mem), None).unwrap();
9494        assert!(t.quantization().is_none());
9495        t.set_quantization(Quantization::per_tensor(0.1, -5))
9496            .unwrap();
9497        let q = t.quantization().unwrap();
9498        assert_eq!(q.scale(), &[0.1]);
9499        t.clear_quantization();
9500        assert!(t.quantization().is_none());
9501    }
9502
9503    #[test]
9504    fn test_tensor_with_quantization_builder() {
9505        let t = Tensor::<i8>::new(&[4, 4], Some(TensorMemory::Mem), None)
9506            .unwrap()
9507            .with_quantization(Quantization::per_tensor_symmetric(0.05))
9508            .unwrap();
9509        assert!(t.quantization().is_some());
9510    }
9511
9512    #[test]
9513    fn test_tensor_dyn_quantization_float_arm_returns_none() {
9514        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9515        let td = TensorDyn::F32(t);
9516        assert!(td.quantization().is_none());
9517    }
9518
9519    #[test]
9520    fn test_tensor_dyn_set_quantization_float_arm_errors() {
9521        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9522        let mut td = TensorDyn::F32(t);
9523        let err = td
9524            .set_quantization(Quantization::per_tensor(0.1, 0))
9525            .unwrap_err();
9526        // float path returns a QuantizationInvalid error.
9527        assert!(matches!(err, Error::QuantizationInvalid { .. }));
9528    }
9529
9530    /// Compile-time type gate — calling `Tensor::<f32>::quantization()` must
9531    /// fail to compile (the `IntegerType` trait bound is not satisfied by
9532    /// `f32`). This doctest anchors the invariant.
9533    ///
9534    /// ```compile_fail
9535    /// use edgefirst_tensor::{Tensor, TensorMemory};
9536    /// let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9537    /// let _ = t.quantization(); // compile error: f32 not IntegerType
9538    /// ```
9539    fn _compile_fail_doctest_anchor() {}
9540
9541    // Any test that cares about the fd count must grab it exclusively.
9542    // Any tests which modifies the fd count by opening or closing fds must grab it
9543    // shared.
9544    pub static FD_LOCK: RwLock<()> = RwLock::new(());
9545
9546    /// Take [`FD_LOCK`] shared, tolerating poisoning.
9547    ///
9548    /// A test that panics while holding the lock poisons it, and every later
9549    /// `.unwrap()` on it panics too — one real failure turns into a handful
9550    /// of unrelated ones that bury it. The guarded data is `()`, so a panic
9551    /// cannot have left an invariant broken and there is nothing to protect
9552    /// against by refusing the lock.
9553    pub fn fd_lock_shared() -> std::sync::RwLockReadGuard<'static, ()> {
9554        FD_LOCK.read().unwrap_or_else(PoisonError::into_inner)
9555    }
9556
9557    /// Take [`FD_LOCK`] exclusively. Poison-tolerant, see [`fd_lock_shared`].
9558    ///
9559    /// Linux-only, like all four of its callers: the fd-leak tests read fd
9560    /// state out of `/proc`, which no other platform provides. Ungated, it
9561    /// is dead code on macOS and the lint denies that.
9562    /// [`fd_lock_shared`] stays available everywhere — macOS tests declare
9563    /// fd activity too.
9564    #[cfg(target_os = "linux")]
9565    pub fn fd_lock_exclusive() -> std::sync::RwLockWriteGuard<'static, ()> {
9566        FD_LOCK.write().unwrap_or_else(PoisonError::into_inner)
9567    }
9568
9569    /// `/proc` fd-target fragments identifying a POSIX shared memory
9570    /// segment. `shm_open` then `shm_unlink` leaves the target rendered as
9571    /// `/dev/shm/<name> (deleted)`.
9572    #[cfg(target_os = "linux")]
9573    const SHM_FD_TARGETS: &[&str] = &["/dev/shm/"];
9574
9575    /// `/proc` fd-target fragments identifying the fds a DMA tensor owns.
9576    ///
9577    /// Both the exported buffer and the heap it came from are listed, so a
9578    /// leak of either is still caught — narrowing this to the buffer alone
9579    /// would silently stop covering leaked heap handles.
9580    #[cfg(target_os = "linux")]
9581    const DMA_FD_TARGETS: &[&str] = &["dmabuf", "dma_heap"];
9582
9583    /// How many of this process's open fds point at an object whose `/proc`
9584    /// target contains `needle`.
9585    ///
9586    /// The fd-leak tests deliberately do NOT compare total fd counts. Under
9587    /// `cargo test` every test in this crate runs as a thread in one
9588    /// process, and roughly twenty of them create shm or DMA tensors —
9589    /// while anything else may open a file, a socket, or a pipe — so an
9590    /// unrelated test opening or closing an fd inside the measurement
9591    /// window moved the total and failed the assertion. Worse, the panic
9592    /// poisoned [`FD_LOCK`] and took four more tests down with it, so the
9593    /// visible failure had little to do with the cause.
9594    ///
9595    /// Counting only fds that name the object under test makes the
9596    /// measurement immune to every unrelated fd, while still catching the
9597    /// leak each test exists to catch. (CI runs nextest, one process per
9598    /// test, which is why this only ever flaked when run locally.)
9599    #[cfg(target_os = "linux")]
9600    fn open_fd_count(needles: &[&str]) -> usize {
9601        let proc = procfs::process::Process::myself()
9602            .expect("Failed to get current process using /proc/self");
9603        proc.fd()
9604            .expect("Failed to list open file descriptors")
9605            .filter_map(Result::ok)
9606            .filter(|entry| {
9607                let target = format!("{:?}", entry.target);
9608                needles.iter().any(|needle| target.contains(needle))
9609            })
9610            .count()
9611    }
9612
9613    /// Test that DMA is NOT available on non-Linux platforms.
9614    /// This verifies the cross-platform behavior of is_dma_available().
9615    #[test]
9616    #[cfg(not(target_os = "linux"))]
9617    fn test_dma_not_available_on_non_linux() {
9618        assert!(
9619            !is_dma_available(),
9620            "DMA memory allocation should NOT be available on non-Linux platforms"
9621        );
9622    }
9623
9624    #[test]
9625    fn colorimetry_defaults_none_and_roundtrips_without_auto_fill() {
9626        use crate::{ColorEncoding, ColorRange, Colorimetry, PixelFormat, TensorMemory};
9627        let mut t = Tensor::<u8>::image(
9628            1280,
9629            720,
9630            PixelFormat::Nv12,
9631            Some(TensorMemory::Mem),
9632            crate::CpuAccess::ReadWrite,
9633        )
9634        .unwrap();
9635        assert_eq!(t.colorimetry(), None); // default undefined
9636        let c = Colorimetry::default()
9637            .with_encoding(ColorEncoding::Bt709)
9638            .with_range(ColorRange::Limited);
9639        t.set_colorimetry(Some(c));
9640        assert_eq!(t.colorimetry(), Some(c));
9641        // configure_image must NOT touch colorimetry
9642        t.configure_image(640, 480, PixelFormat::Grey).unwrap();
9643        assert_eq!(t.colorimetry(), Some(c));
9644    }
9645
9646    #[test]
9647    fn configure_image_within_capacity() {
9648        let mut t = Tensor::<u8>::image_with_capacity(
9649            640,
9650            480,
9651            PixelFormat::Rgb,
9652            None,
9653            crate::CpuAccess::ReadWrite,
9654        )
9655        .unwrap();
9656        t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
9657        assert_eq!(t.format(), Some(PixelFormat::Nv12));
9658        assert_eq!(t.width(), Some(320));
9659        assert_eq!(t.height(), Some(240));
9660        assert_eq!(t.shape(), &[360, 320]); // 240*3/2
9661    }
9662
9663    #[test]
9664    fn configure_image_too_large_errors() {
9665        let mut t = Tensor::<u8>::image_with_capacity(
9666            64,
9667            64,
9668            PixelFormat::Grey,
9669            None,
9670            crate::CpuAccess::ReadWrite,
9671        )
9672        .unwrap();
9673        let err = t
9674            .configure_image(1920, 1080, PixelFormat::Nv12)
9675            .unwrap_err();
9676        assert!(matches!(err, Error::InsufficientCapacity { .. }));
9677    }
9678
9679    /// A reused max-sized IOSurface pool keeps its physical `bytesPerRow` when
9680    /// reconfigured to a smaller logical image (physical-grid / logical-ROI
9681    /// decoupling), instead of collapsing to the frame's natural row stride.
9682    #[test]
9683    #[cfg(target_os = "macos")]
9684    fn configure_image_preserves_iosurface_physical_stride() {
9685        // Declares this test as an fd-opener; see FD_LOCK.
9686        let _lock = crate::tests::fd_lock_shared();
9687        // Pool: GREY/R8 IOSurface 100 wide → bytesPerRow padded to 128.
9688        let mut pool = Tensor::<u8>::image(
9689            100,
9690            64,
9691            PixelFormat::Grey,
9692            Some(TensorMemory::DmaBuf),
9693            crate::CpuAccess::ReadWrite,
9694        )
9695        .unwrap();
9696        let pitch = pool.effective_row_stride().unwrap();
9697        assert!(
9698            pitch >= 128 && pitch.is_multiple_of(64),
9699            "padded bytesPerRow, got {pitch}"
9700        );
9701
9702        // Reconfigure to a smaller NV12 frame; the physical pitch must survive
9703        // (natural would be 32, but the surface stride is the 128-padded pitch).
9704        pool.configure_image(32, 16, PixelFormat::Nv12).unwrap();
9705        assert_eq!(pool.format(), Some(PixelFormat::Nv12));
9706        assert_eq!(pool.width(), Some(32));
9707        assert_eq!(pool.height(), Some(16));
9708        assert_eq!(
9709            pool.effective_row_stride(),
9710            Some(pitch),
9711            "configure_image must preserve the IOSurface physical bytesPerRow"
9712        );
9713
9714        // Reconfigure again to NV24 — pitch still preserved.
9715        pool.configure_image(32, 16, PixelFormat::Nv24).unwrap();
9716        assert_eq!(pool.effective_row_stride(), Some(pitch));
9717    }
9718
9719    /// `configure_image` on a Mem backing reconfigures to the format's
9720    /// **64-byte-aligned** row stride (the odd-dim contract: every image tensor
9721    /// carries a 64-aligned `row_stride`). For NV12 32×16 the minimum is
9722    /// `even(32)=32`, rounded up to the 64-byte alignment → 64. The capacity
9723    /// (64×64×4 RGBA = 16 KiB) easily holds the 24×64 = 1.5 KiB NV12 layout.
9724    #[test]
9725    fn configure_image_mem_aligns_stride() {
9726        let mut t = Tensor::<u8>::image_with_capacity(
9727            64,
9728            64,
9729            PixelFormat::Rgba,
9730            Some(TensorMemory::Mem),
9731            crate::CpuAccess::ReadWrite,
9732        )
9733        .unwrap();
9734        t.configure_image(32, 16, PixelFormat::Nv12).unwrap();
9735        let s = t.effective_row_stride().unwrap();
9736        assert_eq!(s % 64, 0, "stride must be 64-aligned");
9737        assert!(s >= 32, "stride must cover the even-width minimum");
9738        assert_eq!(s, 64);
9739    }
9740
9741    #[test]
9742    fn strided_mem_tensor_cpu_maps_full_padded_buffer() {
9743        // A packed RGBA image with row padding (GPU-pitch style): logical width
9744        // 8 px (32 B/row) but a 48-byte row stride. Over-allocate capacity (for
9745        // 16 px), narrow the logical width, then record the padded stride.
9746        // Previously `map()` rejected this on non-Linux with
9747        // "DMA backing is Linux-only"; HAL-owned Mem is now mappable.
9748        let mut t = Tensor::<u8>::image_with_capacity(
9749            16,
9750            3,
9751            PixelFormat::Rgba,
9752            Some(TensorMemory::Mem),
9753            crate::CpuAccess::ReadWrite,
9754        )
9755        .unwrap(); // capacity 3 × 16 × 4 = 192 B
9756        t.configure_image(8, 3, PixelFormat::Rgba).unwrap(); // logical [3, 8, 4] = 96 B
9757        t.set_row_stride(48).unwrap(); // padded stride (>= 32 B min)
9758
9759        let map = t.map().expect("strided Mem tensor should CPU-map");
9760        // Full padded buffer (stride 48 × 3 rows = 144 B), not the 96 B logical
9761        // view — callers iterate rows via `effective_row_stride()`.
9762        assert_eq!(map.as_slice().len(), 144);
9763        // Logical shape is still reported for shape-aware consumers.
9764        assert_eq!(map.shape(), &[3, 8, 4]);
9765    }
9766
9767    #[test]
9768    fn strided_mem_tensor_over_capacity_errors() {
9769        // Stride larger than the allocation: 64 B × 3 rows = 192 B > 96 B cap.
9770        let mut t = Tensor::<u8>::new(&[3, 8, 4], Some(TensorMemory::Mem), None).unwrap();
9771        t.set_format(PixelFormat::Rgba).unwrap();
9772        t.set_row_stride(64).unwrap();
9773        assert!(matches!(t.map(), Err(Error::InsufficientCapacity { .. })));
9774    }
9775
9776    /// Test that SHM memory allocation is available and usable on Unix systems.
9777    /// This is a basic functional test; Linux has additional FD leak tests using procfs.
9778    #[test]
9779    #[cfg(unix)]
9780    fn test_shm_available_and_usable() {
9781        // Declares this test as an fd-opener; see FD_LOCK.
9782        let _lock = crate::tests::fd_lock_shared();
9783        // "Unix" is not one platform here: Android is unix but its bionic libc
9784        // has no POSIX shared memory, so allocation legitimately reports
9785        // NotImplemented (see shm.rs module docs). Asserting availability
9786        // outright fails there for a documented reason.
9787        if !is_shm_available() {
9788            log::warn!(
9789                "SKIPPED: test_shm_available_and_usable - SHM allocation \
9790                 unavailable on this platform"
9791            );
9792            return;
9793        }
9794
9795        // Create a tensor with SHM backing
9796        let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
9797            .expect("Failed to create SHM tensor");
9798
9799        // Verify we can map and write to it
9800        let mut map = tensor.map().expect("Failed to map SHM tensor");
9801        map.as_mut_slice().fill(0xAB);
9802
9803        // Verify the data was written correctly
9804        assert!(
9805            map.as_slice().iter().all(|&b| b == 0xAB),
9806            "SHM tensor data should be writable and readable"
9807        );
9808    }
9809
9810    // =========================================================================
9811    // packed_rgba16f_layout — host-runnable geometry unit tests (TDD)
9812    // =========================================================================
9813
9814    #[test]
9815    fn packed_rgba16f_layout_planar_rgb_f16() {
9816        let layout =
9817            packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 640).expect("Some");
9818        assert_eq!(layout.surface_w, 160);
9819        assert_eq!(layout.surface_h, 1920);
9820        assert_eq!(layout.bytes_per_texel, 8);
9821        assert_eq!(layout.pitch, 1280);
9822    }
9823
9824    #[test]
9825    fn packed_rgba16f_layout_planar_rgba_f16() {
9826        let layout =
9827            packed_rgba16f_layout(PixelFormat::PlanarRgba, DType::F16, 640, 640).expect("Some");
9828        assert_eq!(layout.surface_w, 160);
9829        assert_eq!(layout.surface_h, 2560); // 4 planes
9830        assert_eq!(layout.bytes_per_texel, 8);
9831        assert_eq!(layout.pitch, 1280);
9832    }
9833
9834    #[test]
9835    fn packed_rgba16f_layout_rejects_misaligned() {
9836        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 642, 640).is_none());
9837    }
9838
9839    #[test]
9840    fn packed_rgba16f_layout_rejects_non_f16() {
9841        // Non-F16 dtype with planar RGB
9842        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::U8, 640, 640).is_none());
9843        // Non-planar format with F32
9844        assert!(packed_rgba16f_layout(PixelFormat::Rgb, DType::F32, 640, 640).is_none());
9845        // Packed Rgba with F16 is not a planar format → None
9846        assert!(packed_rgba16f_layout(PixelFormat::Rgba, DType::F16, 640, 640).is_none());
9847    }
9848
9849    #[test]
9850    fn cuda_map_fast_fails_to_none_without_handle() {
9851        let t = Tensor::<f32>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
9852        assert!(t.cuda().is_none());
9853        assert!(t.cuda_map().is_none()); // pure local check, no GL routing
9854    }
9855
9856    #[test]
9857    fn cuda_returns_none_without_handle() {
9858        // A plain Mem-backed tensor has no CUDA handle attached.
9859        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9860        assert!(t.cuda().is_none(), "no CUDA handle on a Mem tensor");
9861        assert!(t.cuda_map().is_none(), "fast-fail map → None");
9862    }
9863
9864    #[test]
9865    fn cuda_map_then_host_map_fallback() {
9866        // The documented client pattern: try cuda_map() first; when it is None
9867        // (no CUDA handle — the case for a plain Mem tensor), fall back to map().
9868        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
9869        // Bind to a named variable so the CudaMap guard (and its borrow of `t`)
9870        // is dropped at the end of this statement, before the else branch borrows `t` again.
9871        let cuda = t.cuda_map();
9872        if let Some(_c) = cuda {
9873            // On a CUDA-registered tensor we'd use the device ptr here.
9874            unreachable!("a Mem tensor has no CUDA handle");
9875        } else {
9876            let host = t.map().expect("host map fallback must succeed");
9877            // TensorMapTrait::len() returns the element count (not bytes).
9878            assert_eq!(host.len(), 4); // 2*2 f32 elements
9879        }
9880    }
9881
9882    // -------------------------------------------------------------------------
9883    // Tensor::from_foreign — public API tests at the Tensor<T> layer.
9884    //
9885    // The low-level MemTensor::from_foreign mechanics (owner-drop, view sharing)
9886    // are covered in mem.rs.  These tests exercise the Tensor<T> guard paths
9887    // (null ptr, empty shape, size overflow) and the basic wrap+readback
9888    // contract, confirming the public unsafe API wires through correctly.
9889    // -------------------------------------------------------------------------
9890
9891    #[test]
9892    fn from_foreign_valid_wrap_and_readback() {
9893        // The canonical CUDA zero-copy shape: wrap a caller allocation as a
9894        // Mem tensor and verify the tensor reads the exact same bytes.
9895        let mut buf: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
9896        let ptr = buf.as_mut_ptr();
9897        let t = unsafe { Tensor::<f32>::from_foreign(ptr, &[2, 3], None, Some("test_foreign")) }
9898            .expect("valid from_foreign must succeed");
9899        assert_eq!(t.shape(), &[2, 3]);
9900        assert_eq!(t.memory(), TensorMemory::Mem);
9901        assert_eq!(t.name(), "test_foreign");
9902        let m = t.map().unwrap();
9903        // The tensor is a zero-copy borrow — it sees the caller's data.
9904        assert_eq!(m.as_slice(), &[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]);
9905    }
9906
9907    #[test]
9908    fn from_foreign_write_visible_in_caller_allocation() {
9909        // Writes through the tensor's map land in the caller's buffer (zero-copy).
9910        let mut buf: Vec<u8> = vec![0u8; 6];
9911        let ptr = buf.as_mut_ptr();
9912        let t = unsafe { Tensor::<u8>::from_foreign(ptr, &[2, 3], None, None) }.unwrap();
9913        {
9914            let mut m = t.map().unwrap();
9915            m.as_mut_slice().copy_from_slice(&[10, 20, 30, 40, 50, 60]);
9916        }
9917        drop(t);
9918        // Mutations are visible in the original Vec — same physical buffer.
9919        assert_eq!(buf, vec![10, 20, 30, 40, 50, 60]);
9920    }
9921
9922    #[test]
9923    fn from_foreign_rejects_null_ptr() {
9924        let err = unsafe { Tensor::<u8>::from_foreign(std::ptr::null_mut(), &[4], None, None) }
9925            .unwrap_err();
9926        assert!(
9927            matches!(err, Error::InvalidArgument(ref m) if m.contains("non-null")),
9928            "expected InvalidArgument(non-null), got {err:?}"
9929        );
9930    }
9931
9932    #[test]
9933    fn from_foreign_rejects_empty_shape() {
9934        let mut dummy: u8 = 0;
9935        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &[], None, None) }.unwrap_err();
9936        assert!(
9937            matches!(err, Error::InvalidSize(0)),
9938            "expected InvalidSize(0) for empty shape, got {err:?}"
9939        );
9940    }
9941
9942    #[test]
9943    fn from_foreign_rejects_overflow_shape() {
9944        // Two dimensions whose product overflows usize — the overflow guard must
9945        // fire before any pointer arithmetic is attempted.
9946        let mut dummy: u8 = 0;
9947        let huge = [usize::MAX / 2 + 1, 2];
9948        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &huge, None, None) }.unwrap_err();
9949        assert!(
9950            matches!(err, Error::InvalidArgument(ref m) if m.contains("overflow")),
9951            "expected InvalidArgument(overflow), got {err:?}"
9952        );
9953    }
9954
9955    #[test]
9956    fn from_foreign_owner_keeps_allocation_alive() {
9957        // When `owner` is `Some`, dropping the Tensor must not free the backing
9958        // before the owner is also gone — the owner's Drop fires on last ref.
9959        use std::sync::atomic::{AtomicBool, Ordering};
9960        let flag = std::sync::Arc::new(AtomicBool::new(false));
9961        let flag2 = flag.clone();
9962        struct Guard(std::sync::Arc<AtomicBool>);
9963        impl Drop for Guard {
9964            fn drop(&mut self) {
9965                self.0.store(true, Ordering::SeqCst);
9966            }
9967        }
9968        let mut buf: Vec<u32> = vec![42u32; 4];
9969        let ptr = buf.as_mut_ptr();
9970        let owner: ForeignOwner = Box::new(Guard(flag2));
9971        let t = unsafe { Tensor::<u32>::from_foreign(ptr, &[4], Some(owner), None) }.unwrap();
9972        // Map co-owns the backing Arc; the owner must stay alive while the map lives.
9973        let m = t.map().unwrap();
9974        assert_eq!(m.as_slice()[0], 42);
9975        drop(t); // tensor dropped while map is still live
9976        assert!(
9977            !flag.load(Ordering::SeqCst),
9978            "owner must not drop while a map shares the backing"
9979        );
9980        drop(m);
9981        assert!(
9982            flag.load(Ordering::SeqCst),
9983            "owner Drop must fire when the last Arc reference is released"
9984        );
9985    }
9986}