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        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1082            f.write_str("f32 or array of f32")
1083        }
1084        fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<Self::Value, E> {
1085            Ok(vec![v as f32])
1086        }
1087        #[allow(clippy::cast_possible_truncation)]
1088        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1089            Ok(vec![v as f32])
1090        }
1091        #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
1092        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1093            Ok(vec![v as f32])
1094        }
1095        fn visit_seq<A: de::SeqAccess<'de>>(
1096            self,
1097            mut seq: A,
1098        ) -> std::result::Result<Self::Value, A::Error> {
1099            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1100            while let Some(x) = seq.next_element::<f32>()? {
1101                out.push(x);
1102            }
1103            Ok(out)
1104        }
1105    }
1106    de.deserialize_any(V)
1107}
1108
1109fn deserialize_opt_scalar_or_vec_i32<'de, D: serde::Deserializer<'de>>(
1110    de: D,
1111) -> std::result::Result<Option<Vec<i32>>, D::Error> {
1112    use serde::de::{self, Visitor};
1113    struct V;
1114    impl<'de> Visitor<'de> for V {
1115        type Value = Option<Vec<i32>>;
1116        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1117            f.write_str("null, i32, or array of i32")
1118        }
1119        fn visit_none<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1120            Ok(None)
1121        }
1122        fn visit_unit<E: de::Error>(self) -> std::result::Result<Self::Value, E> {
1123            Ok(None)
1124        }
1125        fn visit_some<D2: serde::Deserializer<'de>>(
1126            self,
1127            de: D2,
1128        ) -> std::result::Result<Self::Value, D2::Error> {
1129            struct Inner;
1130            impl<'de> Visitor<'de> for Inner {
1131                type Value = Vec<i32>;
1132                fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
1133                    f.write_str("i32 or array of i32")
1134                }
1135                #[allow(clippy::cast_possible_truncation)]
1136                fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1137                    Ok(vec![v as i32])
1138                }
1139                #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1140                fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1141                    Ok(vec![v as i32])
1142                }
1143                fn visit_seq<A: de::SeqAccess<'de>>(
1144                    self,
1145                    mut seq: A,
1146                ) -> std::result::Result<Self::Value, A::Error> {
1147                    let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1148                    while let Some(x) = seq.next_element::<i32>()? {
1149                        out.push(x);
1150                    }
1151                    Ok(out)
1152                }
1153            }
1154            de.deserialize_any(Inner).map(Some)
1155        }
1156        #[allow(clippy::cast_possible_truncation)]
1157        fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<Self::Value, E> {
1158            Ok(Some(vec![v as i32]))
1159        }
1160        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
1161        fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<Self::Value, E> {
1162            Ok(Some(vec![v as i32]))
1163        }
1164        fn visit_seq<A: de::SeqAccess<'de>>(
1165            self,
1166            mut seq: A,
1167        ) -> std::result::Result<Self::Value, A::Error> {
1168            let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(1));
1169            while let Some(x) = seq.next_element::<i32>()? {
1170                out.push(x);
1171            }
1172            Ok(Some(out))
1173        }
1174    }
1175    de.deserialize_option(V)
1176}
1177
1178/// Count of tensor maps requested beyond the buffer's declared
1179/// [`CpuAccess`] (including any CPU map of a `CpuAccess::None` buffer).
1180/// Undeclared CPU access is a pipeline smell: it forfeits layout
1181/// optimizations (write-combined mappings, tile compression) and may be
1182/// slow or refused on Android. Read via [`unplanned_cpu_access_count`];
1183/// each offending buffer also logs one warning.
1184static UNPLANNED_CPU_ACCESS: AtomicU64 = AtomicU64::new(0);
1185
1186/// Number of tensor maps that exceeded the buffer's declared
1187/// [`CpuAccess`] since process start. A pipeline that declares its CPU
1188/// access correctly holds this flat; see [`CpuAccess`] for the contract.
1189pub fn unplanned_cpu_access_count() -> u64 {
1190    UNPLANNED_CPU_ACCESS.load(Ordering::Relaxed)
1191}
1192
1193/// Record an unplanned CPU access on `identity_id`, warning once per
1194/// buffer (a steady-state pipeline maps the same buffer every frame — a
1195/// per-map warn would flood the log; repeats count silently).
1196///
1197/// `static`-only: the sole caller is `impl<T> TensorMapTrait<T> for
1198/// Tensor<T>` in `lib.rs`, itself `#[cfg(feature = "static")]`.
1199/// [`unplanned_cpu_access_count`] stays available under `dynamic` too --
1200/// it just always reads 0 there, honestly reflecting that this backend
1201/// produces no such telemetry.
1202#[cfg(feature = "static")]
1203pub(crate) fn note_unplanned_cpu_access(identity_id: u64, backend: &str, detail: &str) {
1204    UNPLANNED_CPU_ACCESS.fetch_add(1, Ordering::Relaxed);
1205    static WARNED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<u64>>> =
1206        std::sync::OnceLock::new();
1207    let warned = WARNED.get_or_init(Default::default);
1208    if warned.lock().is_ok_and(|mut s| s.insert(identity_id)) {
1209        log::warn!(
1210            "unplanned CPU access on {backend} buffer (identity {identity_id}): {detail} — \
1211             declare the intent at allocation (CpuAccess::Read/Write/ReadWrite) to make \
1212             this a planned, optimized mapping"
1213        );
1214    }
1215}
1216
1217/// Uniform guard for mutable access through a read-only map. Every
1218/// `TensorMap` backend calls this from `as_mut_slice`/`deref_mut` so a
1219/// `map_read()` misuse fails identically on all platforms instead of
1220/// passing on tolerant ones and exploding only on Android.
1221#[inline]
1222pub(crate) fn assert_map_writable(writable: bool, backend: &str) {
1223    assert!(
1224        writable,
1225        "{backend} map is read-only (obtained via map_read()/CpuAccess::Read) — \
1226         use map_mut() or map_write() for mutable access"
1227    );
1228}
1229
1230/// Declared CPU involvement for an image tensor, chosen at allocation.
1231///
1232/// The HAL assumes buffers are produced and consumed by hardware (ISP,
1233/// codec, GPU, NPU) — hardware access needs no declaration. CPU access is
1234/// the opt-in: it selects the CPU usage/mapping mode at allocation
1235/// (write-combined for `Write`, cached for `Read`) and, on Android, pins
1236/// the layout linear (vendor tile compression requires `None`).
1237///
1238/// Mapping beyond the declared access is best-effort, never silent: it
1239/// may be refused ([`Error::NotImplemented`]) or take a slow path, and it
1240/// always increments [`unplanned_cpu_access_count`] with a once-per-buffer
1241/// warning.
1242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1243pub enum CpuAccess {
1244    /// Hardware-only buffer (the default): no CPU mapping declared.
1245    /// Compression-eligible on platforms with vendor tile layouts.
1246    #[default]
1247    None,
1248    /// CPU reads (verification, CPU consumers) — cached mapping.
1249    Read,
1250    /// CPU writes (decode targets) — write-combined mapping where the
1251    /// platform supports it; reading through a `Write` map is undeclared.
1252    Write,
1253    /// CPU reads and writes — the pre-CpuAccess implicit behavior.
1254    ReadWrite,
1255}
1256
1257impl CpuAccess {
1258    /// Whether this declaration includes CPU reads.
1259    pub fn reads(self) -> bool {
1260        matches!(self, CpuAccess::Read | CpuAccess::ReadWrite)
1261    }
1262
1263    /// Whether this declaration includes CPU writes.
1264    pub fn writes(self) -> bool {
1265        matches!(self, CpuAccess::Write | CpuAccess::ReadWrite)
1266    }
1267
1268    /// Whether this declaration covers `requested` (every direction the
1269    /// request needs is declared).
1270    pub fn covers(self, requested: CpuAccess) -> bool {
1271        (!requested.reads() || self.reads()) && (!requested.writes() || self.writes())
1272    }
1273}
1274
1275/// Requested tile-compression behavior for an image allocation (set via
1276/// [`ImageDesc::with_compression`]).
1277///
1278/// Vendor GPUs store textures in proprietary compressed tile layouts
1279/// (UBWC, AFBC, PVRIC, DCC) that cut memory bandwidth; eligibility
1280/// requires a hardware-only buffer ([`CpuAccess::None`]) because CPU
1281/// mapping pins the layout linear. The request records best knowledge on
1282/// the tensor ([`Tensor::compression`]); it never changes what bytes a
1283/// consumer sees through the GPU/NPU.
1284#[non_exhaustive]
1285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1286pub enum Compression {
1287    /// Let the platform use its native scheme when the format is
1288    /// eligible; otherwise allocate linear and count the fallback
1289    /// ([`compression_fallback_count`]). The right default for pipelines
1290    /// that want the bandwidth win without portability failures.
1291    Any,
1292    /// Require one specific scheme: allocation fails with
1293    /// [`Error::InvalidArgument`] when the device's scheme differs and
1294    /// [`Error::NotImplemented`] on platforms without vendor tile
1295    /// compression. For consumers whose ABI names a layout (e.g. a
1296    /// QNN context binary declaring UBWC inputs).
1297    Scheme(CompressionScheme),
1298}
1299
1300// Declared through `ef_vocabulary!` (see `vocabulary.rs`). This vocabulary
1301// gained a wire numbering when `ef_tensor_compression` was added: the
1302// dynamic backend reads a scheme back across the C boundary, so the enum
1303// needs codes that cannot drift from the C enumerators. Hand-writing that
1304// mapping in `edgefirst-tensor-capi` was the alternative and it is strictly
1305// worse -- `#[non_exhaustive]` makes a cross-crate `match` require a
1306// wildcard arm, so a scheme added here would silently map to "linear"
1307// there. Generated from one declaration, it cannot.
1308//
1309// Code 0 is deliberately unassigned and reserved for "linear" -- the state
1310// this crate spells `Option::None`, which has no variant to carry a code of
1311// its own. The C side names it `EF_COMPRESSION_NONE`.
1312crate::ef_vocabulary! {
1313    /// Vendor tile-compression schemes the HAL recognizes and records.
1314    ///
1315    /// Unrecognized platforms record `None` (linear) — there is deliberately
1316    /// no `Unknown` variant; a scheme is only recorded when the vendor is
1317    /// positively identified.
1318    #[non_exhaustive]
1319    pub enum CompressionScheme {
1320        /// Qualcomm Adreno Universal Bandwidth Compression.
1321        Ubwc = 1, "ubwc", UBWC,
1322        /// Arm Mali/Immortalis Framebuffer Compression.
1323        Afbc = 2, "afbc", AFBC,
1324        /// Imagination PowerVR Image Compression (Google Tensor G5+).
1325        Pvric = 3, "pvric", PVRIC,
1326        /// Samsung Xclipse (AMD RDNA) Delta Color Compression.
1327        Dcc = 4, "dcc", DCC,
1328    }
1329    // `#[doc(hidden)]` for the same reason `dtype_wire` is: emission
1330    // plumbing for `const`-only consumers, not a second documented API.
1331    #[doc(hidden)]
1332    pub mod compression_wire;
1333}
1334
1335/// Count of image allocations that requested [`Compression::Any`] but
1336/// resolved to a linear layout (ineligible format/dtype, unrecognized
1337/// vendor, or a platform without vendor tile compression). Read via
1338/// [`compression_fallback_count`].
1339static COMPRESSION_FALLBACKS: AtomicU64 = AtomicU64::new(0);
1340
1341/// Number of [`Compression::Any`] requests since process start that
1342/// resolved to a linear layout instead of a vendor tile scheme. A
1343/// steady-state pipeline holds this flat after warmup; growth means an
1344/// allocation path keeps requesting compression it never gets.
1345pub fn compression_fallback_count() -> u64 {
1346    COMPRESSION_FALLBACKS.load(Ordering::Relaxed)
1347}
1348
1349/// Record a `Compression::Any` request resolving linear.
1350///
1351/// `static`-only: the sole caller is `Tensor::image_desc` (`impl<T>
1352/// Tensor<T>`), itself `#[cfg(feature = "static")]`.
1353/// [`compression_fallback_count`] stays available under `dynamic` too --
1354/// it just always reads 0 there.
1355#[cfg(feature = "static")]
1356pub(crate) fn note_compression_fallback(detail: &str) {
1357    COMPRESSION_FALLBACKS.fetch_add(1, Ordering::Relaxed);
1358    log::debug!("compression request fell back to linear: {detail}");
1359}
1360
1361/// Whether this platform can allocate `(format, dtype)` images in a
1362/// vendor tile-compressed layout. `true` requires an Android build, an
1363/// eligible format (RGBA8888 `u8`/`i8` initially), and a positively
1364/// identified GPU vendor; everywhere else the answer is `false` and
1365/// [`Compression::Any`] requests fall back to linear.
1366pub fn compression_support(format: PixelFormat, dtype: DType) -> bool {
1367    #[cfg(target_os = "android")]
1368    {
1369        crate::ahardwarebuffer_layout::compression_eligible(format, dtype)
1370            && crate::ahardwarebuffer::device_compression_scheme().is_some()
1371    }
1372    #[cfg(not(target_os = "android"))]
1373    {
1374        let _ = (format, dtype);
1375        false
1376    }
1377}
1378
1379/// Declarative image-allocation request — the full-featured front door
1380/// for image tensors ([`TensorDyn::image_desc`] and the image crate's
1381/// `ImageProcessor::create_image_desc`).
1382///
1383/// The classic constructors (`image`, `create_image`) cover the common
1384/// cases; the desc carries the optional requests — today the
1385/// [`Compression`] request — without another constructor-parameter
1386/// sweep. Fields are private and the builders consume/return by value,
1387/// so future options are non-breaking.
1388///
1389/// ```
1390/// use edgefirst_tensor::{Compression, CpuAccess, DType, ImageDesc, PixelFormat};
1391/// let desc = ImageDesc::new(640, 640, PixelFormat::Rgba, DType::U8)
1392///     .with_access(CpuAccess::None)
1393///     .with_compression(Compression::Any);
1394/// ```
1395#[derive(Debug, Clone)]
1396pub struct ImageDesc {
1397    width: usize,
1398    height: usize,
1399    format: PixelFormat,
1400    dtype: DType,
1401    memory: Option<TensorMemory>,
1402    access: CpuAccess,
1403    compression: Option<Compression>,
1404}
1405
1406impl ImageDesc {
1407    /// A new image request: auto-selected memory, [`CpuAccess::None`]
1408    /// (hardware-only), no compression request.
1409    pub fn new(width: usize, height: usize, format: PixelFormat, dtype: DType) -> Self {
1410        Self {
1411            width,
1412            height,
1413            format,
1414            dtype,
1415            memory: None,
1416            access: CpuAccess::None,
1417            compression: None,
1418        }
1419    }
1420
1421    /// Request a specific memory backing (`None` = auto-select).
1422    pub fn with_memory(mut self, memory: Option<TensorMemory>) -> Self {
1423        self.memory = memory;
1424        self
1425    }
1426
1427    /// Declare the CPU access (see [`CpuAccess`]). Any declaration other
1428    /// than `None` makes a compression request invalid.
1429    pub fn with_access(mut self, access: CpuAccess) -> Self {
1430        self.access = access;
1431        self
1432    }
1433
1434    /// Request a tile-compressed layout (see [`Compression`]).
1435    pub fn with_compression(mut self, compression: Compression) -> Self {
1436        self.compression = Some(compression);
1437        self
1438    }
1439
1440    /// Requested width in pixels.
1441    pub fn width(&self) -> usize {
1442        self.width
1443    }
1444
1445    /// Requested height in pixels.
1446    pub fn height(&self) -> usize {
1447        self.height
1448    }
1449
1450    /// Requested pixel format.
1451    pub fn format(&self) -> PixelFormat {
1452        self.format
1453    }
1454
1455    /// Requested element type.
1456    pub fn dtype(&self) -> DType {
1457        self.dtype
1458    }
1459
1460    /// Requested memory backing (`None` = auto-select).
1461    pub fn memory(&self) -> Option<TensorMemory> {
1462        self.memory
1463    }
1464
1465    /// Declared CPU access.
1466    pub fn access(&self) -> CpuAccess {
1467        self.access
1468    }
1469
1470    /// The compression request, if any.
1471    pub fn compression(&self) -> Option<Compression> {
1472        self.compression
1473    }
1474}
1475
1476/// What kind of system key an identity was derived from. Participates in the
1477/// id so that, say, inode 7 and IOSurfaceID 7 cannot collide.
1478///
1479/// Invariant every variant must uphold: the key passed to
1480/// [`BufferIdentity::derived`] is unique among live buffers of that kind
1481/// **that own an allocation**. There is no "no identity" case — where no
1482/// system-wide key exists a process-local one does (a pointer, a GL name),
1483/// and a process-local key is exactly as sound as a system-wide one:
1484/// pointer/name reuse after free is the same ABA hazard as inode reuse, and
1485/// is mitigated the same way, by the retaining cache (see the design spec's
1486/// Task 6).
1487///
1488/// The allocation qualifier is load-bearing, not a hedge. A zero-element
1489/// tensor (`[0, num_protos]`, the tracker's "no detections this frame"
1490/// sentinel — see [`crate::mem`]) owns no allocation, so its `Vec` yields the
1491/// dangling well-aligned pointer `align_of::<T>()`. Every zero-element
1492/// `Tensor<f32>` in a process therefore shares one identity, and can even
1493/// collide across kinds with a small integer key from another variant.
1494///
1495/// That is safe rather than tolerated: an identity exists to answer "are
1496/// these the same bytes?", and buffers with no bytes have nothing to alias.
1497/// Nor can they reach an import cache. There are four GPU import paths —
1498/// a dma-buf fd on Linux, an IOSurface ref on macOS, an `AHardwareBuffer` on
1499/// Android, and an `ID3D11Texture2D` on Windows — and each takes only its own
1500/// object, so a tensor with no allocation reaches none of them. The Windows
1501/// path additionally keys on the texture the import itself holds a reference
1502/// to, and `edgefirst-image` **refuses** any texture whose identity is not
1503/// `D3d11Texture` over that same pointer — in every build, and on every
1504/// import including one served from its cache
1505/// (`GlPlatform::validate_import_identity`). That refusal exists because the
1506/// rule was once only written
1507/// down: the dynamic backend handed the image crate `HostPtr` identities over
1508/// recyclable handle addresses, and converts silently rendered into dropped
1509/// textures. Do not "fix" the zero-element case by minting a distinct id per
1510/// empty tensor: that reintroduces a counter, which is the state this whole
1511/// design exists to remove.
1512#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1513pub enum IdentityKind {
1514    /// `(st_dev, st_ino)` of a dma-buf or memfd. Survives `dup`, and is the
1515    /// same value in every process that holds the buffer.
1516    DmaBuf,
1517    Shm,
1518    /// `IOSurfaceGetID`.
1519    IoSurface,
1520    /// `AHardwareBuffer_getId`, API 31+.
1521    AHardwareBufferId,
1522    /// The `AHardwareBuffer*` pointer, pre-API-31 where no id exists.
1523    /// Process-local: unique among live buffers here, meaningless elsewhere.
1524    AHardwareBufferPtr,
1525    /// An `ID3D11Texture2D*`. Process-local, like `AHardwareBufferPtr`, and
1526    /// the same pointer for every kind of texture tensor: one the HAL
1527    /// allocated, one wrapped from a caller's pointer, and one opened from a
1528    /// shared NT handle all key on the texture object they hold.
1529    ///
1530    /// **Both backends derive it from the same pointer, so their ids are
1531    /// equal byte for byte.** The static backend reads the `ComPtr` it owns
1532    /// (`d3d11::texture::tex_key`); the dynamic backend reads the same
1533    /// pointer back across the ABI with `ef_tensor_d3d11_texture`
1534    /// (`TensorDyn::derive_identity`). That equality is the point, not a
1535    /// coincidence: [`BufferIdentity::derived`] exists so two independently
1536    /// linked copies of this crate name one buffer identically, and
1537    /// `edgefirst-image` links the dynamic backend inside every `-capi` leaf
1538    /// while its own tests link the static one.
1539    ///
1540    /// **An `ef_tensor` handle's address must never be the key.** It is not a
1541    /// buffer at all, nothing the cache holds keeps it alive, and this
1542    /// library's allocator hands a freed handle's address to the very next
1543    /// one it mints — measured at 400 texture handles over 4 distinct
1544    /// addresses. The dynamic backend did exactly that on Windows until
1545    /// 2026-09-04, and a convert whose destination key collided rendered into
1546    /// the dropped texture while returning success.
1547    ///
1548    /// A handle import therefore does **not** compare equal to the tensor it
1549    /// was exported from, nor to a second import of the same handle:
1550    /// `OpenSharedResource1` mints a fresh `ID3D11Texture2D` per open. Keying
1551    /// on a handle value would make those compare equal, and would be unsound:
1552    /// Windows recycles a closed handle value straight into the next
1553    /// `DuplicateHandle`, so a consumer that caches on this id past the
1554    /// producing tensor's lifetime -- as the image crate's EGLImage cache
1555    /// does -- could serve one texture's entry for a later, unrelated import.
1556    /// A COM object's address cannot be recycled that way: a cache that holds
1557    /// a reference to the texture keeps the address taken for as long as its
1558    /// entry can be found. The cost is a cache miss per re-import instead of
1559    /// a wrong image.
1560    D3d11Texture,
1561    /// A host mapping address. Process-local.
1562    HostPtr,
1563    /// A GL buffer name. Valid only in the context that created it.
1564    Pbo,
1565}
1566
1567/// Unique identity for a tensor's underlying buffer.
1568///
1569/// Created fresh on every buffer allocation or import. The `id` is a cache
1570/// key, `kind` records what system key (if any) it was derived from, and the
1571/// `guard` is an `Arc<()>` whose weak references allow downstream caches to
1572/// detect when the buffer has been dropped.
1573#[derive(Debug, Clone)]
1574pub struct BufferIdentity {
1575    id: u64,
1576    kind: IdentityKind,
1577    guard: Arc<()>,
1578}
1579
1580impl BufferIdentity {
1581    /// Derive an identity from a key the operating system already guarantees.
1582    ///
1583    /// Deliberately NOT a counter: two independently-linked copies of this
1584    /// crate must derive the same id for the same buffer, or a consumer's
1585    /// import cache misses every frame. See the design spec, "The core finding".
1586    pub fn derived(kind: IdentityKind, key: u64) -> Self {
1587        // Mix the discriminant into the high bits so different kinds with the
1588        // same numeric key never collide.
1589        let id = ((kind as u64) << 56) ^ key;
1590        Self {
1591            id,
1592            kind,
1593            guard: Arc::new(()),
1594        }
1595    }
1596
1597    /// Unique identifier for this buffer. Changes when the buffer changes.
1598    pub fn id(&self) -> u64 {
1599        self.id
1600    }
1601
1602    /// What kind of system key this identity was derived from.
1603    pub fn kind(&self) -> IdentityKind {
1604        self.kind
1605    }
1606
1607    /// Returns a weak reference to the buffer guard. Goes dead when the
1608    /// owning Tensor is dropped (and no clones remain).
1609    pub fn weak(&self) -> Weak<()> {
1610        Arc::downgrade(&self.guard)
1611    }
1612}
1613
1614// `static`-only from here through `fs_magic` below: every use site lives in
1615// `impl<T> TensorStorage<T>` (`from_fd`'s DMA-BUF-vs-tmpfs classification),
1616// itself `#[cfg(feature = "static")]`. `dynamic`'s `from_fd`
1617// (`dynamic_backend.rs`) drives `ef_tensor_builder_wrap` instead, which
1618// does this same classification inside `libedgefirst_tensor.so`.
1619#[cfg(all(target_os = "linux", feature = "static"))]
1620use nix::sys::stat::{major, minor};
1621
1622/// Filesystem magic of the internal `dma_buf` mount, from
1623/// `include/uapi/linux/magic.h` (`DMA_BUF_MAGIC`, "DMAB").
1624///
1625/// This is stable UAPI and is the only reliable way to recognize a DMA-BUF
1626/// fd — see [`TensorStorage::from_fd`] for why `st_dev` cannot be used.
1627#[cfg(all(target_os = "linux", feature = "static"))]
1628const DMA_BUF_MAGIC: u32 = 0x444d_4142;
1629
1630/// Filesystem magic of tmpfs, from `include/uapi/linux/magic.h`
1631/// (`TMPFS_MAGIC`). Covers both POSIX `shm_open` segments under `/dev/shm`
1632/// and anonymous `memfd_create` files.
1633#[cfg(all(target_os = "linux", feature = "static"))]
1634const TMPFS_MAGIC: u32 = 0x0102_1994;
1635
1636/// Normalize a raw `fstatfs` `f_type` to the 32-bit unsigned magic that
1637/// `include/uapi/linux/magic.h` documents.
1638///
1639/// The width and signedness of `f_type` are target-dependent: `__fsword_t`
1640/// on Linux/gnu (`i64` on 64-bit, `i32` on 32-bit targets such as armv7,
1641/// i686 and aarch64-ilp32), `c_int` on uclibc, `c_ulong` on musl, and
1642/// `c_uint` on s390x. Where it is signed and 32 bits wide, widening it
1643/// sign-extends every magic with bit 31 set — hugetlbfs (`0x958458f6`),
1644/// btrfs (`0x9123683e`), f2fs (`0xf2f52010`) — which would make the value
1645/// reported by [`Error::UnknownBufferType`] impossible to find in the
1646/// header the docs point callers at.
1647///
1648/// Every magic is a 32-bit quantity, so truncating back to `u32` recovers
1649/// the true value from all four representations and loses nothing.
1650#[cfg(all(target_os = "linux", feature = "static"))]
1651const fn fs_magic(raw: i64) -> u32 {
1652    raw as u32
1653}
1654
1655/// The operations every memory backend implements.
1656///
1657/// This is the seam that lets [`Tensor<T>`] hide which backend is in play.
1658/// The implementors are `DmaTensor` (Linux DMA-BUF), `IoSurfaceTensor`
1659/// (macOS/iOS), `AHardwareBufferTensor` (Android), `ShmTensor`, `MemTensor`,
1660/// and [`PboTensor`]; `TensorStorage<T>` dispatches to whichever is active.
1661/// Backend types are not public — allocate a [`Tensor<T>`] or [`TensorDyn`]
1662/// and call these methods through it.
1663///
1664/// Import the trait to get `shape`, `size`, `map`, `clone_fd`,
1665/// `buffer_identity`, and the zero-copy sub-region `view` on a tensor value.
1666/// `Tensor::view` and `Tensor::batch` route through [`TensorTrait::view`], so
1667/// each backend's identity-sharing rule lives in exactly one place.
1668///
1669/// [`TensorDyn`]: crate::TensorDyn
1670pub trait TensorTrait<T>: Send + Sync
1671where
1672    T: Num + Clone + fmt::Debug,
1673{
1674    /// Create a new tensor with the given shape and optional name. If no name
1675    /// is given, a random name will be generated.
1676    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
1677    where
1678        Self: Sized;
1679
1680    #[cfg(unix)]
1681    /// Import an existing buffer as a tensor, taking ownership of its file
1682    /// descriptor. The buffer is adopted in place — no bytes are copied.
1683    ///
1684    /// The backend is **detected**, not chosen: the fd already belongs to a
1685    /// buffer type, and this call's job is to recognize which. On Linux that
1686    /// is decided by the fd's filesystem magic, which is stable UAPI
1687    /// (`include/uapi/linux/magic.h`):
1688    ///
1689    /// | Filesystem | Magic | Resulting [`TensorMemory`] |
1690    /// |------------|-------|----------------------------|
1691    /// | `dma_buf` | `DMA_BUF_MAGIC` (`0x444d4142`) | [`TensorMemory::DmaBuf`] |
1692    /// | `tmpfs` (`/dev/shm` **and** `memfd`) | `TMPFS_MAGIC` (`0x01021994`) | [`TensorMemory::Shm`] |
1693    /// | anything else | — | *rejected* — see Errors |
1694    ///
1695    /// Both supported types are identified **positively**. An unrecognized
1696    /// filesystem is an error, never a fallback to shared memory: the wrong
1697    /// branch does not fail loudly (a DMA-BUF is mmap-able, so it would
1698    /// import as a perfectly functional tensor that merely isn't DMA, and a
1699    /// pipe would import as a zero-length one), so guessing would trade a
1700    /// clear error here for silent loss of zero-copy far downstream.
1701    ///
1702    /// The device number is deliberately **not** consulted. `dma_buf` files
1703    /// live on an internal kernel mount whose `st_dev` comes from
1704    /// `get_anon_bdev()` — an IDA shared with every other anonymous
1705    /// pseudo-filesystem and allocated in boot order — so the minor a
1706    /// DMA-BUF lands on varies by kernel build and is not part of any ABI.
1707    ///
1708    /// On non-Linux Unix (macOS/iOS/Android) there is no fd-based DMA import;
1709    /// the fd is always adopted as [`TensorMemory::Shm`].
1710    ///
1711    /// # Arguments
1712    ///
1713    /// * `fd` - Owned descriptor for the buffer to import. Ownership
1714    ///   transfers to the returned tensor and the fd is closed on drop; pass
1715    ///   [`clone_fd`](TensorTrait::clone_fd) output to keep your own handle.
1716    /// * `shape` - Logical shape to interpret the buffer with. Must describe
1717    ///   no more elements than the buffer holds.
1718    /// * `name` - Optional name; a random one is generated when `None`.
1719    ///
1720    /// # Returns
1721    ///
1722    /// A tensor sharing the imported buffer's memory, whose
1723    /// [`memory()`](TensorTrait::memory) reports the detected backend.
1724    ///
1725    /// # Errors
1726    ///
1727    /// * [`Error::UnknownBufferType`] - the fd is on a filesystem that is
1728    ///   neither `dma_buf` nor `tmpfs`, so its buffer type cannot be
1729    ///   determined. Carries the observed `fstatfs` magic as a `u32`,
1730    ///   normalized so it can be looked up in `magic.h` directly on both
1731    ///   32- and 64-bit targets. Typical causes: a regular file, a pipe or
1732    ///   socket, or a `MFD_HUGETLB` memfd (hugetlbfs, not tmpfs). Linux only.
1733    /// * [`Error::UnknownDeviceType`] - the fd's `st_dev` major is non-zero,
1734    ///   i.e. it lives on a real block device rather than an anonymous or
1735    ///   in-memory filesystem. Linux only.
1736    /// * [`Error::InvalidSize`] - `shape` is empty or describes zero
1737    ///   elements.
1738    /// * [`Error::NixError`] - `fstat`, `fstatfs`, or `mmap` failed on the
1739    ///   descriptor.
1740    ///
1741    /// # Examples
1742    ///
1743    /// ```no_run
1744    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
1745    ///
1746    /// # fn main() -> edgefirst_tensor::Result<()> {
1747    /// let src = Tensor::<u8>::new(&[480, 640, 3], Some(TensorMemory::DmaBuf), None)?;
1748    ///
1749    /// // Round-tripping a DMA-BUF fd preserves the backend — the import is
1750    /// // still zero-copy, and still eligible for GPU/NPU paths.
1751    /// let imported = Tensor::<u8>::from_fd(src.clone_fd()?, src.shape(), None)?;
1752    /// assert_eq!(imported.memory(), TensorMemory::DmaBuf);
1753    /// # Ok(())
1754    /// # }
1755    /// ```
1756    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
1757    where
1758        Self: Sized;
1759
1760    #[cfg(unix)]
1761    /// Clone the file descriptor associated with this tensor.
1762    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd>;
1763
1764    /// Get the memory type of this tensor.
1765    fn memory(&self) -> TensorMemory;
1766
1767    /// Get the name of this tensor.
1768    fn name(&self) -> String;
1769
1770    /// Get the number of elements in this tensor.
1771    fn len(&self) -> usize {
1772        self.shape().iter().product()
1773    }
1774
1775    /// Check if the tensor is empty.
1776    fn is_empty(&self) -> bool {
1777        self.len() == 0
1778    }
1779
1780    /// Get the size in bytes of this tensor.
1781    fn size(&self) -> usize {
1782        self.len() * std::mem::size_of::<T>()
1783    }
1784
1785    /// Get the shape of this tensor.
1786    fn shape(&self) -> &[usize];
1787
1788    /// Reshape this tensor to the given shape. The total number of elements
1789    /// must remain the same.
1790    fn reshape(&mut self, shape: &[usize]) -> Result<()>;
1791
1792    /// Bytes of the underlying allocation (>= the current logical `size()`).
1793    /// Defaults to the logical size for storages without spare capacity.
1794    fn capacity_bytes(&self) -> usize {
1795        self.size()
1796    }
1797
1798    /// Set the logical shape to any shape whose byte size fits the allocation
1799    /// capacity, without the equal-size constraint of `reshape`.
1800    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
1801        self.reshape(shape)
1802    }
1803
1804    /// Map the tensor into memory with the given access direction and
1805    /// return a TensorMap for accessing the data.
1806    ///
1807    /// `access` selects the platform mapping mode (read-only IOSurface
1808    /// lock, dma-buf sync direction, AHardwareBuffer lock usage) and the
1809    /// map's mutability: a map obtained with [`CpuAccess::Read`] rejects
1810    /// `as_mut_slice`. [`CpuAccess::None`] is not a mappable direction
1811    /// and returns [`Error::InvalidArgument`].
1812    ///
1813    /// Prefer the typed wrappers [`map_read`](Self::map_read) /
1814    /// [`map_write`](Self::map_write) / [`map_mut`](Self::map_mut).
1815    /// Map for CPU access.
1816    ///
1817    /// Like [`pin_host`](Tensor::pin_host), `'a` bounds how long the backing
1818    /// allocation lives rather than borrowing the tensor: the returned view
1819    /// shares ownership through the pin's keepalive, so a map can coexist with
1820    /// a `&mut` on the same tensor.
1821    ///
1822    /// # Windows D3D11 textures: a write-only map of a partial window
1823    ///
1824    /// A [`CpuAccess::Write`] map is not refreshed from the texture first, so
1825    /// the bytes the caller leaves untouched are undefined -- the documented
1826    /// contract a whole-tensor map can meet and a sub-view cannot, since the
1827    /// window spans its own rows only.
1828    ///
1829    /// A tensor allocated [`CpuAccess::Write`] (no staging texture) publishes
1830    /// only the window's own rows on unmap, so a sub-view -- a
1831    /// [`view`](Tensor::view), or a shape narrowed with `set_logical_shape`
1832    /// -- is refused only when its window is not a whole, pitch-aligned row
1833    /// range (a plain-offset [`view`](Tensor::view) always is; an arbitrary
1834    /// byte offset from `set_plane_offset` might not be), which fails with
1835    /// [`Error::InvalidArgument`].
1836    ///
1837    /// A tensor allocated [`CpuAccess::Read`] or [`CpuAccess::ReadWrite`]
1838    /// (staging texture) has no such per-window publish: unmapping always
1839    /// copies the whole staging texture back, so a write-only sub-view is
1840    /// refused with [`Error::PartialWriteRequiresReadWrite`], naming
1841    /// [`CpuAccess::ReadWrite`] as the same window with the refresh that
1842    /// makes the untouched rows well-defined -- and, unlike the
1843    /// `CpuAccess::Write` case above, that retry always succeeds.
1844    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1845    where
1846        T: 'a;
1847
1848    /// Map the tensor read-write (equivalent to
1849    /// `map_with(CpuAccess::ReadWrite)` — the historical `map()`
1850    /// behavior).
1851    fn map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1852    where
1853        T: 'a,
1854    {
1855        self.map_with(CpuAccess::ReadWrite)
1856    }
1857
1858    /// Map the tensor for CPU reading only. The returned map rejects
1859    /// `as_mut_slice`; on macOS this takes the read-only IOSurface lock
1860    /// (skips the unlock flush), on Linux the dma-buf read-direction
1861    /// sync.
1862    fn map_read<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1863    where
1864        T: 'a,
1865    {
1866        self.map_with(CpuAccess::Read)
1867    }
1868
1869    /// Map the tensor for CPU writing (fill-only: reading through a
1870    /// write map may see write-combined memory — do not read the slice).
1871    fn map_write<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1872    where
1873        T: 'a,
1874    {
1875        self.map_with(CpuAccess::Write)
1876    }
1877
1878    /// Map the tensor read-write (alias of [`map`](Self::map) with the
1879    /// intent spelled out).
1880    fn map_mut<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1881    where
1882        T: 'a,
1883    {
1884        self.map_with(CpuAccess::ReadWrite)
1885    }
1886
1887    /// Non-blocking [`map_with`](Self::map_with): returns
1888    /// `Err(Error::IoError(WouldBlock))` while a GPU copy the map depends on
1889    /// is still in flight, and makes progress on a retry.
1890    ///
1891    /// Backings with no such copy (host memory, shared memory, dma-buf,
1892    /// IOSurface, AHardwareBuffer, PBO) alias `map_with`, so a caller can use
1893    /// this unconditionally and only ever see `WouldBlock` where the wait is
1894    /// real -- today the Windows D3D11 texture's staging refresh.
1895    ///
1896    /// A retry loop must yield or sleep between attempts. On the WARP
1897    /// software adapter the CPU threads are the GPU, so a tight loop starves
1898    /// the very copy it is waiting for.
1899    ///
1900    /// [`CpuAccess::Write`] is refused on a Windows D3D11 texture tensor whose
1901    /// window is shorter than its backing, exactly as it is for
1902    /// [`map_with`](Self::map_with); see that method.
1903    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
1904    where
1905        T: 'a,
1906    {
1907        self.map_with(access)
1908    }
1909
1910    /// Non-blocking [`map`](Self::map): read-write, and `WouldBlock` rather
1911    /// than a stall. See [`try_map_with`](Self::try_map_with).
1912    fn try_map<'a>(&self) -> Result<crate::view::HostView<'a, T>>
1913    where
1914        T: 'a,
1915    {
1916        self.try_map_with(CpuAccess::ReadWrite)
1917    }
1918
1919    /// Get the buffer identity for cache keying and liveness tracking.
1920    fn buffer_identity(&self) -> &BufferIdentity;
1921
1922    /// Create a zero-copy sub-region view of this backing that shares the
1923    /// underlying allocation **and** [`BufferIdentity`].
1924    ///
1925    /// The window is `[offset_bytes, offset_bytes + shape.product() *
1926    /// size_of::<T>())` measured from this tensor's own logical start, so a
1927    /// sub-view of a sub-view composes by adding offsets. Sharing the parent's
1928    /// identity is the contract that lets identity-keyed caches (e.g. the GL
1929    /// EGLImage import cache) treat offset-distinct windows as one buffer rather
1930    /// than unrelated allocations — `view` must never mint a fresh identity.
1931    ///
1932    /// Defaults to [`Error::NotImplemented`]; every backend that supports
1933    /// sub-views overrides it (`Mem`, `Shm`, Linux DMA, macOS IOSurface, `Pbo`).
1934    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self>
1935    where
1936        Self: Sized,
1937    {
1938        let _ = (offset_bytes, shape);
1939        Err(Error::NotImplemented(
1940            "view (zero-copy sub-region) is not supported for this tensor backend".to_owned(),
1941        ))
1942    }
1943}
1944
1945pub trait TensorMapTrait<T>
1946where
1947    T: Num + Clone + fmt::Debug,
1948{
1949    /// Get the shape of this tensor map.
1950    fn shape(&self) -> &[usize];
1951
1952    /// Unmap the tensor from memory.
1953    fn unmap(&mut self);
1954
1955    /// Get the number of elements in this tensor map.
1956    fn len(&self) -> usize {
1957        self.shape().iter().product()
1958    }
1959
1960    /// Check if the tensor map is empty.
1961    fn is_empty(&self) -> bool {
1962        self.len() == 0
1963    }
1964
1965    /// Get the size in bytes of this tensor map.
1966    fn size(&self) -> usize {
1967        self.len() * std::mem::size_of::<T>()
1968    }
1969
1970    /// Get a slice to the data in this tensor map.
1971    fn as_slice(&self) -> &[T];
1972
1973    /// Get a mutable slice to the data in this tensor map.
1974    fn as_mut_slice(&mut self) -> &mut [T];
1975
1976    /// Whether this mapping permits writes -- i.e. whether the coherency
1977    /// bracket it holds will flush on release.
1978    ///
1979    /// The single source of truth for a binding that has to advertise
1980    /// mutability to a foreign consumer (the Python buffer protocol's
1981    /// `readonly` flag, say). Deriving that from the access a caller
1982    /// *requested* instead would leave the advertisement agreeing with the
1983    /// request while both disagreed with the mapping actually taken.
1984    fn is_writable(&self) -> bool;
1985
1986    #[cfg(feature = "ndarray")]
1987    /// Get an ndarray ArrayView of the tensor data.
1988    fn view(&'_ self) -> Result<ndarray::ArrayView<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
1989        Ok(ndarray::ArrayView::from_shape(
1990            self.shape(),
1991            self.as_slice(),
1992        )?)
1993    }
1994
1995    #[cfg(feature = "ndarray")]
1996    /// Get an ndarray ArrayViewMut of the tensor data.
1997    fn view_mut(
1998        &'_ mut self,
1999    ) -> Result<ndarray::ArrayViewMut<'_, T, ndarray::Dim<ndarray::IxDynImpl>>> {
2000        let shape = self.shape().to_vec();
2001        Ok(ndarray::ArrayViewMut::from_shape(
2002            shape,
2003            self.as_mut_slice(),
2004        )?)
2005    }
2006}
2007
2008// Declared through `ef_vocabulary!` (see `vocabulary.rs`). Before this, the
2009// same four backings were numbered three different ways -- Rust's implicit
2010// discriminants (`Dma=0, Shm=1, Mem=2, Pbo=3`), the C ABI's
2011// `hal_tensor_memory` (`MEM=0, DMA=1, SHM=2, PBO=3`), and Python's
2012// `PyTensorMemory`, which had no explicit discriminants at all and a
2013// `#[cfg(unix)]` variant in the middle of the list, so its numbering
2014// depended on the target OS. The canonical assignment below is the one all
2015// of them converge on; `hal_tensor_memory` is the single explicitly-mapped
2016// outlier that keeps its own released values (see `to_hal_tensor_memory` in
2017// the capi crate).
2018//
2019// Note this is NOT `protocol::kind`, which is a genuinely different
2020// vocabulary and stays so: `kind` describes what a `TensorDesc`'s `handle`
2021// and `ptr` fields MEAN to an importer, and both `Mem` and `Shm` mean the
2022// same thing there (`kind::HOST`, host-addressable pointer, no handle). A
2023// vocabulary that collapses two variants into one cannot also be the
2024// vocabulary that distinguishes them. `kind_of()` in `protocol.rs` is the
2025// mapping between them.
2026crate::ef_vocabulary! {
2027    /// Which memory backend a tensor is (or should be) allocated from.
2028    ///
2029    /// Pass `Some(..)` to a constructor to pin the backend, or `None` to
2030    /// auto-select. Auto-selection differs by constructor: [`Tensor::new`]
2031    /// tries `DmaBuf` → `Mem`, while the image constructors try `DmaBuf` →
2032    /// `Mem` and skip `Shm`. `EDGEFIRST_TENSOR_FORCE_MEM=1` short-circuits
2033    /// either chain to `Mem`, which is how tests run on hosts without
2034    /// DMA-heap permissions.
2035    ///
2036    /// A pinned request has no fallback: if the backend cannot serve it, the
2037    /// constructor fails rather than quietly giving you something slower.
2038    /// [`TensorTrait::memory`] reports what a tensor actually ended up with.
2039    ///
2040    /// # Every variant exists on every platform
2041    ///
2042    /// This is a namespace of codes, not a list of what this build can
2043    /// allocate -- [`TensorMemory::is_available`] answers the latter, at
2044    /// runtime. `Shm` used to be `#[cfg(unix)]`, which shifted every
2045    /// variant declared after it on a non-unix build and made the
2046    /// numbering a compile-time detail of the host. It also made a
2047    /// cross-platform recording unreadable: a blob captured on Linux
2048    /// carries `"dmabuf"`, and a macOS consumer has to be able to NAME
2049    /// that backing in order to report "cannot materialise here" instead
2050    /// of failing with an unrecognised code.
2051    ///
2052    /// # `#[non_exhaustive]`
2053    ///
2054    /// Match this with a wildcard arm. The variant list is expected to
2055    /// keep growing -- `IoSurface` and `Cuda` were added here, Windows
2056    /// backings and a real CUDA device backing are named as coming -- and
2057    /// this attribute is what keeps a closed-source consumer's
2058    /// `match memory { .. }` compiling across those additions. It protects
2059    /// DOWNSTREAM, not this crate: inside `edgefirst-tensor` the matches
2060    /// stay exhaustive and compile-enforced. Same rule as [`DType`],
2061    /// [`Compression`] and [`PixelFormat`], which `TensorMemory` was the
2062    /// lone exception to.
2063    #[non_exhaustive]
2064    pub enum TensorMemory {
2065        /// Regular system memory allocation. Available everywhere; the
2066        /// floor every auto-selection chain ends on.
2067        Mem = 0, "mem", MEM,
2068        /// POSIX Shared Memory allocation. Suitable for inter-process
2069        /// communication, but not for hardware acceleration. Only
2070        /// allocatable on unix; nameable everywhere.
2071        Shm = 1, "shm", SHM,
2072        /// Platform-native zero-copy GPU buffer.
2073        ///
2074        /// On Linux this is a DMA-BUF (`DmaTensor` in
2075        /// `crates/tensor/src/dma.rs`) allocated via the DRM/dma-heap
2076        /// subsystem. On macOS/iOS it is currently also this variant that
2077        /// yields an IOSurface, and on Android an AHardwareBuffer: they
2078        /// share the `TensorStorage::Dma` slot at the trait level, and the
2079        /// public C API discriminant (`HAL_TENSOR_MEMORY_DMA = 1`) covers
2080        /// all three with no ABI break. `IoSurface` below is the code for
2081        /// naming that backing specifically; no backend reports it yet.
2082        ///
2083        /// Allows hardware-accelerated paths (OpenGL backend on Linux via
2084        /// `EGL_EXT_image_dma_buf_import`; macOS via
2085        /// `EGL_ANGLE_iosurface_client_buffer`). CPU access via `map()`
2086        /// incurs cache-coherency overhead on Linux DMA-BUF and is similar
2087        /// in cost on IOSurface; SHM/Mem are cheaper for CPU-only workloads.
2088        ///
2089        /// On Windows it is an `ID3D11Texture2D` on the process D3D11 device
2090        /// (`crates/tensor/src/d3d11/`), allocated only through
2091        /// [`Tensor::image`](crate::Tensor::image); CPU access goes through a
2092        /// staging copy. The first such allocation creates the process
2093        /// device, so a host supplying its own device must call
2094        /// `d3d11::use_external_device` before it -- and before
2095        /// [`crate::is_gpu_buffer_available`], which creates the device too.
2096        DmaBuf = 2, "dmabuf", DMABUF,
2097        /// Apple IOSurface, named specifically rather than through the
2098        /// portable `DmaBuf` spelling.
2099        ///
2100        /// Defined as a code so a descriptor or recording that names an
2101        /// IOSurface is parseable on every platform. **No backend produces
2102        /// or accepts it yet** -- macOS/iOS allocate and report `DmaBuf`.
2103        /// Pinning a request to it fails with `NotImplemented`.
2104        IoSurface = 3, "iosurface", IOSURFACE,
2105        /// OpenGL Pixel Buffer Object memory. Created by `ImageProcessor`
2106        /// when DMA-buf is unavailable but OpenGL is present -- never by
2107        /// [`Tensor::new`], which has no GL context to create it in.
2108        Pbo = 4, "pbo", PBO,
2109        /// CUDA device memory: a device pointer, not host-addressable.
2110        ///
2111        /// Defined as a code for the same reason as `IoSurface`. **No
2112        /// backend produces or accepts it yet**; pinning a request to it
2113        /// fails with `NotImplemented`.
2114        Cuda = 5, "cuda", CUDA,
2115    }
2116    // `#[doc(hidden)]`: `pub` so the const-only form is reachable by an FFI
2117    // or cbindgen consumer, but it is emission plumbing rather than a second
2118    // documented API -- `TensorMemory::code()` is the documented path, and
2119    // the two are generated from the same literal.
2120    #[doc(hidden)]
2121    pub mod tensor_memory_wire;
2122}
2123
2124impl TensorMemory {
2125    /// Whether this backing can actually be allocated in this process, now.
2126    ///
2127    /// A runtime question, not a compile-time one, which is why it is a
2128    /// method on the vocabulary rather than a `cfg` on the variant:
2129    /// `DmaBuf` needs `/dev/dma_heap` to exist *and* this process to have
2130    /// permission on it (a container, a user outside the `video`/`render`
2131    /// groups, and a stock desktop give three different answers on the same
2132    /// kernel), and `Shm` needs a writable `/dev/shm`. Each probe allocates
2133    /// once and caches.
2134    ///
2135    /// Reports what can be *allocated*, which is not the same as what can be
2136    /// *named*: every variant is nameable on every platform (that is the
2137    /// point of the vocabulary), and the ones this build cannot serve say so
2138    /// here rather than by being absent from the enum.
2139    ///
2140    /// Three variants answer `false` unconditionally today, for reasons
2141    /// worth stating rather than hiding behind a probe that cannot fail:
2142    ///
2143    /// * `IoSurface` -- no backend produces or accepts it yet; macOS/iOS
2144    ///   serve IOSurface under the `DmaBuf` spelling, so `DmaBuf` is the
2145    ///   variant that answers `true` there.
2146    /// * `Pbo` -- PBO tensors are created by `ImageProcessor::create_image`
2147    ///   with a GL context current, never by [`Tensor::new`]. This crate
2148    ///   holds no GL context and cannot see one; ask the image crate
2149    ///   whether a GL backend is live.
2150    /// * `Cuda` -- there is no CUDA backing in this crate at all. Probing
2151    ///   for `libcuda` would answer `true` on a Jetson while every
2152    ///   allocation still failed, which is worse than answering `false`.
2153    ///
2154    /// One variant answers `true` for a narrower constructor than the rest:
2155    /// on Windows `DmaBuf` is an `ID3D11Texture2D`, which is image-formatted,
2156    /// so `true` there means [`Tensor::image`] can serve it and
2157    /// [`Tensor::new`] with `Some(DmaBuf)` still answers `NotImplemented`.
2158    /// [`is_gpu_buffer_available`](crate::is_gpu_buffer_available), which this
2159    /// forwards to, states the same split.
2160    pub fn is_available(self) -> bool {
2161        match self {
2162            // Always: the fallback every constructor chain ends on. If this
2163            // could fail, allocation would have no floor left.
2164            TensorMemory::Mem => true,
2165            TensorMemory::Shm => is_shm_available(),
2166            // Today `DmaBuf` is the portable spelling of "platform-native
2167            // zero-copy GPU buffer", so it is the portable probe that
2168            // answers for it -- true on macOS/iOS (IOSurface) and Android
2169            // (AHardwareBuffer) as well as Linux.
2170            TensorMemory::DmaBuf => is_gpu_buffer_available(),
2171            TensorMemory::IoSurface | TensorMemory::Pbo | TensorMemory::Cuda => false,
2172        }
2173    }
2174
2175    /// The error to return when a caller pins a request to a backing this
2176    /// build knows the name of but cannot serve.
2177    ///
2178    /// Deliberately not `unreachable!()`. `IoSurface` and `Cuda` are
2179    /// unreachable *today* only because no backend reports them yet; the
2180    /// day one does, an `unreachable!()` written now becomes a panic in
2181    /// library code reached from an ordinary caller request. A "this build
2182    /// cannot do that" error is the same information without the crash, and
2183    /// it is what every other unservable request here already returns.
2184    ///
2185    /// `static`-only: every call site is inside `impl<T> TensorStorage<T>`
2186    /// (`TensorStorage::new`), itself `#[cfg(feature = "static")]`.
2187    #[cfg(feature = "static")]
2188    pub(crate) fn unsupported_here(self) -> Error {
2189        let reason = match self {
2190            // Not reachable: `Mem` is servable on every target. Stated
2191            // rather than panicked on, for the reason above.
2192            TensorMemory::Mem => "Mem is servable on every platform, so this should not happen",
2193            TensorMemory::Shm => "POSIX shared memory is unix-only",
2194            TensorMemory::DmaBuf => {
2195                "this target has no platform GPU-buffer allocator (DMA-BUF is Linux, \
2196                 IOSurface macOS/iOS, AHardwareBuffer Android, D3D11 textures on \
2197                 Windows)"
2198            }
2199            TensorMemory::IoSurface => {
2200                "no backend produces or accepts IOSurface under its own code yet -- \
2201                 macOS/iOS allocate and report TensorMemory::DmaBuf"
2202            }
2203            TensorMemory::Pbo => {
2204                "PBO tensors are created by ImageProcessor::create_image with a GL \
2205                 context current, never by Tensor::new"
2206            }
2207            TensorMemory::Cuda => "there is no CUDA backing in this build",
2208        };
2209        Error::NotImplemented(format!(
2210            "TensorMemory::{self:?} is not supported by this build: {reason}"
2211        ))
2212    }
2213}
2214
2215impl From<TensorMemory> for String {
2216    /// The wire string, via [`TensorMemory::as_str`] -- one spelling, not a
2217    /// second one maintained alongside it.
2218    ///
2219    /// `DmaBuf` renders as `"dmabuf"`, not the `"dma"` this impl used to
2220    /// emit. `TryFrom<&str>` still accepts `"dma"` so anything that
2221    /// persisted the old spelling keeps parsing; the benchmark result keys
2222    /// in `.github/scripts` spell it `"dma"` but build that string
2223    /// themselves and never went through this impl.
2224    fn from(memory: TensorMemory) -> Self {
2225        memory.as_str().to_owned()
2226    }
2227}
2228
2229impl TryFrom<&str> for TensorMemory {
2230    type Error = Error;
2231
2232    /// Parses [`TensorMemory::as_str`]'s spelling, plus the legacy `"dma"`
2233    /// alias for `DmaBuf`.
2234    fn try_from(s: &str) -> Result<Self> {
2235        TensorMemory::from_str_code(s)
2236            .or(if s == "dma" {
2237                Some(TensorMemory::DmaBuf)
2238            } else {
2239                None
2240            })
2241            .ok_or_else(|| Error::InvalidMemoryType(s.to_owned()))
2242    }
2243}
2244
2245// Everything from here through the end of `impl<T> TensorTrait<T> for
2246// Tensor<T>` below is the `static` backend's storage-owning `Tensor<T>`:
2247// `TensorStorage<T>` (the per-memory-backend enum) and the `Tensor<T>`
2248// struct that wraps it. `dynamic`'s `Tensor<T>` is a different, much
2249// smaller type -- a typed lens over `TensorDyn` with no storage of its
2250// own -- defined in `tensor_dyn/dynamic_tensor.rs` and re-exported below.
2251#[cfg(feature = "static")]
2252#[derive(Debug)]
2253#[allow(dead_code)] // Variants are constructed by downstream crates via pub(crate) helpers
2254pub(crate) enum TensorStorage<T>
2255where
2256    T: Num + Clone + fmt::Debug + Send + Sync,
2257{
2258    /// Platform-native zero-copy GPU buffer. Inner type differs per
2259    /// target: `DmaTensor` on Linux (DMA-BUF fd), `IoSurfaceTensor` on
2260    /// macOS (CFRetained IOSurface). The shared variant name keeps the
2261    /// public `TensorMemory::DmaBuf` discriminant stable across platforms.
2262    #[cfg(target_os = "linux")]
2263    Dma(DmaTensor<T>),
2264    #[cfg(any(target_os = "macos", target_os = "ios"))]
2265    Dma(IoSurfaceTensor<T>),
2266    #[cfg(target_os = "android")]
2267    Dma(AHardwareBufferTensor<T>),
2268    #[cfg(target_os = "windows")]
2269    Dma(crate::d3d11::texture::D3d11TextureTensor<T>),
2270    #[cfg(unix)]
2271    Shm(ShmTensor<T>),
2272    Mem(MemTensor<T>),
2273    Pbo(PboTensor<T>),
2274}
2275
2276#[cfg(feature = "static")]
2277impl<T> TensorStorage<T>
2278where
2279    T: Num + Clone + fmt::Debug + Send + Sync,
2280{
2281    /// The backing allocation's intrinsic physical row pitch in bytes, if it
2282    /// has one that is fixed independent of the logical shape. macOS IOSurface
2283    /// reports its 64-aligned `bytesPerRow`; other backings (Linux DMA, SHM,
2284    /// Mem, PBO) have no fixed pitch beyond the logical shape and return `None`.
2285    ///
2286    /// Used by `configure_image` to preserve the physical pitch when a reused
2287    /// pool tensor is reconfigured to a smaller logical image — so the decode
2288    /// writes rows at the surface's real stride and the GPU samples them with
2289    /// the same stride (the physical-grid / logical-ROI decoupling).
2290    pub(crate) fn backing_row_stride(&self) -> Option<usize> {
2291        match self {
2292            // Only genuine image-formatted IOSurfaces (height > 1) carry a real
2293            // per-row pitch; a generic byte-bag (height == 1) returns `None` so
2294            // `configure_image` does not adopt its whole-buffer "row" as a stride.
2295            #[cfg(any(target_os = "macos", target_os = "ios"))]
2296            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2297            // Android AHardwareBuffer: same rule — only genuine 2D
2298            // image-formatted buffers (height > 1) carry a real row pitch.
2299            #[cfg(target_os = "android")]
2300            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2301            // Windows D3D11: the driver's staging row pitch. `None` when the
2302            // tensor has no staging (write-only or hardware-only), whose host
2303            // buffer has tight rows -- except for a semi-planar tensor, whose
2304            // texture is allocated as wide as that pitch for every access, so
2305            // it always has one to report.
2306            #[cfg(target_os = "windows")]
2307            TensorStorage::Dma(t) => t.image_backing_row_stride(),
2308            _ => None,
2309        }
2310    }
2311
2312    /// Create a new tensor storage with the given shape, memory type, and
2313    /// optional name. If no name is given, a random name will be generated.
2314    /// If no memory type is given, the best available memory type will be
2315    /// chosen based on the platform and environment variables.
2316    fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
2317        match memory {
2318            #[cfg(target_os = "linux")]
2319            Some(TensorMemory::DmaBuf) => {
2320                DmaTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2321            }
2322            #[cfg(any(target_os = "macos", target_os = "ios"))]
2323            Some(TensorMemory::DmaBuf) => {
2324                IoSurfaceTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2325            }
2326            #[cfg(target_os = "android")]
2327            Some(TensorMemory::DmaBuf) => {
2328                AHardwareBufferTensor::<T>::new(shape, name).map(TensorStorage::Dma)
2329            }
2330            // Windows has a DmaBuf backing, but it is a D3D11 texture: an image
2331            // layout, not a byte bag, so a shape alone cannot describe one.
2332            // Name the constructor that can, rather than quietly handing back
2333            // host memory the caller did not ask for.
2334            #[cfg(target_os = "windows")]
2335            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2336                "D3D11 texture tensors are image-formatted; use Tensor::image with \
2337                 TensorMemory::DmaBuf"
2338                    .to_owned(),
2339            )),
2340            #[cfg(not(any(
2341                target_os = "linux",
2342                target_os = "macos",
2343                target_os = "ios",
2344                target_os = "android",
2345                target_os = "windows"
2346            )))]
2347            Some(TensorMemory::DmaBuf) => Err(crate::error::Error::NotImplemented(
2348                "TensorMemory::DmaBuf is only available on Linux (DMA-BUF), macOS/iOS (IOSurface), \
2349                 Android (AHardwareBuffer), and Windows (D3D11 textures, via Tensor::image)"
2350                    .to_owned(),
2351            )),
2352            #[cfg(unix)]
2353            Some(TensorMemory::Shm) => {
2354                ShmTensor::<T>::new(shape, name).map(TensorStorage::Shm)
2355            }
2356            #[cfg(not(unix))]
2357            Some(m @ TensorMemory::Shm) => Err(m.unsupported_here()),
2358            Some(TensorMemory::Mem) => {
2359                MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2360            }
2361            Some(TensorMemory::Pbo) => Err(crate::error::Error::NotImplemented(
2362                "PboTensor cannot be created via Tensor::new() — use ImageProcessor::create_image()".to_owned(),
2363            )),
2364            // Defined codes with no backing behind them yet. An error, not
2365            // an `unreachable!()` -- see `unsupported_here`.
2366            Some(m @ (TensorMemory::IoSurface | TensorMemory::Cuda)) => Err(m.unsupported_here()),
2367            None => {
2368                if std::env::var("EDGEFIRST_TENSOR_FORCE_MEM")
2369                    .is_ok_and(|x| x != "0" && x.to_lowercase() != "false")
2370                {
2371                    MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2372                } else {
2373                    // Auto-select priority: Dma > Mem. Shm is intentionally NOT
2374                    // auto-selected — it offers no advantage over Mem for an
2375                    // in-process tensor and Mem always succeeds, so Shm below Mem
2376                    // is effectively never reached. Request Shm explicitly via
2377                    // `TensorMemory::Shm` when cross-process sharing is needed.
2378                    // (PBO sits between Dma and Mem but is GL-backed and created
2379                    // only via `ImageProcessor::create_image`.)
2380                    #[cfg(target_os = "linux")]
2381                    {
2382                        // Linux: Try DMA -> Mem
2383                        match DmaTensor::<T>::new(shape, name) {
2384                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2385                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2386                        }
2387                    }
2388                    #[cfg(any(target_os = "macos", target_os = "ios"))]
2389                    {
2390                        // macOS/iOS: Try IOSurface -> Mem. IOSurface is the
2391                        // GPU-shareable backend (zero-copy via ANGLE), filling the
2392                        // same role as DMA-BUF on Linux.
2393                        match IoSurfaceTensor::<T>::new(shape, name) {
2394                            Ok(tensor) => Ok(TensorStorage::Dma(tensor)),
2395                            Err(_) => MemTensor::<T>::new(shape, name).map(TensorStorage::Mem),
2396                        }
2397                    }
2398                    #[cfg(target_os = "android")]
2399                    {
2400                        // Android: Mem. Unlike macOS (where a byte-bag
2401                        // IOSurface is still GL-importable as R8) a generic
2402                        // BLOB AHardwareBuffer cannot back a GPU texture, so
2403                        // auto-selecting it buys nothing and costs plenty: a
2404                        // gralloc allocator-HAL ioctl per allocation (orders
2405                        // slower than malloc, ≥1 page + a dmabuf fd even for
2406                        // tiny tensors) and a lock/unlock cache-maintenance
2407                        // round trip on every map(). Zero-copy image tensors
2408                        // come from `Tensor::image(..)`; callers that want a
2409                        // BLOB (NNAPI handoff) request `TensorMemory::DmaBuf`
2410                        // explicitly.
2411                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2412                    }
2413                    #[cfg(all(
2414                        unix,
2415                        not(any(
2416                            target_os = "linux",
2417                            target_os = "macos",
2418                            target_os = "ios",
2419                            target_os = "android"
2420                        ))
2421                    ))]
2422                    {
2423                        // Other Unix (BSD): Mem only (no DMA; Shm is explicit-only)
2424                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2425                    }
2426                    #[cfg(not(unix))]
2427                    {
2428                        // Windows/other: Mem only
2429                        MemTensor::<T>::new(shape, name).map(TensorStorage::Mem)
2430                    }
2431                }
2432            }
2433        }
2434    }
2435
2436    /// Create a DMA-backed tensor storage with an explicit byte size that
2437    /// may exceed `shape.product() * sizeof(T)`. Used for image tensors
2438    /// with row-padded layouts (see `DmaTensor::new_with_byte_size`).
2439    ///
2440    /// This is intentionally DMA-only: padding is only meaningful for
2441    /// buffers that will be imported as GPU textures via EGLImage. PBO,
2442    /// Shm, and Mem storage doesn't benefit from pitch alignment and
2443    /// shouldn't pay the memory cost.
2444    #[cfg(target_os = "linux")]
2445    pub(crate) fn new_dma_with_byte_size(
2446        shape: &[usize],
2447        byte_size: usize,
2448        name: Option<&str>,
2449    ) -> Result<Self> {
2450        DmaTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Dma)
2451    }
2452
2453    // No non-Linux stub: the only caller (`Tensor::image_with_stride`)
2454    // returns `NotImplemented` directly on non-Linux without ever
2455    // reaching the storage layer, so defining a stub here would be
2456    // dead code and fail the `-D warnings` clippy gate on macOS CI.
2457
2458    /// Create a Mem-backed tensor storage with an explicit byte size that may
2459    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2460    /// 64-byte-aligned row strides (see `MemTensor::with_capacity_bytes`).
2461    pub(crate) fn new_mem_with_byte_size(
2462        shape: &[usize],
2463        byte_size: usize,
2464        name: Option<&str>,
2465    ) -> Result<Self>
2466    where
2467        T: 'static,
2468    {
2469        MemTensor::<T>::with_capacity_bytes(shape, byte_size, name).map(TensorStorage::Mem)
2470    }
2471
2472    /// Create a Shm-backed tensor storage with an explicit byte size that may
2473    /// exceed `shape.product() * sizeof(T)`.  Used for image tensors with
2474    /// 64-byte-aligned row strides (see `ShmTensor::new_with_byte_size`).
2475    #[cfg(unix)]
2476    pub(crate) fn new_shm_with_byte_size(
2477        shape: &[usize],
2478        byte_size: usize,
2479        name: Option<&str>,
2480    ) -> Result<Self> {
2481        ShmTensor::<T>::new_with_byte_size(shape, byte_size, name).map(TensorStorage::Shm)
2482    }
2483
2484    /// Allocate an image-formatted IOSurface-backed storage (macOS).
2485    ///
2486    /// Used by `Tensor::image()` when the caller requests
2487    /// `TensorMemory::DmaBuf` and the format has an IOSurface FourCC
2488    /// mapping (YUYV, RGBA, BGRA today). Falls back to `new_with_byte_size`
2489    /// otherwise.
2490    #[cfg(any(target_os = "macos", target_os = "ios"))]
2491    pub(crate) fn new_image_iosurface(
2492        width: usize,
2493        height: usize,
2494        format: PixelFormat,
2495        dtype: DType,
2496        shape: &[usize],
2497        name: Option<&str>,
2498    ) -> Result<Self> {
2499        IoSurfaceTensor::<T>::new_image(width, height, format, dtype, shape, name)
2500            .map(TensorStorage::Dma)
2501    }
2502
2503    /// Allocate an image-formatted AHardwareBuffer-backed storage (Android).
2504    ///
2505    /// Used by `Tensor::image()` when the caller requests
2506    /// `TensorMemory::DmaBuf` and the format has an AHardwareBuffer format
2507    /// mapping (RGBA8 and the RGBA16F float paths today). Falls back to
2508    /// `new_with_byte_size` otherwise.
2509    #[cfg(target_os = "android")]
2510    pub(crate) fn new_image_ahardwarebuffer(
2511        width: usize,
2512        height: usize,
2513        format: PixelFormat,
2514        dtype: DType,
2515        shape: &[usize],
2516        name: Option<&str>,
2517        access: CpuAccess,
2518    ) -> Result<Self> {
2519        AHardwareBufferTensor::<T>::new_image(width, height, format, dtype, shape, name, access)
2520            .map(TensorStorage::Dma)
2521    }
2522
2523    /// Allocate an image-formatted D3D11 texture storage (Windows).
2524    ///
2525    /// Used by `Tensor::image()` when the caller requests
2526    /// `TensorMemory::DmaBuf`. Unlike the IOSurface and AHardwareBuffer
2527    /// constructors there is no byte-bag fallback: a format with no texture
2528    /// layout is an error, because a texture is the only thing this backing
2529    /// can be.
2530    #[cfg(target_os = "windows")]
2531    pub(crate) fn new_image_d3d11(
2532        width: usize,
2533        height: usize,
2534        format: PixelFormat,
2535        dtype: DType,
2536        shape: &[usize],
2537        name: Option<&str>,
2538        access: CpuAccess,
2539    ) -> Result<Self> {
2540        crate::d3d11::texture::D3d11TextureTensor::<T>::new_image(
2541            width, height, format, dtype, shape, name, access,
2542        )
2543        .map(TensorStorage::Dma)
2544    }
2545
2546    /// Create a new tensor storage using the given file descriptor, shape,
2547    /// and optional name.
2548    #[cfg(unix)]
2549    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2550        #[cfg(target_os = "linux")]
2551        {
2552            use nix::sys::stat::fstat;
2553            use nix::sys::statfs::fstatfs;
2554
2555            let stat = fstat(&fd)?;
2556            let major = major(stat.st_dev);
2557            let minor = minor(stat.st_dev);
2558
2559            if major != 0 {
2560                // Dma and Shm tensors are expected to have major number 0
2561                return Err(Error::UnknownDeviceType(major, minor));
2562            }
2563
2564            // Classify by filesystem magic, never by the st_dev minor.
2565            //
2566            // dma_buf files live on an internal kernel mount (`dma_buf_mnt`,
2567            // created with `kern_mount`) whose superblock draws its device
2568            // number from `get_anon_bdev()` — an IDA allocated first-come,
2569            // first-served during boot, shared with every other anonymous
2570            // pseudo-filesystem (pipefs, sockfs, anon_inodefs, nsfs, bdev,
2571            // tracefs, ...). The minor a DMA-BUF lands on is therefore a
2572            // function of which pseudo-filesystems registered first on that
2573            // kernel build: it moves with kernel config, driver load order,
2574            // initramfs use, and kernel version. It is not part of any ABI.
2575            //
2576            // Observed values for a genuine DMA-BUF: 12 on x86 desktop, 8 on
2577            // the ADIS Verdin. An earlier revision hardcoded `9 | 10` here,
2578            // which silently imported real DMA-BUFs as shared memory
2579            // everywhere else.
2580            //
2581            // The filesystem magic, by contrast, is stable UAPI
2582            // (include/uapi/linux/magic.h). Both branches are identified
2583            // positively; anything else is genuinely unknown and is rejected
2584            // rather than guessed at. Falling back to SHM would "work" — a
2585            // DMA-BUF is mmap-able, and even a pipe imports as a zero-length
2586            // tensor — which is exactly why it must not be the default.
2587            let magic = fs_magic(fstatfs(&fd)?.filesystem_type().0 as i64);
2588
2589            log::debug!(
2590                "Creating tensor from fd: major={major}, minor={minor}, magic={magic:#010x}"
2591            );
2592
2593            match magic {
2594                DMA_BUF_MAGIC => DmaTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Dma),
2595                TMPFS_MAGIC => ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm),
2596                other => Err(Error::UnknownBufferType(other)),
2597            }
2598        }
2599        #[cfg(all(unix, not(target_os = "linux")))]
2600        {
2601            // On macOS/iOS/BSD, always use SHM (no DMA-BUF fd import)
2602            ShmTensor::<T>::from_fd(fd, shape, name).map(TensorStorage::Shm)
2603        }
2604    }
2605}
2606
2607#[cfg(feature = "static")]
2608impl<T> TensorTrait<T> for TensorStorage<T>
2609where
2610    T: Num + Clone + fmt::Debug + Send + Sync,
2611{
2612    fn new(shape: &[usize], name: Option<&str>) -> Result<Self> {
2613        Self::new(shape, None, name)
2614    }
2615
2616    #[cfg(unix)]
2617    fn from_fd(fd: OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self> {
2618        Self::from_fd(fd, shape, name)
2619    }
2620
2621    #[cfg(unix)]
2622    fn clone_fd(&self) -> Result<OwnedFd> {
2623        match self {
2624            #[cfg(any(
2625                target_os = "linux",
2626                target_os = "macos",
2627                target_os = "ios",
2628                target_os = "android"
2629            ))]
2630            TensorStorage::Dma(t) => t.clone_fd(),
2631            TensorStorage::Shm(t) => t.clone_fd(),
2632            TensorStorage::Mem(t) => t.clone_fd(),
2633            TensorStorage::Pbo(t) => t.clone_fd(),
2634        }
2635    }
2636
2637    fn memory(&self) -> TensorMemory {
2638        match self {
2639            #[cfg(any(
2640                target_os = "linux",
2641                target_os = "macos",
2642                target_os = "ios",
2643                target_os = "android",
2644                target_os = "windows"
2645            ))]
2646            TensorStorage::Dma(_) => TensorMemory::DmaBuf,
2647            #[cfg(unix)]
2648            TensorStorage::Shm(_) => TensorMemory::Shm,
2649            TensorStorage::Mem(_) => TensorMemory::Mem,
2650            TensorStorage::Pbo(_) => TensorMemory::Pbo,
2651        }
2652    }
2653
2654    fn name(&self) -> String {
2655        match self {
2656            #[cfg(any(
2657                target_os = "linux",
2658                target_os = "macos",
2659                target_os = "ios",
2660                target_os = "android",
2661                target_os = "windows"
2662            ))]
2663            TensorStorage::Dma(t) => t.name(),
2664            #[cfg(unix)]
2665            TensorStorage::Shm(t) => t.name(),
2666            TensorStorage::Mem(t) => t.name(),
2667            TensorStorage::Pbo(t) => t.name(),
2668        }
2669    }
2670
2671    fn shape(&self) -> &[usize] {
2672        match self {
2673            #[cfg(any(
2674                target_os = "linux",
2675                target_os = "macos",
2676                target_os = "ios",
2677                target_os = "android",
2678                target_os = "windows"
2679            ))]
2680            TensorStorage::Dma(t) => t.shape(),
2681            #[cfg(unix)]
2682            TensorStorage::Shm(t) => t.shape(),
2683            TensorStorage::Mem(t) => t.shape(),
2684            TensorStorage::Pbo(t) => t.shape(),
2685        }
2686    }
2687
2688    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
2689        match self {
2690            #[cfg(any(
2691                target_os = "linux",
2692                target_os = "macos",
2693                target_os = "ios",
2694                target_os = "android",
2695                target_os = "windows"
2696            ))]
2697            TensorStorage::Dma(t) => t.reshape(shape),
2698            #[cfg(unix)]
2699            TensorStorage::Shm(t) => t.reshape(shape),
2700            TensorStorage::Mem(t) => t.reshape(shape),
2701            TensorStorage::Pbo(t) => t.reshape(shape),
2702        }
2703    }
2704
2705    fn capacity_bytes(&self) -> usize {
2706        match self {
2707            #[cfg(any(
2708                target_os = "linux",
2709                target_os = "macos",
2710                target_os = "ios",
2711                target_os = "android",
2712                target_os = "windows"
2713            ))]
2714            TensorStorage::Dma(t) => t.capacity_bytes(),
2715            #[cfg(unix)]
2716            TensorStorage::Shm(t) => t.capacity_bytes(),
2717            TensorStorage::Mem(t) => t.capacity_bytes(),
2718            TensorStorage::Pbo(t) => t.capacity_bytes(),
2719        }
2720    }
2721
2722    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
2723        match self {
2724            #[cfg(any(
2725                target_os = "linux",
2726                target_os = "macos",
2727                target_os = "ios",
2728                target_os = "android",
2729                target_os = "windows"
2730            ))]
2731            TensorStorage::Dma(t) => t.set_logical_shape(shape),
2732            #[cfg(unix)]
2733            TensorStorage::Shm(t) => t.set_logical_shape(shape),
2734            TensorStorage::Mem(t) => t.set_logical_shape(shape),
2735            TensorStorage::Pbo(t) => t.set_logical_shape(shape),
2736        }
2737    }
2738
2739    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2740    where
2741        T: 'a,
2742    {
2743        match self {
2744            #[cfg(any(
2745                target_os = "linux",
2746                target_os = "macos",
2747                target_os = "ios",
2748                target_os = "android",
2749                target_os = "windows"
2750            ))]
2751            TensorStorage::Dma(t) => t.map_with(access),
2752            #[cfg(unix)]
2753            TensorStorage::Shm(t) => t.map_with(access),
2754            TensorStorage::Mem(t) => t.map_with(access),
2755            TensorStorage::Pbo(t) => t.map_with(access),
2756        }
2757    }
2758
2759    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
2760    where
2761        T: 'a,
2762    {
2763        match self {
2764            #[cfg(any(
2765                target_os = "linux",
2766                target_os = "macos",
2767                target_os = "ios",
2768                target_os = "android",
2769                target_os = "windows"
2770            ))]
2771            TensorStorage::Dma(t) => t.try_map_with(access),
2772            #[cfg(unix)]
2773            TensorStorage::Shm(t) => t.try_map_with(access),
2774            TensorStorage::Mem(t) => t.try_map_with(access),
2775            TensorStorage::Pbo(t) => t.try_map_with(access),
2776        }
2777    }
2778
2779    fn buffer_identity(&self) -> &BufferIdentity {
2780        match self {
2781            #[cfg(any(
2782                target_os = "linux",
2783                target_os = "macos",
2784                target_os = "ios",
2785                target_os = "android",
2786                target_os = "windows"
2787            ))]
2788            TensorStorage::Dma(t) => t.buffer_identity(),
2789            #[cfg(unix)]
2790            TensorStorage::Shm(t) => t.buffer_identity(),
2791            TensorStorage::Mem(t) => t.buffer_identity(),
2792            TensorStorage::Pbo(t) => t.buffer_identity(),
2793        }
2794    }
2795
2796    /// Forward a sub-region view to the active backend, re-wrapping the
2797    /// backend's view (which shares the parent's allocation and
2798    /// [`BufferIdentity`]) back into the matching `TensorStorage` variant. Each
2799    /// backend's `view` is its own `TensorTrait::view` override; this single
2800    /// match is the only per-variant dispatch (`Tensor::subview` calls through
2801    /// here rather than matching the storage itself).
2802    fn view(&self, offset_bytes: usize, shape: &[usize]) -> Result<Self> {
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.view(offset_bytes, shape).map(TensorStorage::Dma),
2812            #[cfg(unix)]
2813            TensorStorage::Shm(t) => t.view(offset_bytes, shape).map(TensorStorage::Shm),
2814            TensorStorage::Mem(t) => t.view(offset_bytes, shape).map(TensorStorage::Mem),
2815            TensorStorage::Pbo(t) => t.view(offset_bytes, shape).map(TensorStorage::Pbo),
2816        }
2817    }
2818}
2819
2820/// Multi-backend tensor with optional image format metadata.
2821///
2822/// When `format` is `Some`, this tensor represents an image. Width, height,
2823/// and channels are derived from `shape` + `format`. When `format` is `None`,
2824/// this is a raw tensor (identical to the pre-refactoring behavior).
2825///
2826/// This is the `static` backend's `Tensor<T>` -- it owns its storage. See
2827/// `tensor_dyn/dynamic_tensor.rs` for `dynamic`'s `Tensor<T>`, a typed lens
2828/// with no storage of its own.
2829#[cfg(feature = "static")]
2830#[derive(Debug)]
2831pub struct Tensor<T>
2832where
2833    T: Num + Clone + fmt::Debug + Send + Sync,
2834{
2835    /// CUDA registration for this tensor, if any. Set after creation by
2836    /// the image crate once a PBO is registered with CUDA interop.
2837    ///
2838    /// MUST be declared before `storage`: CUDA must unregister the GL buffer
2839    /// before storage's Drop deletes it (cudaGraphicsUnregisterResource before
2840    /// glDeleteBuffers). Rust drops fields in declaration order.
2841    cuda: Option<crate::cuda::CudaHandle>,
2842    pub(crate) storage: TensorStorage<T>,
2843    format: Option<PixelFormat>,
2844    chroma: Option<Box<Tensor<T>>>,
2845    /// Row stride in bytes for externally allocated buffers with row padding.
2846    /// `None` means tightly packed (stride == width * bytes_per_pixel).
2847    row_stride: Option<usize>,
2848    /// Byte offset within the DMA-BUF where image data starts.
2849    /// `None` means offset 0 (data starts at the beginning of the buffer).
2850    plane_offset: Option<usize>,
2851    /// Quantization metadata for integer-typed tensors. Public access is
2852    /// gated by the `IntegerType` trait — `Tensor<f32>` etc. carry the
2853    /// field for layout uniformity but have no way to read or write it.
2854    pub(crate) quantization: Option<Quantization>,
2855    /// Optional colorimetry metadata. `None` = undefined; never auto-filled.
2856    colorimetry: Option<crate::Colorimetry>,
2857    /// Declared CPU access (see [`CpuAccess`]). Image constructors set it
2858    /// from their `access` parameter; non-image tensors (`new`, imports,
2859    /// numpy) default to `ReadWrite` — they are CPU-centric by nature.
2860    /// `map_with` counts requests beyond this declaration as unplanned.
2861    cpu_access: CpuAccess,
2862    /// Recorded vendor tile-compression scheme — best knowledge from
2863    /// allocation time (see [`Compression`]). `Some` only for Android
2864    /// hardware-only AHardwareBuffers whose allocation requested
2865    /// compression on an eligible format with a recognized vendor. When
2866    /// set, the row-stride accessors describe no meaningful linear
2867    /// layout. `configure_image` preserves it (physical layout, unlike
2868    /// colorimetry); views inherit it.
2869    compression: Option<CompressionScheme>,
2870    /// Parent-image snapshot when this tensor is a [`view`](Self::view)/
2871    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. Lets the GL
2872    /// backend key its import on the parent and render the view as a
2873    /// `glViewport`/`glScissor` ROI. See [`ViewOrigin`].
2874    view_origin: Option<ViewOrigin>,
2875}
2876
2877#[cfg(feature = "static")]
2878impl<T> Tensor<T>
2879where
2880    T: Num + Clone + fmt::Debug + Send + Sync,
2881{
2882    /// Wrap a TensorStorage in a Tensor with no image metadata.
2883    pub(crate) fn wrap(storage: TensorStorage<T>) -> Self {
2884        Self {
2885            storage,
2886            format: None,
2887            chroma: None,
2888            row_stride: None,
2889            plane_offset: None,
2890            quantization: None,
2891            cuda: None,
2892            colorimetry: None,
2893            cpu_access: CpuAccess::ReadWrite,
2894            compression: None,
2895            view_origin: None,
2896        }
2897    }
2898
2899    /// Construct a tensor from a row-major element slice + shape. Allocates a
2900    /// new buffer (`TensorMemory::Mem`) and memcpys the contents; caller
2901    /// retains ownership of the input slice.
2902    ///
2903    /// # Errors
2904    ///
2905    /// - [`Error::InvalidShape`] if `values.len() != shape.iter().product()`.
2906    /// - Propagates any allocation error from [`Self::new`].
2907    pub fn from_slice(values: &[T], shape: &[usize]) -> Result<Self>
2908    where
2909        T: Copy,
2910    {
2911        let expected: usize = shape.iter().product();
2912        if values.len() != expected {
2913            return Err(Error::InvalidShape(format!(
2914                "from_slice: values.len()={} but shape product={expected} (shape={shape:?})",
2915                values.len()
2916            )));
2917        }
2918        let t = Self::new(shape, Some(TensorMemory::Mem), None)?;
2919        {
2920            let mut m = t.map()?;
2921            m.as_mut_slice().copy_from_slice(values);
2922        }
2923        Ok(t)
2924    }
2925
2926    /// Wrap externally-owned memory as a tensor without copying. The tensor
2927    /// borrows `[ptr, ptr + shape.product() * size_of::<T>())` as
2928    /// [`TensorMemory::Mem`]; `owner`, when `Some`, co-owns the source so it
2929    /// outlives the tensor (and all derived views/maps). See [`ForeignOwner`].
2930    ///
2931    /// The canonical use is CUDA zero-copy: allocate host-coherent memory
2932    /// (`cudaHostAlloc`), wrap the host pointer here, and bind the matching
2933    /// device pointer to the inference engine — reads and writes hit the same
2934    /// physical buffer with no host copy. The identical primitive backs the
2935    /// Python `Tensor.from_numpy` zero-copy borrow (owner = the NumPy object).
2936    ///
2937    /// # Safety
2938    ///
2939    /// `ptr` must be non-null, aligned to `align_of::<T>()`, and valid for
2940    /// `shape.product()` elements of `T` for as long as the returned tensor —
2941    /// and every view/map sharing its backing — is alive. Pass an `owner` that
2942    /// co-owns the source to uphold that contract.
2943    ///
2944    /// # Errors
2945    ///
2946    /// [`Error::InvalidSize`] if `shape` is empty.
2947    pub unsafe fn from_foreign(
2948        ptr: *mut T,
2949        shape: &[usize],
2950        owner: Option<crate::ForeignOwner>,
2951        name: Option<&str>,
2952    ) -> Result<Self> {
2953        // SAFETY: forwarded to `from_foreign_with_capacity`'s contract; `0`
2954        // clamps to the tight shape-derived footprint below, i.e. no extra
2955        // headroom -- identical to this function's own prior behaviour.
2956        unsafe { Self::from_foreign_with_capacity(ptr, shape, 0, owner, name) }
2957    }
2958
2959    /// [`Self::from_foreign`], additionally recording that the allocation
2960    /// behind `ptr` holds at least `capacity_bytes` bytes -- headroom beyond
2961    /// the tight `shape.product() * size_of::<T>()` footprint that a later
2962    /// [`configure_image`](Self::configure_image) / `set_logical_shape` can
2963    /// grow into without erroring.
2964    ///
2965    /// This is the consumer half of the cross-package capsule protocol's
2966    /// [`crate::protocol::TensorDesc::capacity`]: a producer's pool tensor
2967    /// (allocated for its largest expected image, holding a smaller one
2968    /// today) reports its real allocation size so the imported alias
2969    /// inherits the same headroom instead of being clamped to today's
2970    /// logical shape. `capacity_bytes` below the tight footprint is clamped
2971    /// up to it, never down -- the shape-implied floor always holds.
2972    ///
2973    /// # Safety
2974    ///
2975    /// Same as [`Self::from_foreign`], except `ptr` must be valid for
2976    /// `capacity_bytes` bytes (or the tight shape footprint, whichever is
2977    /// larger), not just the shape-implied footprint.
2978    ///
2979    /// # Errors
2980    ///
2981    /// [`Error::InvalidSize`] if `shape` is empty.
2982    pub unsafe fn from_foreign_with_capacity(
2983        ptr: *mut T,
2984        shape: &[usize],
2985        capacity_bytes: usize,
2986        owner: Option<crate::ForeignOwner>,
2987        name: Option<&str>,
2988    ) -> Result<Self> {
2989        if shape.is_empty() {
2990            return Err(Error::InvalidSize(0));
2991        }
2992        if ptr.is_null() {
2993            return Err(Error::InvalidArgument(
2994                "from_foreign: ptr must be non-null".to_owned(),
2995            ));
2996        }
2997        shape
2998            .iter()
2999            .copied()
3000            .try_fold(1usize, |acc, dim| acc.checked_mul(dim))
3001            .ok_or_else(|| {
3002                Error::InvalidArgument(format!(
3003                    "from_foreign: shape.product() overflows usize (shape={shape:?})"
3004                ))
3005            })?;
3006        let capacity_elems = capacity_bytes / std::mem::size_of::<T>();
3007        // SAFETY: this function's own contract (see its doc comment) is
3008        // exactly `MemTensor::from_foreign_with_capacity`'s contract.
3009        let mem = unsafe {
3010            MemTensor::<T>::from_foreign_with_capacity(ptr, shape, capacity_elems, owner, name)
3011        };
3012        Ok(Self::wrap(TensorStorage::Mem(mem)))
3013    }
3014
3015    /// Pin a stable host address for this tensor's data.
3016    ///
3017    /// The returned [`HostPin`] carries **no borrow of the tensor**, so a
3018    /// caller can pin once at init and still pass `&mut self` to
3019    /// `ImageProcessor::convert` every frame. That borrow conflict is the
3020    /// blocker reported in [#134](https://github.com/EdgeFirstAI/hal/issues/134).
3021    ///
3022    /// The address stays valid until the pin is dropped, and survives
3023    /// re-layout: `configure_image` and `set_logical_shape` only rewrite the
3024    /// logical shape after a capacity check, and every backend's pin owns its
3025    /// own mapping, so nothing can move underneath it.
3026    ///
3027    /// A pin is therefore a **snapshot**, not a tracker: [`HostPin::len`]
3028    /// reports the extent at pin time and does not follow a later re-layout.
3029    /// That is the safe direction — the window always stays inside the
3030    /// allocation — but it means a caller who re-layouts should re-pin if it
3031    /// wants the new extent.
3032    ///
3033    /// The address is **not** a coherency guarantee: on non-coherent backends
3034    /// bracket CPU access with
3035    /// [`sync_for_cpu`](Self::sync_for_cpu) and
3036    /// [`sync_for_device`](Self::sync_for_device), which are no-ops where the
3037    /// backend does not require them.
3038    ///
3039    /// # Cost
3040    ///
3041    /// On `Mem` this is an `Arc` refcount bump — the backing allocation is
3042    /// never resized, so its base pointer is already stable. Other backends
3043    /// establish a mapping once and reuse it.
3044    ///
3045    /// # Errors
3046    ///
3047    /// - [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`]: a
3048    ///   pinned mapping is CPU access by definition, so this is an error
3049    ///   rather than a warn-and-stage.
3050    /// - [`Error::NotImplemented`] for the two backends that **cannot** pin,
3051    ///   rather than merely not having been wired yet: a PBO
3052    ///   (`glMapBufferRange` establishes the address and the visibility
3053    ///   together, and there is no `GL_MAP_PERSISTENT_BIT` in GLES) and an
3054    ///   AHardwareBuffer (`AHardwareBuffer_lock` is what yields the address at
3055    ///   all, so there is no address outside the lock). Both name
3056    ///   `map()`/`map_with()` as the alternative. `Mem`, `Shm`, DMA-BUF,
3057    ///   IOSurface and the Windows D3D11 texture all pin — the texture through
3058    ///   a persistent host buffer the storage owns, which
3059    ///   [`sync_for_cpu`](Self::sync_for_cpu) and
3060    ///   [`sync_for_device`](Self::sync_for_device) move bytes between and the
3061    ///   texture, rather than through the staging mapping (a pin outlives every
3062    ///   map guard, so holding the staging texture mapped would freeze it).
3063    pub fn pin_host<'a>(&self, access: CpuAccess) -> Result<HostPin<'a>>
3064    where
3065        T: 'a,
3066    {
3067        if access == CpuAccess::None {
3068            return Err(Error::InvalidArgument(
3069                "pin_host: CpuAccess::None is not a valid pin; a pinned mapping \
3070                 is CPU access by definition"
3071                    .to_owned(),
3072            ));
3073        }
3074        // Backends yield everything addressable from the tensor's offset;
3075        // narrow to the logical extent so a consumer cannot mistake pitch
3076        // padding for data. A map guard keeps the wider window.
3077        let logical = self.len() * std::mem::size_of::<T>();
3078        let pin = match &self.storage {
3079            TensorStorage::Mem(m) => Ok(m.host_pin()),
3080            #[cfg(unix)]
3081            TensorStorage::Shm(s) => s.host_pin(),
3082            #[cfg(target_os = "linux")]
3083            TensorStorage::Dma(d) => d.host_pin(),
3084            #[cfg(any(target_os = "macos", target_os = "ios"))]
3085            TensorStorage::Dma(s) => s.host_pin(),
3086            // Windows D3D11: the pin is the storage's own persistent host
3087            // buffer, not the staging mapping -- see `D3d11TextureTensor::host_pin`.
3088            // The direction goes with it: a writable pin is what tells
3089            // `sync_for_device` the host buffer, not the staging copy, carries
3090            // the caller's writes.
3091            #[cfg(target_os = "windows")]
3092            TensorStorage::Dma(t) => t.host_pin(access),
3093            // AHardwareBuffer cannot support a pin at all, and that is a
3094            // platform contract rather than missing work: the NDK exposes no
3095            // way to obtain a CPU address outside AHardwareBuffer_lock, so a
3096            // "persistent" address would mean holding the lock for the pin's
3097            // lifetime -- which blocks the GPU from the buffer and makes any
3098            // further lock undefined. Both are the opposite of what a pin is
3099            // for. Say so, rather than implying a TODO.
3100            #[cfg(target_os = "android")]
3101            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3102                "pin_host: AHardwareBuffer has no host address outside \
3103                 AHardwareBuffer_lock, so it cannot hand out one that outlives \
3104                 a guard. Use map()/map_with() for CPU access; for zero-copy \
3105                 handoff pass the buffer itself."
3106                    .to_owned(),
3107            )),
3108            // PBO is the same shape of constraint as AHardwareBuffer. The
3109            // message is a shared constant because `TensorDyn::pin_host`
3110            // (`tensor_dyn/dynamic_backend.rs`) must repeat this refusal --
3111            // the ABI has `ef_tensor_map` and no `ef_tensor_pin_host`, so the
3112            // library cannot tell a pin from a map. See
3113            // `crate::pbo::PIN_HOST_PBO_REFUSAL` for the reasoning at length.
3114            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3115                crate::pbo::PIN_HOST_PBO_REFUSAL.to_owned(),
3116            )),
3117        }?;
3118        Ok(pin.narrowed(logical))
3119    }
3120
3121    /// Make CPU reads/writes of this tensor coherent — the CPU is about to
3122    /// access the buffer.
3123    ///
3124    /// Corresponds to `DMA_BUF_SYNC_START`, and the name matches the Linux
3125    /// kernel DMA API for the same reason: `for_cpu` says who is about to
3126    /// touch the memory, which is what the caller actually knows.
3127    ///
3128    /// Pair with [`sync_for_device`](Self::sync_for_device).
3129    ///
3130    /// # Which backends owe maintenance
3131    ///
3132    /// | backend | behaviour |
3133    /// |---|---|
3134    /// | `Mem`, `Shm` | **no-op** — plain CPU memory, coherent by construction |
3135    /// | `Dma` (Linux DMA-BUF) | one `DMA_BUF_IOCTL_SYNC`, direction-tagged |
3136    /// | `Dma` (macOS/iOS IOSurface) | `IOSurfaceLock` / `IOSurfaceUnlock`, options matched to `access` |
3137    /// | `Dma` (Android AHardwareBuffer) | [`Error::NotImplemented`] — no sync independent of the lock |
3138    /// | `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 |
3139    /// | `Pbo` | [`Error::NotImplemented`] — no coherency window independent of the map |
3140    ///
3141    /// Only the first row is a no-op, so "bracket unconditionally and pay
3142    /// nothing" holds for `Mem`/`Shm` alone. The two that report
3143    /// `NotImplemented` do so because their platform fuses addressing and
3144    /// coherency into one call — `AHardwareBuffer_lock` and
3145    /// `glMapBufferRange` each perform the maintenance *as* they hand out the
3146    /// address, so a separate bracket would have to nest inside a map guard
3147    /// that already owns the pair. Both name `map()`/`map_with()` as the
3148    /// alternative. They previously fell through a wildcard and answered
3149    /// `Ok(())`, which is worse than an error because the symptom is
3150    /// stale-but-plausible pixels rather than a failure. The match is
3151    /// exhaustive so a new backend cannot inherit that silence — it must state
3152    /// its coherency story to compile.
3153    ///
3154    /// # Cost
3155    ///
3156    /// `Mem`/`Shm`: nothing. `Dma` on Linux: one ioctl. IOSurface: one
3157    /// lock/unlock pair. Windows D3D11 texture: one `CopyResource` plus a
3158    /// staging map per direction, and nothing at all on a tensor no one has
3159    /// pinned.
3160    ///
3161    /// # Errors
3162    ///
3163    /// [`Error::InvalidArgument`] when `access` is [`CpuAccess::None`], which
3164    /// describes no access to make coherent.
3165    pub fn sync_for_cpu(&self, access: CpuAccess) -> Result<()> {
3166        Self::check_sync_access(access)?;
3167        match &self.storage {
3168            // Plain CPU memory: coherent by construction.
3169            TensorStorage::Mem(_) => Ok(()),
3170            #[cfg(unix)]
3171            TensorStorage::Shm(_) => Ok(()),
3172            #[cfg(target_os = "linux")]
3173            TensorStorage::Dma(d) => {
3174                // The direction tells the kernel which maintenance this access
3175                // needs: a read-only bracket skips the writeback at END, a
3176                // write-only one skips the invalidate at START. It MUST match
3177                // the direction passed to sync_for_device.
3178                crate::dmabuf::sync_access(&d.fd, true, access).map_err(Error::NixError)
3179            }
3180            #[cfg(any(target_os = "macos", target_os = "ios"))]
3181            TensorStorage::Dma(s) => s.sync_for_cpu(access),
3182            // Windows D3D11: refreshes the pinned host buffer from the texture
3183            // through the staging copy.
3184            #[cfg(target_os = "windows")]
3185            TensorStorage::Dma(t) => t.sync_for_cpu(access),
3186            #[cfg(target_os = "android")]
3187            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3188                "sync_for_cpu: AHardwareBuffer has no sync independent of its \
3189                 lock. AHardwareBuffer_lock both establishes the address and \
3190                 performs the cache invalidate, and the NDK leaves locking an \
3191                 already-locked buffer undefined, so a bracket on top of a map \
3192                 guard would nest. Use map()/map_with(), which owns the pair."
3193                    .to_owned(),
3194            )),
3195            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3196                "sync_for_cpu: a PBO has no coherency window independent of its \
3197                 map -- glMapBufferRange establishes the address and the \
3198                 visibility together, and glUnmapBuffer publishes the writes. \
3199                 Use map()/map_with(), which owns the pair."
3200                    .to_owned(),
3201            )),
3202        }
3203    }
3204
3205    /// Release the buffer back to the device — the CPU is done accessing it.
3206    ///
3207    /// Corresponds to `DMA_BUF_SYNC_END`. See
3208    /// [`sync_for_cpu`](Self::sync_for_cpu) for the pairing and cost.
3209    pub fn sync_for_device(&self, access: CpuAccess) -> Result<()> {
3210        Self::check_sync_access(access)?;
3211        match &self.storage {
3212            TensorStorage::Mem(_) => Ok(()),
3213            #[cfg(unix)]
3214            TensorStorage::Shm(_) => Ok(()),
3215            #[cfg(target_os = "linux")]
3216            TensorStorage::Dma(d) => {
3217                crate::dmabuf::sync_access(&d.fd, false, access).map_err(Error::NixError)
3218            }
3219            #[cfg(any(target_os = "macos", target_os = "ios"))]
3220            TensorStorage::Dma(s) => s.sync_for_device(access),
3221            // Windows D3D11: uploads the pinned host buffer to the texture.
3222            #[cfg(target_os = "windows")]
3223            TensorStorage::Dma(t) => t.sync_for_device(access),
3224            #[cfg(target_os = "android")]
3225            TensorStorage::Dma(_) => Err(Error::NotImplemented(
3226                "sync_for_device: AHardwareBuffer has no sync independent of \
3227                 its lock — see sync_for_cpu. Use map()/map_with()."
3228                    .to_owned(),
3229            )),
3230            TensorStorage::Pbo(_) => Err(Error::NotImplemented(
3231                "sync_for_device: a PBO has no coherency window independent of \
3232                 its map -- see sync_for_cpu. Use map()/map_with()."
3233                    .to_owned(),
3234            )),
3235        }
3236    }
3237
3238    fn check_sync_access(access: CpuAccess) -> Result<()> {
3239        if access == CpuAccess::None {
3240            return Err(Error::InvalidArgument(
3241                "sync_for_cpu/sync_for_device: CpuAccess::None describes no \
3242                 access to synchronise"
3243                    .to_owned(),
3244            ));
3245        }
3246        Ok(())
3247    }
3248
3249    /// Construct a tensor from a 3-D ndarray view. Respects strides — one
3250    /// copy in all cases; contiguous views take a memcpy fast path.
3251    ///
3252    /// Only available when the `ndarray` feature is enabled.
3253    #[cfg(feature = "ndarray")]
3254    pub fn from_arrayview3(view: ndarray::ArrayView3<'_, T>) -> Result<Self>
3255    where
3256        T: Copy,
3257    {
3258        let (h, w, c) = view.dim();
3259        let t = Self::new(&[h, w, c], Some(TensorMemory::Mem), None)?;
3260        {
3261            let mut m = t.map()?;
3262            let dst = m.as_mut_slice();
3263            if let Some(src) = view.as_slice() {
3264                dst.copy_from_slice(src);
3265            } else {
3266                for (d, &s) in dst.iter_mut().zip(view.iter()) {
3267                    *d = s;
3268                }
3269            }
3270        }
3271        Ok(t)
3272    }
3273
3274    /// Create a new tensor with the given shape, memory type, and optional
3275    /// name. If no name is given, a random name will be generated. If no
3276    /// memory type is given, the best available memory type will be chosen
3277    /// based on the platform and environment variables.
3278    ///
3279    /// On Linux platforms, the order of preference is: Dma -> Shm -> Mem.
3280    /// On other Unix platforms (macOS), the order is: Shm -> Mem.
3281    /// On non-Unix platforms, only Mem is available.
3282    ///
3283    /// # Environment Variables
3284    /// - `EDGEFIRST_TENSOR_FORCE_MEM`: If set to a non-zero and non-false
3285    ///   value, forces the use of regular system memory allocation
3286    ///   (`TensorMemory::Mem`) regardless of platform capabilities.
3287    ///
3288    /// # Example
3289    /// ```rust
3290    /// use edgefirst_tensor::{Error, Tensor, TensorMemory, TensorTrait};
3291    /// # fn main() -> Result<(), Error> {
3292    /// let tensor = Tensor::<f32>::new(&[2, 3, 4], Some(TensorMemory::Mem), Some("test_tensor"))?;
3293    /// assert_eq!(tensor.memory(), TensorMemory::Mem);
3294    /// assert_eq!(tensor.name(), "test_tensor");
3295    /// #    Ok(())
3296    /// # }
3297    /// ```
3298    pub fn new(shape: &[usize], memory: Option<TensorMemory>, name: Option<&str>) -> Result<Self> {
3299        let _span = tracing::trace_span!(
3300            "tensor.alloc",
3301            ?shape,
3302            memory = ?memory,
3303            dtype = std::any::type_name::<T>(),
3304        )
3305        .entered();
3306        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
3307        let mut t = TensorStorage::new(shape, memory, name).map(Self::wrap)?;
3308        // Best-effort: attach a CUDA ExternalMemory handle for DMA tensors on
3309        // CUDA-capable hosts. Never blocks tensor creation on failure.
3310        // RUNTIME-UNVALIDATED: no CUDA+dma_heap test platform available; ABI
3311        // layout-asserted vs. CUDA 12.6 driver_types.h; mechanism proven by
3312        // gpu-probe O5 on Orin.
3313        #[cfg(target_os = "linux")]
3314        t.try_init_dma_cuda();
3315        Ok(t)
3316    }
3317
3318    /// Create an image tensor with the given format.
3319    /// Allocate an image tensor from a declarative request — the
3320    /// full-featured constructor behind [`Self::image`] and friends.
3321    ///
3322    /// Adds the [`Compression`] request to the classic parameters:
3323    ///
3324    /// - a request with any CPU access other than [`CpuAccess::None`] is
3325    ///   [`Error::InvalidArgument`] (CPU mapping pins the layout linear);
3326    /// - [`Compression::Scheme`] fails with [`Error::NotImplemented`] on
3327    ///   platforms without vendor tile compression, and with
3328    ///   [`Error::InvalidArgument`] when the device's native scheme or
3329    ///   the format eligibility doesn't match;
3330    /// - [`Compression::Any`] never fails for compression reasons: it
3331    ///   records the scheme when the allocation is eligible and
3332    ///   otherwise resolves linear, incrementing
3333    ///   [`compression_fallback_count`].
3334    ///
3335    /// The recorded outcome is readable via [`Tensor::compression`].
3336    pub fn image_desc(desc: &ImageDesc) -> Result<Self>
3337    where
3338        T: 'static,
3339    {
3340        let Some(t_dtype) = dtype_of::<T>() else {
3341            return Err(Error::InvalidArgument(
3342                "image_desc: element type has no DType mapping".into(),
3343            ));
3344        };
3345        if t_dtype != desc.dtype {
3346            return Err(Error::InvalidArgument(format!(
3347                "image_desc: desc.dtype is {:?} but the tensor element type is {t_dtype:?}",
3348                desc.dtype
3349            )));
3350        }
3351
3352        // Compression-request guards. CPU access pins the layout linear,
3353        // so a request combined with any declared access is a
3354        // contradiction the caller should hear about immediately.
3355        if desc.compression.is_some() && desc.access != CpuAccess::None {
3356            return Err(Error::InvalidArgument(format!(
3357                "image_desc: a compression request requires CpuAccess::None                  (declared {:?}) — CPU mapping pins the layout linear",
3358                desc.access
3359            )));
3360        }
3361        if let Some(Compression::Scheme(requested)) = desc.compression {
3362            #[cfg(not(target_os = "android"))]
3363            {
3364                return Err(Error::NotImplemented(format!(
3365                    "image_desc: Compression::Scheme({requested:?}) — no vendor tile                      compression on this platform (request Compression::Any for a                      portable fallback)"
3366                )));
3367            }
3368            #[cfg(target_os = "android")]
3369            {
3370                if matches!(
3371                    desc.memory,
3372                    Some(TensorMemory::Mem) | Some(TensorMemory::Shm) | Some(TensorMemory::Pbo)
3373                ) {
3374                    return Err(Error::InvalidArgument(format!(
3375                        "image_desc: Compression::Scheme({requested:?}) requires                          hardware memory (TensorMemory::DmaBuf or auto-select), got {:?}",
3376                        desc.memory
3377                    )));
3378                }
3379                if !crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype) {
3380                    return Err(Error::InvalidArgument(format!(
3381                        "image_desc: ({:?}, {:?}) is not compression-eligible                          (RGBA8888 u8/i8 initially)",
3382                        desc.format, desc.dtype
3383                    )));
3384                }
3385                let device = crate::ahardwarebuffer::device_compression_scheme();
3386                if device != Some(requested) {
3387                    return Err(Error::InvalidArgument(format!(
3388                        "image_desc: Compression::Scheme({requested:?}) but the device's                          native scheme is {device:?}"
3389                    )));
3390                }
3391            }
3392        }
3393
3394        // A compression request implies a hardware pipeline, so auto
3395        // memory promotes to the platform's zero-copy allocation first
3396        // (`Tensor::image` only takes the AHardwareBuffer/IOSurface path
3397        // under an explicit Dma request). `Scheme` propagates the Dma
3398        // failure — the caller demanded a layout only that allocator can
3399        // produce; `Any` falls back to plain auto-select.
3400        #[allow(unused_mut)]
3401        let mut t = match (desc.memory, desc.compression) {
3402            (None, Some(request)) => {
3403                match Self::image(
3404                    desc.width,
3405                    desc.height,
3406                    desc.format,
3407                    Some(TensorMemory::DmaBuf),
3408                    desc.access,
3409                ) {
3410                    Ok(t) => t,
3411                    Err(e) if matches!(request, Compression::Scheme(_)) => return Err(e),
3412                    Err(_) => Self::image(desc.width, desc.height, desc.format, None, desc.access)?,
3413                }
3414            }
3415            (memory, _) => Self::image(desc.width, desc.height, desc.format, memory, desc.access)?,
3416        };
3417
3418        // Record best knowledge / count fallbacks. Only an Android
3419        // hardware-only AHardwareBuffer allocation can actually hold a
3420        // vendor tile layout; everywhere else an Any request resolves
3421        // linear and is counted.
3422        if let Some(request) = desc.compression {
3423            #[cfg(target_os = "android")]
3424            {
3425                let eligible =
3426                    crate::ahardwarebuffer_layout::compression_eligible(desc.format, desc.dtype);
3427                let scheme = crate::ahardwarebuffer::device_compression_scheme();
3428                let is_ahb = t.memory() == TensorMemory::DmaBuf;
3429                match (request, scheme) {
3430                    (_, Some(s)) if eligible && is_ahb => {
3431                        t.set_compression_unchecked(Some(s));
3432                    }
3433                    (Compression::Scheme(requested), _) => {
3434                        // Pre-validated eligible + scheme match, so the only
3435                        // way here is the allocation resolving off-AHB.
3436                        return Err(Error::InvalidOperation(format!(
3437                            "image_desc: Compression::Scheme({requested:?}) requested but                              the allocation resolved to {:?} (not an AHardwareBuffer)",
3438                            t.memory()
3439                        )));
3440                    }
3441                    (Compression::Any, _) => {
3442                        note_compression_fallback(&format!(
3443                            "({:?}, {:?}) {}x{}: eligible={eligible}, scheme={scheme:?},                              memory={:?}",
3444                            desc.format,
3445                            desc.dtype,
3446                            desc.width,
3447                            desc.height,
3448                            t.memory()
3449                        ));
3450                    }
3451                }
3452            }
3453            #[cfg(not(target_os = "android"))]
3454            {
3455                debug_assert!(matches!(request, Compression::Any));
3456                let _ = request;
3457                note_compression_fallback(&format!(
3458                    "({:?}, {:?}) {}x{}: no vendor tile compression on this platform",
3459                    desc.format, desc.dtype, desc.width, desc.height
3460                ));
3461            }
3462        }
3463        Ok(t)
3464    }
3465
3466    /// Allocate an image tensor of `width` × `height` in `format`.
3467    ///
3468    /// This is the common-case image constructor. The shape follows from the
3469    /// format's layout — packed `[H, W, C]`, planar `[C, H, W]`, semi-planar
3470    /// `[H + chroma_rows, W]` — and the format is recorded on the tensor, so
3471    /// the image crate and the codec can work from it without out-of-band
3472    /// parameters.
3473    ///
3474    /// `memory` requests a backend, or `None` to auto-select. Auto-select for
3475    /// images is **DMA → Mem**: unlike [`Tensor::new`] it does not try SHM,
3476    /// which offers an image nothing that Mem doesn't (it is not
3477    /// GPU-importable, and Mem always succeeds). Ask for
3478    /// [`TensorMemory::Shm`] explicitly if you need it.
3479    ///
3480    /// `access` declares what the **CPU** intends to do with the buffer and is
3481    /// required; see [`CpuAccess`]. Hardware access — GPU, NPU, ISP, codec —
3482    /// is always implied and never declared. Prefer the narrowest declaration
3483    /// that fits: `None` keeps the allocation eligible for Android vendor tile
3484    /// compression, and anything else pins the layout linear.
3485    ///
3486    /// DMA-backed images always get a **64-byte-aligned row stride**, in every
3487    /// layout, because Mali and Vivante reject an EGLImage import at an
3488    /// unaligned pitch. Read the real pitch back with
3489    /// [`Tensor::effective_row_stride`] rather than assuming `width × bpp`.
3490    ///
3491    /// For a compression request or any other less common knob, build an
3492    /// [`ImageDesc`] and call [`Tensor::image_desc`].
3493    ///
3494    /// # Errors
3495    ///
3496    /// - [`Error::InvalidArgument`] if `width` × `height` is not a valid size
3497    ///   for `format` (for example odd dimensions where the format's chroma
3498    ///   subsampling forbids them), or, on macOS with an explicit
3499    ///   [`TensorMemory::DmaBuf`], if the format cannot be expressed as an
3500    ///   image-formatted IOSurface at this width. The message names the
3501    ///   aligned width to use.
3502    /// - Whatever the chosen backend returns if the allocation itself fails —
3503    ///   for an explicit `memory` request there is no fallback.
3504    ///
3505    /// # Example
3506    ///
3507    /// ```rust
3508    /// use edgefirst_tensor::{CpuAccess, PixelFormat, Tensor, TensorMemory};
3509    ///
3510    /// # fn main() -> Result<(), edgefirst_tensor::Error> {
3511    /// // A camera frame the GPU converts and the CPU never touches.
3512    /// let frame = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12,
3513    ///                                 Some(TensorMemory::Mem), CpuAccess::None)?;
3514    /// assert_eq!(frame.format(), Some(PixelFormat::Nv12));
3515    /// # Ok(())
3516    /// # }
3517    /// ```
3518    pub fn image(
3519        width: usize,
3520        height: usize,
3521        format: PixelFormat,
3522        memory: Option<TensorMemory>,
3523        access: CpuAccess,
3524    ) -> Result<Self>
3525    where
3526        T: 'static,
3527    {
3528        // Shape comes from the shared `PixelFormat::allocation_shape` helper (packed /
3529        // planar / semi-planar NV12·NV16). NV12 supports odd dimensions via the
3530        // `H + ceil(H/2)` combined-plane height.
3531        // The `T: 'static` bound is required by the macOS IOSurface path below.
3532        let shape = format.allocation_shape(width, height).ok_or_else(|| {
3533            Error::InvalidArgument(format!(
3534                "invalid dimensions {width}x{height} for format {format:?}"
3535            ))
3536        })?;
3537
3538        // macOS Dma path: allocate a format-aware IOSurface (FourCC +
3539        // 2D dimensions) so the GL backend can bind it via
3540        // `EGL_ANGLE_iosurface_client_buffer`. Without this, the IOSurface
3541        // would default to a generic byte buffer (FourCC 'L008') and
3542        // ANGLE would reject the import with `EGL_BAD_ATTRIBUTE`.
3543        //
3544        // Guard: IOSurface rounds `bytes_per_row` up to 64-byte alignment.
3545        // If the natural row pitch (`width * channels * sizeof(T)`) is not
3546        // already 64-byte aligned, the padded allocation cannot be mapped
3547        // as a contiguous packed tensor — CPU reads/writes would use the
3548        // wrong stride.
3549        //
3550        // Explicit-Dma contract: when the caller passes
3551        // `Some(TensorMemory::DmaBuf)` they have asked for an
3552        // **image-formatted IOSurface**. Silently downgrading to the
3553        // generic 'L008' byte-bag when alignment fails buries the
3554        // mismatch — the caller only finds out hours later when ANGLE
3555        // (or any GL importer) rejects the bind with
3556        // `EGL_BAD_ATTRIBUTE`. Same anti-pattern bit us previously on
3557        // Mali GPUs with DMA-BUF padding. The right behaviour is to
3558        // fail loudly here with the alignment requirement spelled out
3559        // so the caller can either pick aligned dimensions, request
3560        // SHM/Mem explicitly, or pass `memory=None` for auto-select.
3561        #[cfg(any(target_os = "macos", target_os = "ios"))]
3562        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3563            // For planar formats the IOSurface stacks channels
3564            // vertically (channels * height rows), so the row stride is
3565            // single-channel width * sizeof(T). Packed formats keep the
3566            // natural width * channels * sizeof(T) stride.
3567            let natural_row_bytes = match format.layout() {
3568                PixelLayout::Planar => width * std::mem::size_of::<T>(),
3569                _ => width * format.channels() * std::mem::size_of::<T>(),
3570            };
3571            // A format with a real IOSurface FourCC (RGBA/BGRA/YUYV packed,
3572            // GREY/NV12/NV16/NV24 as R8) tolerates a non-64-aligned natural
3573            // pitch: the surface is allocated with its own 64-aligned
3574            // `bytes_per_row`, the tensor records that stride below, and a CPU
3575            // map iterates rows correctly via the strided-map path while the GL
3576            // import uses the surface's pitch directly — fully zero-copy.
3577            // Planar (the F16 RGBA16F packing) is consumed flat as
3578            // `[1, C, H, W]` with no stride, so it still requires an aligned
3579            // pitch; and formats without a FourCC would fall through to a
3580            // generic byte-bag GL can't bind. Both fail loudly rather than
3581            // silently downgrade.
3582            let has_image_fourcc = dtype_of::<T>()
3583                .and_then(|dt| crate::iosurface_layout::image_iosurface_layout(format, dt))
3584                .is_some();
3585            let padded_ok = has_image_fourcc && format.layout() != PixelLayout::Planar;
3586            if !natural_row_bytes.is_multiple_of(64) && !padded_ok {
3587                let elem_size = std::mem::size_of::<T>();
3588                let per_pixel_bytes = match format.layout() {
3589                    PixelLayout::Planar => elem_size.max(1),
3590                    _ => format.channels().max(1) * elem_size.max(1),
3591                };
3592                // Compute the next 64-byte-aligned width by rounding the
3593                // natural row pitch up to the next multiple of 64 and
3594                // dividing back by per-pixel bytes. This handles every
3595                // `per_pixel_bytes` value correctly:
3596                //
3597                //   * Divisors of 64 (1/2/4/8/16/32/64) → the suggestion
3598                //     is always 64-byte aligned.
3599                //   * Non-divisors of 64 (e.g. RGB u8 with 3 B/pixel) →
3600                //     the next aligned row pitch may not be an integer
3601                //     multiple of per_pixel_bytes (3 doesn't divide 64
3602                //     in any way), so a "pad width to N" suggestion is
3603                //     structurally impossible — omit the suggestion
3604                //     instead of printing a wrong number.
3605                //   * per_pixel_bytes > 64 → same situation, also
3606                //     omitted; the previous formula divided by zero.
3607                //
3608                // The error always names the alignment requirement
3609                // verbatim and lists the two non-DMA alternatives so
3610                // the caller has at least one always-applicable fix.
3611                let aligned_row_bytes = natural_row_bytes.next_multiple_of(64);
3612                let pad_hint =
3613                    if per_pixel_bytes > 0 && aligned_row_bytes.is_multiple_of(per_pixel_bytes) {
3614                        let w = aligned_row_bytes / per_pixel_bytes;
3615                        format!("Pad width to {w} (the next 64-byte-aligned stride), ")
3616                    } else {
3617                        String::new()
3618                    };
3619                return Err(Error::InvalidArgument(format!(
3620                    "Tensor::image: {format:?} {width}x{height} with element \
3621                     size {elem_size} produces a {natural_row_bytes}-byte natural \
3622                     row pitch, which is not 64-byte aligned. \
3623                     IOSurface rounds bytes_per_row up to 64 bytes, so a \
3624                     contiguous CPU map of this tensor would read garbage. \
3625                     {pad_hint}pass memory=None to auto-fall-back to SHM, or \
3626                     pass memory=Some(TensorMemory::Shm) or \
3627                     Some(TensorMemory::Mem) explicitly."
3628                )));
3629            }
3630            // Alignment OK. Explicit-Dma contract: the caller asked for an
3631            // **image-formatted, GL-importable** IOSurface, so every failure
3632            // from here on is loud. The old behaviour fell through to the
3633            // generic 'L008' byte-bag `Some(other)` arm below, and the caller
3634            // only found out when the GL import rejected the bind with
3635            // `EGL_BAD_ATTRIBUTE` — the same silent-downgrade anti-pattern as
3636            // the alignment case above. (Semi-planar/Grey u8 map to 'L008'
3637            // *by design* in `image_iosurface_layout` — the R8-plane
3638            // representation the YUV shaders sample — so they return through
3639            // the mapped path and never reach these errors.)
3640            let dtype = dtype_of::<T>().ok_or_else(|| {
3641                Error::InvalidArgument(format!(
3642                    "Tensor::image: element type {} has no DType, so no \
3643                     image-formatted IOSurface exists. Pass memory=None or \
3644                     Some(TensorMemory::Mem) for a CPU tensor.",
3645                    std::any::type_name::<T>()
3646                ))
3647            })?;
3648            if crate::iosurface_layout::image_iosurface_layout(format, dtype).is_none() {
3649                return Err(Error::InvalidArgument(format!(
3650                    "Tensor::image: no zero-copy IOSurface mapping exists for \
3651                     {format:?}/{dtype:?} on macOS/iOS (supported: \
3652                     Rgba/Rgb @ U8/I8, Bgra/Yuyv/Grey/Nv12/Nv16/Nv24 @ U8, \
3653                     Rgba/PlanarRgb/PlanarRgba @ F16). Pass memory=None to \
3654                     auto-select, or Some(TensorMemory::Mem) explicitly, for \
3655                     a CPU tensor."
3656                )));
3657            }
3658            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3659            // reject a width the texel packing cannot express up front
3660            // (mirrors the Android pre-guard).
3661            if format == PixelFormat::Rgb
3662                && matches!(dtype, DType::U8 | DType::I8)
3663                && packed_rgb888_layout(width, height).is_none()
3664            {
3665                return Err(Error::InvalidArgument(format!(
3666                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3667                     IOSurface packing (got width={width}). Pad the width, or pass \
3668                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3669                )));
3670            }
3671            let storage = TensorStorage::<T>::new_image_iosurface(
3672                width, height, format, dtype, &shape, None,
3673            )?;
3674            let mut t = Self::wrap(storage);
3675            t.format = Some(format);
3676            // IOSurface rounds `bytes_per_row` up to 64 bytes. When that
3677            // pitch exceeds the natural packed/planar row stride, record
3678            // it so CPU consumers iterate rows correctly (the GL import
3679            // already uses the surface's own pitch). For 64-aligned rows
3680            // — the common model-input case — the two match and no stride
3681            // is stored, leaving the flat mapping unchanged.
3682            if let TensorStorage::Dma(ref io) = t.storage {
3683                let bpr = io.bytes_per_row();
3684                if let Some(natural) = t.effective_row_stride() {
3685                    if bpr > natural {
3686                        t.set_row_stride_unchecked(bpr);
3687                    }
3688                }
3689            }
3690            t.cpu_access = access;
3691            return Ok(t);
3692        }
3693
3694        // Android Dma path: allocate a format-aware AHardwareBuffer so the
3695        // GL backend can import it as an EGLImage
3696        // (`eglGetNativeClientBufferANDROID` → `eglCreateImageKHR`).
3697        //
3698        // Geometry: gralloc chooses the row pitch (`desc.stride`) at
3699        // allocation, and pads freely (validated on the Galaxy S26 Ultra,
3700        // where SnapAlloc pads the planar-F16 RGBA16F surface). A padded
3701        // pitch is recorded on the tensor so CPU maps iterate rows via the
3702        // strided-map path; the GL render uses the buffer's own pitch
3703        // through the EGLImage either way, so the GPU path stays fully
3704        // zero-copy. Consumers needing a FLAT layout (the future NPU
3705        // handoff's `[1, C, H, W]` contract) must check `row_stride()` and
3706        // repack when set — flatness is a per-device property here, unlike
3707        // macOS where IOSurface's 64-BYTE alignment keeps model-sized F16
3708        // surfaces naturally flat.
3709        #[cfg(target_os = "android")]
3710        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3711            // Explicit-Dma contract (mirrors the macOS block above): the
3712            // caller asked for an image-formatted, GL-importable
3713            // AHardwareBuffer, so an unmapped combination or a gralloc
3714            // refusal errors here instead of falling through to a BLOB
3715            // byte-bag the GL backend can never import — that downgrade
3716            // only surfaced as a silent per-frame CPU upload.
3717            let dtype = dtype_of::<T>().ok_or_else(|| {
3718                Error::InvalidArgument(format!(
3719                    "Tensor::image: element type {} has no DType, so no \
3720                     image-formatted AHardwareBuffer exists. Pass memory=None \
3721                     or Some(TensorMemory::Mem) for a CPU tensor.",
3722                    std::any::type_name::<T>()
3723                ))
3724            })?;
3725            // Planar F16 requires width % 4 == 0 for the RGBA16F
3726            // packing — reject up front with the requirement spelled
3727            // out (mirrors the macOS pre-allocation guard) instead of
3728            // falling through to a byte-bag GL cannot bind.
3729            if format.layout() == PixelLayout::Planar
3730                && dtype == DType::F16
3731                && packed_rgba16f_layout(format, dtype, width, height).is_none()
3732            {
3733                return Err(Error::InvalidArgument(format!(
3734                    "Tensor::image: {format:?} F16 requires width%4==0 for the RGBA16F \
3735                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3736                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3737                )));
3738            }
3739            // Packed RGB u8/i8 rides an RGBA8888 surface at (W*3/4, H) —
3740            // the same whole-texel constraint as the F16 packing above.
3741            if format == PixelFormat::Rgb
3742                && matches!(dtype, DType::U8 | DType::I8)
3743                && packed_rgb888_layout(width, height).is_none()
3744            {
3745                return Err(Error::InvalidArgument(format!(
3746                    "Tensor::image: Rgb {dtype:?} requires width%4==0 for the RGBA8888 \
3747                     AHardwareBuffer packing (got width={width}). Pad the width, or pass \
3748                     memory=Some(TensorMemory::Mem) for a CPU tensor."
3749                )));
3750            }
3751            if crate::ahardwarebuffer::image_ahardwarebuffer_layout(format, dtype).is_none() {
3752                return Err(Error::InvalidArgument(format!(
3753                    "Tensor::image: no zero-copy AHardwareBuffer mapping exists \
3754                     for {format:?}/{dtype:?} on Android (Grey/NV* need \
3755                     R8-format buffers, API 29+; the HAL floor is API 26 — see \
3756                     `image_ahardwarebuffer_layout`). Pass memory=None to \
3757                     auto-select, or Some(TensorMemory::Mem) explicitly, for a \
3758                     CPU tensor; camera NV12 stays zero-copy by wrapping the \
3759                     camera's own AHardwareBuffer instead of allocating one."
3760                )));
3761            }
3762            let storage = TensorStorage::<T>::new_image_ahardwarebuffer(
3763                width, height, format, dtype, &shape, None, access,
3764            )?;
3765            let mut t = Self::wrap(storage);
3766            t.format = Some(format);
3767            if let TensorStorage::Dma(ref ahb) = t.storage {
3768                // gralloc chooses the row pitch (Qualcomm's
3769                // SnapAlloc pads e.g. the 160-px-wide RGBA16F
3770                // surface of a 640-wide planar F16 target).
3771                // When it exceeds the natural stride, record
3772                // it so CPU consumers iterate rows correctly
3773                // via `effective_row_stride()` — the GPU
3774                // renders through the EGLImage at the
3775                // buffer's real pitch regardless, so the
3776                // render stays fully zero-copy. Consumers
3777                // that need the FLAT `[1, C, H, W]` layout
3778                // (the future NPU handoff) must check
3779                // `row_stride()` is unset and repack — or
3780                // pick an aligned width — rather than assume
3781                // flatness (see the module docs).
3782                let bpr = ahb.bytes_per_row();
3783                if let Some(natural) = t.effective_row_stride() {
3784                    if bpr > natural {
3785                        log::debug!(
3786                            "Tensor::image: gralloc padded the {format:?} \
3787                             AHardwareBuffer pitch to {bpr} bytes (natural \
3788                             {natural}); recording row stride"
3789                        );
3790                        t.set_row_stride_unchecked(bpr);
3791                    }
3792                }
3793            }
3794            t.cpu_access = access;
3795            return Ok(t);
3796        }
3797
3798        // Windows Dma path: allocate an `ID3D11Texture2D` on the process
3799        // device so the GL backend can bind it through ANGLE's D3D11 texture
3800        // import and a D3D12/CUDA consumer can open its shared NT handle.
3801        //
3802        // Explicit-Dma contract, as on macOS and Android: the caller asked for
3803        // a texture, so a format with no texture layout is an error here
3804        // rather than a silent fall-through to the host-memory arms below,
3805        // which would only surface much later as a failed GPU import.
3806        #[cfg(target_os = "windows")]
3807        if matches!(memory, Some(TensorMemory::DmaBuf)) {
3808            let dtype = dtype_of::<T>().ok_or_else(|| {
3809                Error::InvalidArgument(format!(
3810                    "Tensor::image: element type {} has no DType, so no D3D11 texture \
3811                     layout exists. Pass memory=None or Some(TensorMemory::Mem) for a \
3812                     CPU tensor.",
3813                    std::any::type_name::<T>()
3814                ))
3815            })?;
3816            let storage = TensorStorage::<T>::new_image_d3d11(
3817                width, height, format, dtype, &shape, None, access,
3818            )?;
3819            let mut t = Self::wrap(storage);
3820            t.format = Some(format);
3821            record_d3d11_row_stride(&mut t, format, "Tensor::image");
3822            t.cpu_access = access;
3823            // Best-effort CUDA import, as the Linux DMA arms do: a texture a
3824            // CUDA consumer can map is worth the one import at allocation.
3825            t.try_init_d3d11_cuda();
3826            return Ok(t);
3827        }
3828
3829        // Compute the **64-byte-aligned** row stride for every image layout.
3830        //
3831        // Embedded GPUs reject `eglCreateImage` DMA-BUF imports whose row pitch
3832        // is not 64-byte aligned: Mali returns `EGL_BAD_ALLOC`, Vivante
3833        // `EGL_BAD_ACCESS`. This bit packed RGBA/RGB destinations at odd widths
3834        // AND at even non-multiple-of-16 widths (e.g. 321→1284, 322→1288 bytes —
3835        // neither divisible by 64), so an odd-source → RGBA convert failed on
3836        // imx95/imx8mp while succeeding on V3D/Tegra. Semi-planar already aligned
3837        // here; we now align packed and planar identically so every image()
3838        // allocation is GPU-importable regardless of width.
3839        //
3840        // The per-layout natural pitch and total row count:
3841        //   * SemiPlanar `[total_h, width]`     — pitch = even(width)·elem, rows = total_h
3842        //   * Packed     `[height, width, ch]`  — pitch = width·ch·elem,    rows = height
3843        //   * Planar     `[ch, height, width]`  — pitch = width·elem,       rows = ch·height
3844        // Allocation byte size = `aligned_stride · total_rows` (NOT the shape
3845        // product, which reflects only the logical width and under-allocates the
3846        // padding on odd / unaligned widths).
3847        let elem = std::mem::size_of::<T>();
3848        let channels = format.channels();
3849        let (natural_stride, total_rows) = match format.layout() {
3850            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
3851            PixelLayout::Packed => (width * channels * elem, height),
3852            PixelLayout::Planar => (width * elem, channels * height),
3853        };
3854        let aligned_stride = natural_stride.next_multiple_of(64);
3855        let semi = format.layout() == PixelLayout::SemiPlanar;
3856
3857        // DMA buffers MUST carry a 64-aligned row pitch — Mali/Vivante reject a
3858        // DMA-BUF EGLImage whose pitch is not 64-aligned. Semi-planar also needs
3859        // the aligned pitch on every backend (its chroma-plane offset math
3860        // assumes it). Packed/planar on host-only memory (Mem/Shm) keep the
3861        // natural tight pitch so the many flat CPU consumers are unaffected.
3862        let host_stride = if semi { aligned_stride } else { natural_stride };
3863        let host_byte_size = host_stride * total_rows;
3864        #[cfg(target_os = "linux")]
3865        let dma_byte_size = aligned_stride * total_rows;
3866
3867        // `used_stride` is the actual row pitch of the storage created below.
3868        let (storage, used_stride) = match memory {
3869            #[cfg(target_os = "linux")]
3870            Some(TensorMemory::DmaBuf) => (
3871                TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None)?,
3872                aligned_stride,
3873            ),
3874            #[cfg(unix)]
3875            Some(TensorMemory::Shm) => (
3876                TensorStorage::<T>::new_shm_with_byte_size(&shape, host_byte_size, None)?,
3877                host_stride,
3878            ),
3879            Some(TensorMemory::Mem) => (
3880                TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3881                host_stride,
3882            ),
3883            #[allow(unused_variables)]
3884            Some(other) => {
3885                // PBO and any future variants: fall through to standard new().
3886                return {
3887                    let mut t = Self::new(&shape, Some(other), None)?;
3888                    t.format = Some(format);
3889                    t.cpu_access = access;
3890                    Ok(t)
3891                };
3892            }
3893            None => {
3894                // Auto-select priority: DMA → Mem (DMA gets the 64-aligned
3895                // pitch; the Mem fallback keeps the tight host pitch, so the
3896                // recorded stride matches the storage used). Shm is NOT
3897                // auto-selected — it offers no advantage over Mem for an
3898                // in-process image and Mem always succeeds, so it sits below Mem
3899                // and is reached only via an explicit `TensorMemory::Shm`.
3900                #[cfg(target_os = "linux")]
3901                {
3902                    match TensorStorage::<T>::new_dma_with_byte_size(&shape, dma_byte_size, None) {
3903                        Ok(s) => (s, aligned_stride),
3904                        Err(_) => (
3905                            TensorStorage::<T>::new_mem_with_byte_size(
3906                                &shape,
3907                                host_byte_size,
3908                                None,
3909                            )?,
3910                            host_stride,
3911                        ),
3912                    }
3913                }
3914                #[cfg(not(target_os = "linux"))]
3915                {
3916                    (
3917                        TensorStorage::<T>::new_mem_with_byte_size(&shape, host_byte_size, None)?,
3918                        host_stride,
3919                    )
3920                }
3921            }
3922        };
3923
3924        let mut t = Self::wrap(storage);
3925        t.format = Some(format);
3926        // Record the row stride when it exceeds the natural tight pitch (padding
3927        // is present — DMA packed/planar at an unaligned width, or always for
3928        // semi-planar), mirroring the IOSurface path above. Aligned-width and
3929        // host-only packed/planar images keep their flat layout with no explicit
3930        // stride; `effective_row_stride()` then falls back to the identical
3931        // computed pitch. When padding IS present, consumers must iterate rows by
3932        // `effective_row_stride()` to skip it.
3933        if semi || used_stride > natural_stride {
3934            t.set_row_stride_unchecked(used_stride);
3935        }
3936        debug_assert!(
3937            t.row_stride.is_some() || !semi,
3938            "image() must always set row_stride for semi-planar tensors"
3939        );
3940        t.cpu_access = access;
3941        #[cfg(target_os = "linux")]
3942        t.try_init_dma_cuda();
3943        Ok(t)
3944    }
3945
3946    /// Create a DMA-backed image tensor with an explicit row stride that
3947    /// may exceed the natural `width * channels * sizeof(T)` pitch.
3948    ///
3949    /// Used for image tensors that need GPU pitch alignment padding: the
3950    /// underlying DMA-BUF is sized to `row_stride * height` bytes, but
3951    /// the tensor's logical shape stays at `[height, width, channels]`.
3952    /// `width()` / `height()` / `shape()` continue to report the
3953    /// user-requested values; the padding is visible only via
3954    /// `row_stride()` / `effective_row_stride()` and is automatically
3955    /// propagated to the GL backend's EGLImage import so Mali Valhall
3956    /// accepts the buffer.
3957    ///
3958    /// # Supported formats
3959    ///
3960    /// Currently only **packed** pixel layouts (RGBA8, BGRA8, RGB888,
3961    /// Grey, etc.) are supported — the formats the GL backend uses as
3962    /// render destinations. Semi-planar formats (NV12, NV16) come from
3963    /// external allocators (camera capture, video decoders) and are
3964    /// imported via `TensorDyn::from_fd` + `set_row_stride`, which
3965    /// already supports padded strides.
3966    ///
3967    /// # Supported memory
3968    ///
3969    /// Currently only `TensorMemory::DmaBuf` is supported. PBO and Mem
3970    /// storage don't go through EGLImage import so they don't need
3971    /// pitch alignment; if you pass any other memory type this returns
3972    /// `NotImplemented`. `None` (auto-select) is treated as `Dma`.
3973    ///
3974    /// # Errors
3975    ///
3976    /// - `InvalidArgument` if `row_stride_bytes < width * channels * sizeof(T)`
3977    ///   (the requested stride would not fit a single row)
3978    /// - `NotImplemented` for non-packed formats or non-DMA memory
3979    /// - `IoError` if the DMA-heap allocation fails (propagated from
3980    ///   `DmaTensor::new_with_byte_size`)
3981    pub fn image_with_stride(
3982        width: usize,
3983        height: usize,
3984        format: PixelFormat,
3985        row_stride_bytes: usize,
3986        memory: Option<TensorMemory>,
3987        access: CpuAccess,
3988    ) -> Result<Self> {
3989        #[cfg(not(target_os = "linux"))]
3990        let _ = access;
3991        // DMA backing (the only thing this constructor produces) is
3992        // Linux-only. On macOS/BSD/Windows the non-Linux block below is
3993        // the only compiled body and returns `NotImplemented` directly;
3994        // on Linux the non-Linux block is cfg-removed and the function
3995        // falls through to the real validation + allocation path. Each
3996        // target compiles exactly one of the two blocks, and the block
3997        // serves as the function's tail expression in both cases — so
3998        // neither needs an explicit `return` (avoids
3999        // `clippy::needless_return` on the macOS CI gate).
4000        #[cfg(not(target_os = "linux"))]
4001        {
4002            let _ = (width, height, format, row_stride_bytes, memory);
4003            Err(Error::NotImplemented(
4004                "image_with_stride requires DMA support (Linux only)".to_owned(),
4005            ))
4006        }
4007
4008        #[cfg(target_os = "linux")]
4009        {
4010            if format.layout() != PixelLayout::Packed {
4011                return Err(Error::NotImplemented(format!(
4012                    "Tensor::image_with_stride only supports packed pixel layouts, got {format:?}"
4013                )));
4014            }
4015            let elem = std::mem::size_of::<T>();
4016            let min_stride = width
4017                .checked_mul(format.channels())
4018                .and_then(|p| p.checked_mul(elem))
4019                .ok_or_else(|| {
4020                    Error::InvalidArgument(format!(
4021                        "image_with_stride: width {width} × channels {} × sizeof::<T>={elem} \
4022                         overflows usize",
4023                        format.channels()
4024                    ))
4025                })?;
4026            if row_stride_bytes < min_stride {
4027                return Err(Error::InvalidArgument(format!(
4028                    "image_with_stride: row_stride {row_stride_bytes} < minimum {min_stride} \
4029                     ({width} px × {} ch × {elem} B)",
4030                    format.channels()
4031                )));
4032            }
4033            let total_byte_size = row_stride_bytes.checked_mul(height).ok_or_else(|| {
4034                Error::InvalidArgument(format!(
4035                    "image_with_stride: row_stride {row_stride_bytes} × height {height} overflows usize"
4036                ))
4037            })?;
4038
4039            let shape = vec![height, width, format.channels()];
4040
4041            let storage = match memory {
4042                Some(TensorMemory::DmaBuf) | None => {
4043                    TensorStorage::<T>::new_dma_with_byte_size(&shape, total_byte_size, None)?
4044                }
4045                Some(other) => {
4046                    return Err(Error::NotImplemented(format!(
4047                        "image_with_stride: only TensorMemory::DmaBuf is supported, got {other:?}"
4048                    )));
4049                }
4050            };
4051
4052            let mut t = Self::wrap(storage);
4053            t.format = Some(format);
4054            t.row_stride = Some(row_stride_bytes);
4055            t.cpu_access = access;
4056            // Match new()/from_fd(): a DMA tensor must attempt CUDA external-
4057            // memory import so a strided DMA buffer is also zero-copy
4058            // CUDA-mappable (no-op when libcudart is absent).
4059            t.try_init_dma_cuda();
4060            Ok(t)
4061        }
4062    }
4063
4064    /// Attach format metadata to an existing tensor.
4065    ///
4066    /// # Arguments
4067    ///
4068    /// * `format` - The pixel format to attach
4069    ///
4070    /// # Returns
4071    ///
4072    /// `Ok(())` on success, with the format stored as metadata on the tensor.
4073    ///
4074    /// # Errors
4075    ///
4076    /// Returns `Error::InvalidShape` if the tensor shape is incompatible with
4077    /// the format's layout (packed expects `[H, W, C]`, planar expects
4078    /// `[C, H, W]`, semi-planar expects `[H*k, W]` with format-specific
4079    /// height constraints).
4080    pub fn set_format(&mut self, format: PixelFormat) -> Result<()> {
4081        let shape = self.shape();
4082        match format.layout() {
4083            PixelLayout::Packed => {
4084                if shape.len() != 3 || shape[2] != format.channels() {
4085                    return Err(Error::InvalidShape(format!(
4086                        "packed format {format:?} expects [H, W, {}], got {shape:?}",
4087                        format.channels()
4088                    )));
4089                }
4090            }
4091            PixelLayout::Planar => {
4092                if shape.len() != 3 || shape[0] != format.channels() {
4093                    return Err(Error::InvalidShape(format!(
4094                        "planar format {format:?} expects [{}, H, W], got {shape:?}",
4095                        format.channels()
4096                    )));
4097                }
4098            }
4099            PixelLayout::SemiPlanar => {
4100                if shape.len() != 2 {
4101                    return Err(Error::InvalidShape(format!(
4102                        "semi-planar format {format:?} expects [H*k, W], got {shape:?}"
4103                    )));
4104                }
4105                match format {
4106                    // Combined-plane height is `H + ceil(H/2)` (luma + chroma
4107                    // rows). For even H that is `3H/2` (≡ 0 mod 3); for odd H it
4108                    // is `(3H+1)/2` (≡ 2 mod 3). Only totals ≡ 1 mod 3 are
4109                    // unreachable, so reject just those — odd-height NV12 is
4110                    // valid (e.g. 725 rows for a 483-tall image).
4111                    PixelFormat::Nv12 if shape[0] % 3 == 1 => {
4112                        return Err(Error::InvalidShape(format!(
4113                            "NV12 contiguous shape[0] must be H + ceil(H/2) for some height; \
4114                             {} is unreachable (≡ 1 mod 3)",
4115                            shape[0]
4116                        )));
4117                    }
4118                    PixelFormat::Nv16 if !shape[0].is_multiple_of(2) => {
4119                        return Err(Error::InvalidShape(format!(
4120                            "NV16 contiguous shape[0] must be even, got {}",
4121                            shape[0]
4122                        )));
4123                    }
4124                    // NV24 (4:4:4): combined-plane height is 3H (Y + 2H chroma).
4125                    PixelFormat::Nv24 if !shape[0].is_multiple_of(3) => {
4126                        return Err(Error::InvalidShape(format!(
4127                            "NV24 contiguous shape[0] must be a multiple of 3 (= 3H), got {}",
4128                            shape[0]
4129                        )));
4130                    }
4131                    _ => {}
4132                }
4133            }
4134        }
4135        // Clear stored stride/offset when format changes — they may be invalid
4136        // for the new format. Caller must re-set after changing format.
4137        if self.format != Some(format) {
4138            self.row_stride = None;
4139            self.plane_offset = None;
4140            match self.storage {
4141                TensorStorage::Mem(ref mut m) => m.set_offset(0),
4142                #[cfg(target_os = "linux")]
4143                TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
4144                // Paired with the arm in `set_plane_offset`: a setter that
4145                // takes effect on this backing needs a clear that does too,
4146                // or the wrapper reports `None` while `map()` still starts at
4147                // the old offset -- a stale window rather than a lost one.
4148                // `reshape`'s clear site needs no such arm:
4149                // `IoSurfaceTensor::reshape` zeroes `view_offset` itself
4150                // before the match there runs.
4151                #[cfg(any(target_os = "macos", target_os = "ios"))]
4152                TensorStorage::Dma(ref mut io) => io.view_offset = 0,
4153                // The same pair for D3D11. Unlike `IoSurfaceTensor`,
4154                // `D3d11TextureTensor::reshape` leaves `view_offset` alone, so
4155                // `reshape`'s clear site carries this arm as well.
4156                #[cfg(target_os = "windows")]
4157                TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
4158                // Paired with the arm in `set_plane_offset`: a setter that
4159                // takes effect on this backing needs a clear that does too.
4160                // `reshape`'s clear site needs no `Pbo` arm --
4161                // `PboTensor::reshape` zeroes `view_offset` itself before the
4162                // match there runs, exactly as `IoSurfaceTensor::reshape`
4163                // does. `set_logical_shape` deliberately does not, matching
4164                // `Mem`.
4165                TensorStorage::Pbo(ref mut p) => p.view_offset = 0,
4166                // Same pair, `Shm` side. `cfg(unix)` mirrors the `Shm`
4167                // variant's own gate on `TensorStorage`, not a per-OS
4168                // multiplex -- see the comment on the `set_plane_offset`
4169                // arm.
4170                #[cfg(unix)]
4171                TensorStorage::Shm(ref mut s) => s.set_offset(0),
4172                // No-op, pairing with `set_plane_offset`'s Android arm:
4173                // nothing writes `AHardwareBufferTensor::view_offset` there
4174                // either yet, so there is nothing here for a format change
4175                // to clear. See that arm's comment for the full accounting
4176                // of the still-incomplete gap.
4177                #[cfg(target_os = "android")]
4178                TensorStorage::Dma(_) => {}
4179            }
4180        }
4181        self.format = Some(format);
4182        Ok(())
4183    }
4184
4185    /// Set this tensor's logical dimensions and pixel format to a decoded
4186    /// image, reusing the existing allocation. The shape is derived from the
4187    /// format layout; fails with `Error::InsufficientCapacity` if the
4188    /// allocation cannot hold `width`×`height` in `format`, or
4189    /// `Error::InvalidArgument` if the dimensions are invalid for the format.
4190    ///
4191    /// For NV12/NV16/NV24 the buffer width is rounded up to even (a chroma-plane
4192    /// interleaving requirement); the true odd width is reported by the decoder
4193    /// in `ImageInfo` and trimmed by a `convert()` crop. See
4194    /// [`PixelFormat::allocation_shape`].
4195    ///
4196    /// When the backing has a fixed physical row pitch (an IOSurface's
4197    /// 64-aligned `bytesPerRow`) that exceeds the new format's natural row
4198    /// stride — i.e. a reused max-sized pool tensor reconfigured to a smaller
4199    /// image — the physical pitch is preserved as the tensor's `row_stride`.
4200    /// This keeps the **physical grid** (allocation stride/surface) fixed while
4201    /// the **logical ROI** (this image's W×H) changes, so the decode writes rows
4202    /// at the surface's real stride and the GPU samples them at the same stride.
4203    /// Exact-sized buffers (pitch == natural) stay tightly packed unchanged.
4204    pub fn configure_image(
4205        &mut self,
4206        width: usize,
4207        height: usize,
4208        format: PixelFormat,
4209    ) -> Result<()> {
4210        let shape = format.allocation_shape(width, height).ok_or_else(|| {
4211            Error::InvalidArgument(format!(
4212                "invalid dimensions {width}x{height} for format {format:?}"
4213            ))
4214        })?;
4215        // Capture the pre-existing row stride before `set_format` clears it.
4216        // For pool tensors that were allocated at a larger width (e.g. 1920-wide
4217        // pool decoding a 789-wide image), this preserves the backing pitch so
4218        // rows are still written at the correct physical stride.
4219        let prior_stride = self.row_stride;
4220
4221        self.storage.set_logical_shape(&shape)?;
4222        self.set_format(format)?; // clears any stale row_stride
4223
4224        // Restore the correct row pitch for the new geometry. A DMA buffer of
4225        // ANY layout, and a semi-planar buffer on any backing, MUST carry a
4226        // 64-byte-aligned pitch: Mali/Vivante reject a DMA-BUF EGLImage whose
4227        // row pitch is not 64-aligned (`EGL_BAD_ALLOC` / `EGL_BAD_ACCESS`), and
4228        // semi-planar chroma-offset math assumes it. This mirrors the pitch
4229        // `Tensor::image()` allocates, so a recycled pool buffer imports
4230        // identically to a fresh one — without it a `configure_image()`'d packed
4231        // buffer (e.g. Y800 96-wide → tight pitch 96) failed convert() on
4232        // imx95/imx8mp via the texture-upload fallback while the fresh oracle
4233        // (aligned 128) succeeded. Packed/planar on host-only memory (Mem/Shm)
4234        // keep the tight pitch so flat CPU consumers stay unaffected — matching
4235        // `image()`'s `host_stride` rule.
4236        let elem = std::mem::size_of::<T>();
4237        let channels = format.channels();
4238        let (min_stride, total_rows) = match format.layout() {
4239            PixelLayout::SemiPlanar => (width.next_multiple_of(2) * elem, shape[0]),
4240            PixelLayout::Packed => (width * channels * elem, height),
4241            PixelLayout::Planar => (width * elem, channels * height),
4242        };
4243        let needs_align = self.storage.memory() == TensorMemory::DmaBuf
4244            || format.layout() == PixelLayout::SemiPlanar;
4245
4246        let active_stride = if let Some(pitch) = self.storage.backing_row_stride() {
4247            // macOS IOSurface: use the surface's native pitch.
4248            let natural = self.effective_row_stride().unwrap_or(0);
4249            if pitch > natural {
4250                self.set_row_stride_unchecked(pitch);
4251                pitch
4252            } else {
4253                natural
4254            }
4255        } else if needs_align {
4256            // Priority:
4257            //   1. Prior stride (pool reuse): if the pre-existing stride is
4258            //      64-aligned, >= this layout's minimum row, and fits the
4259            //      allocation, keep it. This is the hot-loop reuse case (large
4260            //      pool, small image).
4261            //   2. Compute a fresh 64-aligned stride for the current width.
4262            let aligned = min_stride.next_multiple_of(64);
4263            let capacity = self.storage.capacity_bytes();
4264
4265            let candidate = if let Some(ps) = prior_stride {
4266                if ps >= min_stride && ps % 64 == 0 && ps * total_rows <= capacity {
4267                    ps
4268                } else {
4269                    aligned
4270                }
4271            } else {
4272                aligned
4273            };
4274
4275            if candidate * total_rows <= capacity {
4276                self.set_row_stride_unchecked(candidate);
4277                candidate
4278            } else {
4279                // Shouldn't happen for legitimate pools, but don't crash.
4280                self.effective_row_stride().unwrap_or(0)
4281            }
4282        } else {
4283            self.effective_row_stride().unwrap_or(0)
4284        };
4285
4286        // Ensure the active stride fits the allocation. A pool reconfigured to a
4287        // wider image than its backing would silently SIGBUS on any subsequent
4288        // map/write — catch it here instead.
4289        if needs_align && active_stride > 0 {
4290            let needed = active_stride * total_rows;
4291            let capacity = self.storage.capacity_bytes();
4292            if needed > capacity {
4293                return Err(Error::InsufficientCapacity { needed, capacity });
4294            }
4295        }
4296        Ok(())
4297    }
4298
4299    /// Allocate an image tensor sized to hold up to `width`×`height` in
4300    /// `format`, reusable for any smaller image via `configure_image`.
4301    pub fn image_with_capacity(
4302        width: usize,
4303        height: usize,
4304        format: PixelFormat,
4305        memory: Option<TensorMemory>,
4306        access: CpuAccess,
4307    ) -> Result<Self>
4308    where
4309        T: 'static,
4310    {
4311        Self::image(width, height, format, memory, access)
4312    }
4313
4314    /// Pixel format (None if not an image).
4315    pub fn format(&self) -> Option<PixelFormat> {
4316        self.format
4317    }
4318
4319    /// Image width (None if not an image).
4320    pub fn width(&self) -> Option<usize> {
4321        let fmt = self.format?;
4322        let shape = self.shape();
4323        match fmt.layout() {
4324            PixelLayout::Packed => Some(shape[1]),
4325            PixelLayout::Planar => Some(shape[2]),
4326            PixelLayout::SemiPlanar => Some(shape[1]),
4327        }
4328    }
4329
4330    /// Image height (None if not an image).
4331    ///
4332    /// For semi-planar formats the combined-plane shape row count is divided
4333    /// by the format's luma-to-total ratio to recover logical height. This
4334    /// returns the exact logical height (including odd heights) only because
4335    /// the logical dimensions are tracked separately from the physical shape —
4336    /// `configure_image` stores the actual `(width, height)` in the format's
4337    /// `allocation_shape`, which round-trips losslessly via these accessors.
4338    pub fn height(&self) -> Option<usize> {
4339        let fmt = self.format?;
4340        let shape = self.shape();
4341        match fmt.layout() {
4342            PixelLayout::Packed => Some(shape[0]),
4343            PixelLayout::Planar => Some(shape[1]),
4344            PixelLayout::SemiPlanar => {
4345                if self.is_multiplane() {
4346                    Some(shape[0])
4347                } else {
4348                    match fmt {
4349                        PixelFormat::Nv12 => Some(shape[0] * 2 / 3),
4350                        PixelFormat::Nv16 => Some(shape[0] / 2),
4351                        PixelFormat::Nv24 => Some(shape[0] / 3),
4352                        _ => None,
4353                    }
4354                }
4355            }
4356        }
4357    }
4358
4359    /// Create from separate Y and UV planes (multiplane NV12/NV16).
4360    pub fn from_planes(luma: Tensor<T>, chroma: Tensor<T>, format: PixelFormat) -> Result<Self> {
4361        if format.layout() != PixelLayout::SemiPlanar {
4362            return Err(Error::InvalidArgument(format!(
4363                "from_planes requires a semi-planar format, got {format:?}"
4364            )));
4365        }
4366        if chroma.format.is_some() || chroma.chroma.is_some() {
4367            return Err(Error::InvalidArgument(
4368                "chroma tensor must be a raw tensor (no format or chroma metadata)".into(),
4369            ));
4370        }
4371        let luma_shape = luma.shape();
4372        let chroma_shape = chroma.shape();
4373        if luma_shape.len() != 2 || chroma_shape.len() != 2 {
4374            return Err(Error::InvalidArgument(format!(
4375                "from_planes expects 2D shapes, got luma={luma_shape:?} chroma={chroma_shape:?}"
4376            )));
4377        }
4378        if luma_shape[1] != chroma_shape[1] {
4379            return Err(Error::InvalidArgument(format!(
4380                "luma width {} != chroma width {}",
4381                luma_shape[1], chroma_shape[1]
4382            )));
4383        }
4384        match format {
4385            PixelFormat::Nv12 => {
4386                if luma_shape[0] % 2 != 0 {
4387                    return Err(Error::InvalidArgument(format!(
4388                        "NV12 requires even luma height, got {}",
4389                        luma_shape[0]
4390                    )));
4391                }
4392                if chroma_shape[0] != luma_shape[0] / 2 {
4393                    return Err(Error::InvalidArgument(format!(
4394                        "NV12 chroma height {} != luma height / 2 ({})",
4395                        chroma_shape[0],
4396                        luma_shape[0] / 2
4397                    )));
4398                }
4399            }
4400            PixelFormat::Nv16 => {
4401                if chroma_shape[0] != luma_shape[0] {
4402                    return Err(Error::InvalidArgument(format!(
4403                        "NV16 chroma height {} != luma height {}",
4404                        chroma_shape[0], luma_shape[0]
4405                    )));
4406                }
4407            }
4408            // NV24's chroma plane is full-resolution (2×-wide interleaved UV),
4409            // which the equal-width plane check above doesn't model. Multiplane
4410            // NV24 is unused (the JPEG decoder emits a contiguous NV24 buffer),
4411            // so it's not supported here yet.
4412            _ => {
4413                return Err(Error::InvalidArgument(format!(
4414                    "from_planes only supports NV12 and NV16 (NV24 multiplane not yet \
4415                     supported — use a contiguous NV24 tensor), got {format:?}"
4416                )));
4417            }
4418        }
4419
4420        Ok(Tensor {
4421            storage: luma.storage,
4422            format: Some(format),
4423            chroma: Some(Box::new(chroma)),
4424            row_stride: luma.row_stride,
4425            plane_offset: luma.plane_offset,
4426            quantization: luma.quantization,
4427            // A multiplane tensor spans two DMA-BUFs (luma + chroma); CUDA
4428            // external-memory import is per-fd, so there is no single device
4429            // pointer for the composite. Any CUDA handle the luma plane carried
4430            // is intentionally dropped — consumers needing CUDA access to
4431            // multiplane data must import each plane independently.
4432            cuda: None,
4433            colorimetry: luma.colorimetry,
4434            cpu_access: luma.cpu_access,
4435            compression: luma.compression,
4436            // A composed multiplane tensor is a whole image, not a sub-view.
4437            view_origin: None,
4438        })
4439    }
4440
4441    /// Whether this tensor uses separate plane allocations.
4442    pub fn is_multiplane(&self) -> bool {
4443        self.chroma.is_some()
4444    }
4445
4446    /// Access the chroma plane for multiplane semi-planar images.
4447    pub fn chroma(&self) -> Option<&Tensor<T>> {
4448        self.chroma.as_deref()
4449    }
4450
4451    /// Mutable access to the chroma plane for multiplane semi-planar images.
4452    pub fn chroma_mut(&mut self) -> Option<&mut Tensor<T>> {
4453        self.chroma.as_deref_mut()
4454    }
4455
4456    /// Independent tensor over this plane's allocation, copying geometry
4457    /// metadata. Used to answer `ef_tensor_chroma` from the handle without a
4458    /// client-side shadow field.
4459    pub(crate) fn clone_chroma_plane(&self) -> Result<Self> {
4460        // Every storage accepted by `from_planes` implements `view` as a
4461        // shared-backing lens. Using that common path keeps
4462        // `is_multiplane() => chroma().is_some()` true for SHM, PBO and
4463        // D3D11 too, instead of silently losing chroma when a hand-written
4464        // per-storage clone omits a variant.
4465        self.subview(0, self.shape())
4466    }
4467
4468    /// Row stride in bytes (`None` = tightly packed).
4469    pub fn row_stride(&self) -> Option<usize> {
4470        self.row_stride
4471    }
4472
4473    /// Effective row stride in bytes: the stored stride if set, otherwise the
4474    /// minimum stride computed from the format, width, and element size.
4475    /// Returns `None` only when no format is set and no explicit stride was
4476    /// stored via [`set_row_stride`](Self::set_row_stride).
4477    ///
4478    /// **GREY note:** `effective_row_stride()` for a GREY tensor returns the
4479    /// tight `width` bytes (no padding), which is what `normalize_to_numpy` and
4480    /// the CPU convert path expect. The codec's internal `native_row_stride`
4481    /// (64-byte-aligned) is used only during decoding and is not propagated to
4482    /// the tensor's stored stride, so callers reading via
4483    /// `effective_row_stride()` always see the tight value for GREY.
4484    pub fn effective_row_stride(&self) -> Option<usize> {
4485        if let Some(s) = self.row_stride {
4486            return Some(s);
4487        }
4488        let fmt = self.format?;
4489        let w = self.width()?;
4490        let elem = std::mem::size_of::<T>();
4491        Some(match fmt.layout() {
4492            PixelLayout::Packed => w * fmt.channels() * elem,
4493            PixelLayout::Planar => w * elem,
4494            // Semi-planar: minimum stride must cover the even width so the
4495            // interleaved chroma columns are byte-aligned on odd-width images.
4496            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4497        })
4498    }
4499
4500    /// Copy the tensor's logical bytes into `dst`, compacting away any
4501    /// recorded row-stride padding.
4502    ///
4503    /// The flatness helper for NPU handoff: consumers that need a FLAT
4504    /// layout (e.g. `[1, C, H, W]` for NNAPI/LiteRT when the runtime cannot
4505    /// take a padded pitch) call this when [`row_stride`](Self::row_stride)
4506    /// is `Some` — on a tight tensor it degenerates to one memcpy, so it is
4507    /// safe to call unconditionally. `dst.len()` must equal the tight byte
4508    /// footprint (`shape` product × element size). Zero-copy consumers
4509    /// should prefer the buffer handle + [`Tensor::effective_row_stride`]
4510    /// (Self::effective_row_stride) and skip this copy entirely.
4511    pub fn copy_to_flat(&self, dst: &mut [u8]) -> Result<()> {
4512        let tight_bytes = crate::ahardwarebuffer_layout::checked_shape_bytes::<T>(self.shape())?;
4513        if dst.len() != tight_bytes {
4514            return Err(Error::InvalidArgument(format!(
4515                "copy_to_flat: dst is {} bytes but the tensor's tight \
4516                 footprint is {tight_bytes} bytes (shape {:?})",
4517                dst.len(),
4518                self.shape()
4519            )));
4520        }
4521        // Read-only: this helper never writes back into the tensor, so a
4522        // read map suffices — and is required on backends (D3D11) that
4523        // reject a writable map on a tensor allocated CpuAccess::Read.
4524        let map = self.map_read()?;
4525        // SAFETY: T is a plain numeric type (crate-wide bound); viewing the
4526        // mapped elements as bytes is sound.
4527        let src: &[u8] = unsafe {
4528            std::slice::from_raw_parts(
4529                map.as_slice().as_ptr() as *const u8,
4530                std::mem::size_of_val(map.as_slice()),
4531            )
4532        };
4533        let Some(stride) = self.row_stride else {
4534            // Tight layout: the mapped window is exactly the logical bytes.
4535            let got = src.len().min(tight_bytes);
4536            if got < tight_bytes {
4537                return Err(Error::InvalidOperation(format!(
4538                    "copy_to_flat: mapped {got} bytes < tight footprint {tight_bytes}"
4539                )));
4540            }
4541            dst.copy_from_slice(&src[..tight_bytes]);
4542            return Ok(());
4543        };
4544        // Strided: row count follows the strided-map convention (planar
4545        // stacks C planes of H rows; packed/semi-planar use shape[0]), and
4546        // the logical row is tight_bytes / rows for every layout.
4547        let rows = match self.format.map(|f| f.layout()) {
4548            Some(PixelLayout::Planar) => {
4549                let s = self.shape();
4550                if s.len() < 2 {
4551                    return Err(Error::InvalidOperation(
4552                        "copy_to_flat: strided planar tensor requires [C, H, W] shape".into(),
4553                    ));
4554                }
4555                s[0].checked_mul(s[1]).ok_or_else(|| {
4556                    Error::InvalidOperation(format!(
4557                        "copy_to_flat: planar rows {} × {} overflows usize",
4558                        s[0], s[1]
4559                    ))
4560                })?
4561            }
4562            _ => *self.shape().first().ok_or_else(|| {
4563                Error::InvalidOperation("copy_to_flat: tensor has an empty shape".into())
4564            })?,
4565        };
4566        if rows == 0 || !tight_bytes.is_multiple_of(rows) {
4567            return Err(Error::InvalidOperation(format!(
4568                "copy_to_flat: tight footprint {tight_bytes} does not divide \
4569                 into {rows} rows"
4570            )));
4571        }
4572        let row_bytes = tight_bytes / rows;
4573        let need = (rows - 1)
4574            .checked_mul(stride)
4575            .and_then(|b| b.checked_add(row_bytes))
4576            .ok_or_else(|| {
4577                Error::InvalidOperation(format!(
4578                    "copy_to_flat: stride {stride} × rows {rows} overflows usize"
4579                ))
4580            })?;
4581        if src.len() < need {
4582            return Err(Error::InvalidOperation(format!(
4583                "copy_to_flat: mapped {} bytes but strided rows need {need}",
4584                src.len()
4585            )));
4586        }
4587        for r in 0..rows {
4588            dst[r * row_bytes..(r + 1) * row_bytes]
4589                .copy_from_slice(&src[r * stride..r * stride + row_bytes]);
4590        }
4591        Ok(())
4592    }
4593
4594    /// Set the row stride in bytes for externally allocated buffers with
4595    /// row padding (e.g. V4L2 or GStreamer allocators).
4596    ///
4597    /// The stride is propagated to the EGL DMA-BUF import attributes so
4598    /// the GPU interprets the padded buffer layout correctly. Must be
4599    /// called after [`set_format`](Self::set_format) and before the tensor
4600    /// is first passed to `ImageProcessor::convert`. The stored stride
4601    /// is cleared automatically if the pixel format is later changed.
4602    ///
4603    /// No stride-vs-buffer-size validation is performed because the
4604    /// backing allocation size is not reliably known: external DMA-BUFs
4605    /// may be over-allocated by the allocator, and internal tensors store
4606    /// a logical (unpadded) shape. An incorrect stride will be caught by
4607    /// the EGL driver at import time.
4608    ///
4609    /// # Arguments
4610    ///
4611    /// * `stride` - Row stride in bytes. Must be >= the minimum stride for
4612    ///   the format (width * channels * sizeof(T) for packed,
4613    ///   width * sizeof(T) for planar/semi-planar).
4614    ///
4615    /// # Errors
4616    ///
4617    /// * `InvalidArgument` if no pixel format is set on this tensor
4618    /// * `InvalidArgument` if `stride` is less than the minimum for the
4619    ///   format and width
4620    pub fn set_row_stride(&mut self, stride: usize) -> Result<()> {
4621        let fmt = self.format.ok_or_else(|| {
4622            Error::InvalidArgument("cannot set row_stride without a pixel format".into())
4623        })?;
4624        let w = self.width().ok_or_else(|| {
4625            Error::InvalidArgument("cannot determine width for row_stride validation".into())
4626        })?;
4627        let elem = std::mem::size_of::<T>();
4628        let min_stride = match fmt.layout() {
4629            PixelLayout::Packed => w * fmt.channels() * elem,
4630            PixelLayout::Planar => w * elem,
4631            // Semi-planar: minimum must cover even width for chroma alignment.
4632            PixelLayout::SemiPlanar => w.next_multiple_of(2) * elem,
4633        };
4634        if stride < min_stride {
4635            return Err(Error::InvalidArgument(format!(
4636                "row_stride {stride} < minimum {min_stride} for {fmt:?} at width {w}"
4637            )));
4638        }
4639        self.row_stride = Some(stride);
4640        Ok(())
4641    }
4642
4643    /// Set the row stride without format validation.
4644    ///
4645    /// Use this for raw sub-tensors (e.g. chroma planes) that don't carry
4646    /// format metadata. The caller is responsible for ensuring the stride
4647    /// is valid.
4648    pub fn set_row_stride_unchecked(&mut self, stride: usize) {
4649        self.row_stride = Some(stride);
4650    }
4651
4652    /// Builder-style variant of [`set_row_stride`](Self::set_row_stride),
4653    /// consuming and returning `self`.
4654    ///
4655    /// # Errors
4656    ///
4657    /// Same conditions as [`set_row_stride`](Self::set_row_stride).
4658    pub fn with_row_stride(mut self, stride: usize) -> Result<Self> {
4659        self.set_row_stride(stride)?;
4660        Ok(self)
4661    }
4662
4663    /// Byte offset within the DMA-BUF where image data starts (`None` = 0).
4664    pub fn plane_offset(&self) -> Option<usize> {
4665        self.plane_offset
4666    }
4667
4668    /// The parent-image snapshot if this tensor is a [`view`](Self::view)/
4669    /// [`batch`](Self::batch) sub-region; `None` for a whole tensor. The GL
4670    /// backend keys its import on the parent geometry and renders this view as a
4671    /// `glViewport`/`glScissor` ROI at `(x, y, width, height)`. See [`ViewOrigin`].
4672    pub fn view_origin(&self) -> Option<ViewOrigin> {
4673        self.view_origin
4674    }
4675
4676    /// Set the byte offset within the DMA-BUF where image data starts.
4677    ///
4678    /// Propagated to `EGL_DMA_BUF_PLANE0_OFFSET_EXT` on GPU import.
4679    /// Unlike [`set_row_stride`](Self::set_row_stride), no format is required
4680    /// since the offset is format-independent.
4681    pub fn set_plane_offset(&mut self, offset: usize) {
4682        self.plane_offset = Some(offset);
4683        // The offset consulted by `map()` lives inside the storage variant.
4684        // Keep it in sync with the wrapper field for every backing that
4685        // honors it; see also the clear sites in `set_format` and `reshape`.
4686        //
4687        // Note that `TensorStorage::Dma` is a cfg-multiplexed *name* rather
4688        // than one type -- `DmaTensor` on Linux, `IoSurfaceTensor` on
4689        // macOS/iOS, `AHardwareBufferTensor` on Android,
4690        // `D3d11TextureTensor` on Windows -- so each platform needs its own
4691        // arm below; a missing one would silently become a fall-through
4692        // rather than a compile error, which is how macOS went without one
4693        // for a while. The match is kept exhaustive (no `_ => {}`)
4694        // specifically so that hazard is a compile error, not a silent gap.
4695        match self.storage {
4696            TensorStorage::Mem(ref mut m) => m.set_offset(offset),
4697            #[cfg(target_os = "linux")]
4698            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = offset,
4699            // The offset `IoSurfaceTensor::scoped_pin`/`map_inner` add to the
4700            // locked base address. Writing it here is what makes a
4701            // *reconstructed* view address its own sub-region instead of the
4702            // parent surface's origin; `IoSurfaceTensor::view` already set it
4703            // on the fresh-view path, which is why only the descriptor round
4704            // trip was ever wrong. Idempotent against `subview`, which sets
4705            // the same absolute value by both routes -- pinned by
4706            // `nested_iosurface_subviews_do_not_compound_their_plane_offset`.
4707            #[cfg(any(target_os = "macos", target_os = "ios"))]
4708            TensorStorage::Dma(ref mut io) => io.view_offset = offset,
4709            // The offset every `D3d11TextureTensor` pin adds to its host or
4710            // staging base, in backing (pitched) space like `row_stride()`.
4711            // `view()` bounds-checks the value it computes; this route does
4712            // not, so the pins refuse an offset past the backing instead
4713            // (`check_view_offset` in `d3d11/texture.rs`). Pinned by the
4714            // `d3d11` plane-offset tests in `crates/tensor/tests`.
4715            #[cfg(target_os = "windows")]
4716            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(offset),
4717            // The offset `PboTensor::scoped_pin` adds to the mapped base.
4718            // Writing it here is what makes a *reconstructed* view address
4719            // its own sub-region instead of the parent buffer's origin;
4720            // `PboTensor::view` already set it on the fresh-view path, which
4721            // is why only the round trip was ever wrong -- the same shape
4722            // IOSurface had. Idempotent against `subview`, which sets the
4723            // same absolute value by both routes -- pinned by
4724            // `nested_pbo_subviews_do_not_compound_their_plane_offset`.
4725            //
4726            // Not `cfg`-gated, and deliberately so: unlike
4727            // `TensorStorage::Dma` -- a cfg-multiplexed *name* whose gated
4728            // arm silently becomes `_ => {}` on the other targets, the
4729            // mechanism behind this whole issue class -- `TensorStorage::Pbo`
4730            // is one type on every platform.
4731            TensorStorage::Pbo(ref mut p) => p.view_offset = offset,
4732            // The offset `ShmTensor::map_inner`/`host_pin` add to the mmap
4733            // base, exactly like `MemTensor::offset`. `cfg(unix)` here
4734            // mirrors `TensorStorage::Shm`'s own gate on the enum
4735            // definition -- it is not a per-OS multiplex the way
4736            // `TensorStorage::Dma` is, so this arm cannot silently become
4737            // `_ => {}` on a target where the `Shm` variant still exists.
4738            #[cfg(unix)]
4739            TensorStorage::Shm(ref mut s) => s.set_offset(offset),
4740            // STILL INCOMPLETE: `AHardwareBufferTensor` (Android) has a
4741            // `view_offset` that its own `map()` adds and its own `view()`
4742            // sets, but nothing writes it here yet, so restoring an offset
4743            // onto an already-built tensor is silently a no-op on this
4744            // backing -- a caller gets a map at the parent's origin. Not
4745            // reachable through `TensorDyn::import_descriptor` today
4746            // (`import_storage`'s DMABUF arm is `cfg(target_os = "linux")`),
4747            // so it is latent rather than silent. See
4748            // `edgefirst-python-common`'s `interop::apply_plane_offset` for
4749            // the full per-backing accounting. PBO and Shm were on this list
4750            // until Stage B of the client-side-state work gave them the arms
4751            // above.
4752            #[cfg(target_os = "android")]
4753            TensorStorage::Dma(_) => {}
4754        }
4755    }
4756
4757    /// Colorimetry metadata (`None` = undefined; never auto-filled).
4758    /// The CPU access declared for this tensor at allocation (see
4759    /// [`CpuAccess`]). Views share their parent's declaration.
4760    pub fn cpu_access(&self) -> CpuAccess {
4761        self.cpu_access
4762    }
4763
4764    /// Set the declared CPU access without re-allocating — crate-private:
4765    /// the declaration must reflect the underlying allocation's real
4766    /// capabilities (constructors and importers set it; arbitrary widening
4767    /// would defeat the contract).
4768    // Only the Android AHardwareBuffer importer derives a declaration from
4769    // an existing allocation's usage bits today.
4770    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4771    pub(crate) fn set_cpu_access_unchecked(&mut self, access: CpuAccess) {
4772        self.cpu_access = access;
4773    }
4774
4775    /// The vendor tile-compression scheme recorded at allocation, or
4776    /// `None` for a linear layout. `Some` means the pixels live in a
4777    /// proprietary tile order: the row-stride accessors describe no
4778    /// meaningful linear layout and CPU maps are best-effort (see
4779    /// [`Compression`]). Only Android hardware-only allocations that
4780    /// requested compression record a scheme.
4781    pub fn compression(&self) -> Option<CompressionScheme> {
4782        self.compression
4783    }
4784
4785    /// Record the compression scheme — crate-private: recording is an
4786    /// allocation-time fact ([`Tensor::image_desc`] sets it; arbitrary
4787    /// mutation would misdescribe the physical layout).
4788    // Only the Android allocation path records a scheme today.
4789    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
4790    pub(crate) fn set_compression_unchecked(&mut self, scheme: Option<CompressionScheme>) {
4791        self.compression = scheme;
4792    }
4793
4794    pub fn colorimetry(&self) -> Option<crate::Colorimetry> {
4795        self.colorimetry
4796    }
4797
4798    /// Attach/clear colorimetry metadata.
4799    pub fn set_colorimetry(&mut self, c: Option<crate::Colorimetry>) {
4800        self.colorimetry = c;
4801    }
4802
4803    /// Builder-style colorimetry attach.
4804    pub fn with_colorimetry(mut self, c: crate::Colorimetry) -> Self {
4805        self.colorimetry = Some(c);
4806        self
4807    }
4808
4809    /// Create a zero-copy sub-region view of this tensor's backing buffer.
4810    ///
4811    /// The returned tensor shares this tensor's allocation (no copy) and maps
4812    /// the window `[offset_bytes, offset_bytes + shape.product()*size_of::<T>())`
4813    /// measured from this tensor's own logical start. N sub-views into one
4814    /// parent can be written independently, enabling batched assembly into a
4815    /// single buffer. Identical semantics across `Mem` (shared `Arc`) and
4816    /// `Dma` (shared fd) backings.
4817    ///
4818    /// # Disjointness
4819    ///
4820    /// Independent writes are sound *only* when the windows do not overlap. The
4821    /// shared backing uses interior mutability (`UnsafeCell` cells), so two
4822    /// sub-views whose byte ranges intersect alias the same cells: writing one
4823    /// while reading or writing the other is a data race and therefore
4824    /// **undefined behaviour**. The caller is responsible for keeping the
4825    /// windows disjoint; this method does not check for overlap.
4826    ///
4827    /// # Errors
4828    ///
4829    /// - [`Error::InvalidOperation`] if the backing is not `Mem` or `Dma`, or
4830    ///   if `offset_bytes` is not a multiple of `align_of::<T>()`.
4831    /// - [`Error::InsufficientCapacity`] / [`Error::InvalidSize`] if the window
4832    ///   exceeds the parent allocation.
4833    pub(crate) fn subview(&self, offset_bytes: usize, shape: &[usize]) -> Result<Tensor<T>> {
4834        // Offset is absolute into the backing allocation: a sub-view of a
4835        // sub-view composes by adding this tensor's own offset.
4836        let abs_offset = self
4837            .plane_offset
4838            .unwrap_or(0)
4839            .checked_add(offset_bytes)
4840            .ok_or(Error::InvalidSize(offset_bytes))?;
4841        // Every backend exposes `view(offset, shape)` via `TensorTrait`, sharing
4842        // the resource AND `BufferIdentity` (unlike `from_fd`/`from_surface`,
4843        // which mint a fresh identity). The GL backend keys the import on the
4844        // shared identity so offset-distinct sub-views of one buffer reuse a
4845        // single import and address their window via `glViewport`. `Mem`/`Shm`
4846        // share via the allocation `Arc` / a cloned fd; `Pbo` via the GL-buffer
4847        // `Arc`; Linux DMA-BUF / macOS IOSurface via the shared fd / CFRetain.
4848        // `TensorStorage::view` performs the one remaining per-variant dispatch.
4849        let mut t = Tensor::wrap(self.storage.view(offset_bytes, shape)?);
4850        // Inherit the parent's image metadata so the view is a ready-to-use
4851        // sub-image (e.g. a `convert()` destination). The offset is applied
4852        // LAST because `set_format` deliberately clears it — the offset is a
4853        // structural property of the sub-region, not format-dependent metadata.
4854        if let Some(fmt) = self.format {
4855            t.set_format(fmt)?;
4856        }
4857        if let Some(rs) = self.row_stride {
4858            t.set_row_stride_unchecked(rs);
4859        }
4860        t.quantization = self.quantization.clone();
4861        // A sub-region of an image carries the parent's colorimetry — it is the
4862        // same pixels, same color encoding. Inherit it like the other image
4863        // metadata above so a sub-view is a faithful convert() source/target.
4864        t.set_colorimetry(self.colorimetry);
4865        // The declared CPU access is a property of the underlying
4866        // allocation, so every view shares the parent's declaration.
4867        t.cpu_access = self.cpu_access;
4868        // Likewise the recorded compression scheme: the view shares the
4869        // parent's physical layout.
4870        t.compression = self.compression;
4871        if abs_offset > 0 {
4872            t.set_plane_offset(abs_offset);
4873        }
4874        Ok(t)
4875    }
4876
4877    /// Borrow batch element `n` of a batched tensor as a zero-copy view.
4878    ///
4879    /// A batched tensor prepends `N` as the leading dimension over the
4880    /// per-element image layout (`[N, H, W, C]` packed or `[N, C, H, W]`
4881    /// planar) — `N` is over the whole per-element block regardless of
4882    /// `HWC`/`CHW`. `batch(n)` returns element `n`: the contiguous per-element
4883    /// region at byte offset `n * element_size`, sharing the parent's
4884    /// `BufferIdentity` and inheriting its format / row stride / colorimetry.
4885    /// `batch(0)` on a tensor with `N == 1` is equivalent to the whole tensor.
4886    ///
4887    /// # Errors
4888    ///
4889    /// - [`Error::BatchIndexOutOfBounds`] if `n >= N`.
4890    /// - [`Error::InvalidShape`] if the tensor is not batched (a formatted
4891    ///   tensor whose rank lacks the leading `N`, or an empty shape).
4892    pub fn batch(&self, n: usize) -> Result<Tensor<T>> {
4893        let shape = self.shape();
4894        // With a format we know the exact per-element rank, so a missing leading
4895        // `N` is a misuse we reject rather than silently treating a spatial dim
4896        // as the batch. Raw tensors take shape[0] as `N` by contract.
4897        if let Some(fmt) = self.format {
4898            let elem_rank = match fmt.layout() {
4899                PixelLayout::SemiPlanar => 2,
4900                _ => 3,
4901            };
4902            if shape.len() != elem_rank + 1 {
4903                return Err(Error::InvalidShape(format!(
4904                    "batch(): tensor is not batched ({fmt:?} expects a leading N over a \
4905                     {elem_rank}-D element, got shape {shape:?})"
4906                )));
4907            }
4908        }
4909        let batch = *shape
4910            .first()
4911            .ok_or_else(|| Error::InvalidShape("batch(): empty shape".into()))?;
4912        if n >= batch {
4913            return Err(Error::BatchIndexOutOfBounds { index: n, batch });
4914        }
4915        let elem_shape: Vec<usize> = shape[1..].to_vec();
4916        let elem_count: usize = elem_shape.iter().product();
4917        let elem_bytes = elem_count
4918            .checked_mul(std::mem::size_of::<T>())
4919            .ok_or(Error::InvalidSize(elem_count))?;
4920        let offset = n.checked_mul(elem_bytes).ok_or(Error::InvalidSize(n))?;
4921        // For a packed `[N, H, W, C]` tensor the N tiles stack vertically in the
4922        // shared buffer, so the GL import sees one `(W, N*H)` parent and each
4923        // tile is the row-band at `y = n*H`. Snapshot that parent so the backend
4924        // imports once and renders the tile via `glViewport`. Non-packed
4925        // (planar/semi-planar) batching keeps the per-slot path for now (planar
4926        // NCHW tiling is a separate step); raw tensors have no pixel geometry.
4927        let view_origin = match self.format.map(|f| f.layout()) {
4928            Some(PixelLayout::Packed) => {
4929                let tile_h = elem_shape[0];
4930                let tile_w = elem_shape[1];
4931                // Per-row pitch of the tall `(W, N*H)` parent — padded stride if
4932                // set, else the tight row width. The GL import keys on this.
4933                let bpp = elem_shape[2] * std::mem::size_of::<T>();
4934                let parent_stride = self.effective_row_stride().unwrap_or(tile_w * bpp);
4935                Some(self.compose_view_origin(tile_w, batch * tile_h, parent_stride, 0, n * tile_h))
4936            }
4937            _ => None,
4938        };
4939        let mut t = self.subview(offset, &elem_shape)?;
4940        t.view_origin = view_origin;
4941        Ok(t)
4942    }
4943
4944    /// Borrow a rectangular spatial sub-region of an image tensor as a
4945    /// zero-copy view — the **destination/source crop** primitive.
4946    ///
4947    /// `region` is in pixels of the image's leading frame. The returned view
4948    /// shares the parent's `BufferIdentity` and addresses the sub-rectangle by
4949    /// offset + the **parent's** row pitch (so each row lands at the correct
4950    /// columns). `convert(src, &mut dst.view(rect), …)` renders into that
4951    /// sub-rectangle of `dst`; a letterbox fit then clears the view and renders
4952    /// the aspect-preserved content into its inner region. `view`/`batch`/the
4953    /// whole tensor are the one coherent destination model — there is no
4954    /// separate `dst_rect`.
4955    ///
4956    /// # Known limitation: bottom-right multi-row views
4957    ///
4958    /// A multi-row view is mapped as `parent_pitch × rows` bytes **from its own
4959    /// offset**, so the window it asks for overruns the allocation by
4960    /// `region.x * bpp` whenever the view is both offset horizontally *and*
4961    /// reaches the parent's last row. Such a view constructs fine but fails at
4962    /// [`map`](TensorTrait::map) time with [`Error::InsufficientCapacity`] —
4963    /// loudly, never as an out-of-bounds access. For a tiled destination this is
4964    /// the bottom row of tiles at every column but the first. The single-row
4965    /// case is already special-cased below (it keeps its own tight stride); the
4966    /// general fix is to clamp the last row's exposure to the view's own row
4967    /// bytes, which is not yet implemented.
4968    ///
4969    /// # Errors
4970    ///
4971    /// - [`Error::RegionOutOfBounds`] if `region` exceeds the image bounds.
4972    /// - [`Error::InvalidOperation`] if the tensor is not a packed-format image
4973    ///   (planar/semi-planar spatial sub-rects are not a single strided window;
4974    ///   use [`batch`](Self::batch) for batched planar tensors).
4975    pub fn view(&self, region: Region) -> Result<Tensor<T>> {
4976        let fmt = self.format.ok_or_else(|| {
4977            Error::InvalidOperation("view() requires a formatted image tensor".into())
4978        })?;
4979        if fmt.layout() != PixelLayout::Packed {
4980            return Err(Error::InvalidOperation(format!(
4981                "view() supports packed formats only (got {fmt:?}); use batch(n) for batched \
4982                 planar tensors"
4983            )));
4984        }
4985        let w = self
4986            .width()
4987            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image width".into()))?;
4988        let h = self
4989            .height()
4990            .ok_or_else(|| Error::InvalidOperation("view(): tensor has no image height".into()))?;
4991        if !region.fits_within(w, h) {
4992            return Err(Error::RegionOutOfBounds {
4993                region,
4994                bounds: (w, h),
4995            });
4996        }
4997        let elem = std::mem::size_of::<T>();
4998        let bpp = fmt.channels() * elem;
4999        let stride = self.effective_row_stride().unwrap_or(w * bpp);
5000        let offset = region
5001            .y
5002            .checked_mul(stride)
5003            .and_then(|yo| yo.checked_add(region.x.checked_mul(bpp)?))
5004            .ok_or(Error::InvalidSize(region.y))?;
5005        let sub_shape = fmt
5006            .allocation_shape(region.width, region.height)
5007            .ok_or_else(|| Error::InvalidShape(format!("view(): invalid shape for {fmt:?}")))?;
5008        let mut t = self.subview(offset, &sub_shape)?;
5009        // A multi-row sub-rect must advance rows by the PARENT pitch so each row
5010        // addresses the correct columns. A single-row view uses its own tight
5011        // stride — the parent pitch would make the strided `map()` (which exposes
5012        // `stride × rows`) expose a trailing row that runs past the buffer tail
5013        // for an offset (x>0 / bottom) view. The GL backend does NOT rely on this
5014        // (single-row-tight) `row_stride`: it reads the parent pitch from
5015        // `view_origin.parent_row_stride` so its import/cache pitch stays
5016        // parent-consistent for views of any height — see `ViewOrigin`.
5017        let view_stride = if region.height > 1 {
5018            stride
5019        } else {
5020            region.width * bpp
5021        };
5022        t.set_row_stride_unchecked(view_stride);
5023        // Snapshot the parent `(w, h, row_stride)` so the GL backend imports the
5024        // parent once (keyed on the parent pitch, not this view's possibly-tight
5025        // single-row stride) and renders this sub-rect as a `glViewport`/
5026        // `glScissor` ROI at `(region.x, region.y)`. Composes when viewing an
5027        // existing view.
5028        t.view_origin = Some(self.compose_view_origin(w, h, stride, region.x, region.y));
5029        Ok(t)
5030    }
5031
5032    /// Build the [`ViewOrigin`] for a new sub-region of `self`. When `self` is a
5033    /// whole tensor the snapshot names `self` as the parent; when `self` is
5034    /// already a view, the snapshot keeps the **root** parent and accumulates the
5035    /// local origin so nested views still resolve to one import.
5036    fn compose_view_origin(
5037        &self,
5038        parent_width: usize,
5039        parent_height: usize,
5040        parent_row_stride: usize,
5041        x: usize,
5042        y: usize,
5043    ) -> ViewOrigin {
5044        match self.view_origin {
5045            Some(root) => ViewOrigin {
5046                parent_width: root.parent_width,
5047                parent_height: root.parent_height,
5048                parent_row_stride: root.parent_row_stride,
5049                x: root.x.saturating_add(x),
5050                y: root.y.saturating_add(y),
5051            },
5052            None => ViewOrigin {
5053                parent_width,
5054                parent_height,
5055                parent_row_stride,
5056                x,
5057                y,
5058            },
5059        }
5060    }
5061
5062    /// The wrapped `PboTensor`, for this module's own PBO accessors.
5063    ///
5064    /// Crate-private: `PboTensor<T>` is a `static`-side value with no
5065    /// `dynamic` counterpart to lend (there the buffer lives inside
5066    /// `libedgefirst_tensor.so`), so a public borrow of it could not have
5067    /// the same signature on both backends. Callers use
5068    /// [`Self::pbo_id`]/[`Self::pbo_is_mapped`], which are the whole surface
5069    /// `edgefirst-image` ever used.
5070    pub(crate) fn as_pbo(&self) -> Option<&PboTensor<T>> {
5071        match &self.storage {
5072            TensorStorage::Pbo(p) => Some(p),
5073            _ => None,
5074        }
5075    }
5076
5077    /// The GL buffer ID backing this tensor (for GL backends / the
5078    /// cross-package protocol's `handle`). `None` when the tensor is not
5079    /// PBO-backed.
5080    pub fn pbo_id(&self) -> Option<u32> {
5081        self.as_pbo().map(PboTensor::buffer_id)
5082    }
5083
5084    /// Whether this PBO currently holds -- or is establishing -- a CPU
5085    /// mapping. `None` when the tensor is not PBO-backed.
5086    ///
5087    /// The question a GL caller is really asking is "is this buffer free
5088    /// for GL operations?", so a map or unmap still in flight answers
5089    /// `Some(true)`: the GL side must not touch the buffer until the
5090    /// transition finishes. See [`PboTensor::is_mapped`].
5091    pub fn pbo_is_mapped(&self) -> Option<bool> {
5092        self.as_pbo().map(PboTensor::is_mapped)
5093    }
5094
5095    /// The C-ABI `PboOpsVtable` address backing this tensor (for the
5096    /// cross-package protocol's `ptr` under `kind::PBO`; see
5097    /// [`crate::protocol::TensorDesc::ptr`]'s doc comment). `None` when the
5098    /// tensor is not PBO-backed.
5099    pub fn pbo_vtable_ptr(&self) -> Option<*const std::ffi::c_void> {
5100        self.as_pbo()
5101            .map(|p| p.pbo_vtable() as *const _ as *const std::ffi::c_void)
5102    }
5103
5104    /// A type-erased keepalive that must stay alive for at least as long as
5105    /// [`Self::pbo_vtable_ptr`]'s address is used -- see
5106    /// [`PboTensor::pbo_keepalive`]'s own doc comment. `None` when the
5107    /// tensor is not PBO-backed.
5108    pub fn pbo_keepalive(&self) -> Option<Arc<dyn Send + Sync>> {
5109        self.as_pbo().map(PboTensor::pbo_keepalive)
5110    }
5111
5112    /// Downcast to DMA tensor reference (for EGL import, G2D).
5113    #[cfg(target_os = "linux")]
5114    pub fn as_dma(&self) -> Option<&DmaTensor<T>> {
5115        match &self.storage {
5116            TensorStorage::Dma(d) => Some(d),
5117            _ => None,
5118        }
5119    }
5120
5121    /// Borrow the DMA-BUF file descriptor backing this tensor.
5122    ///
5123    /// # Returns
5124    ///
5125    /// A borrowed reference to the DMA-BUF file descriptor, tied to `self`'s
5126    /// lifetime.
5127    ///
5128    /// # Errors
5129    ///
5130    /// Returns `Error::NotImplemented` if the tensor is not DMA-backed.
5131    #[cfg(target_os = "linux")]
5132    pub fn dmabuf(&self) -> Result<std::os::fd::BorrowedFd<'_>> {
5133        use std::os::fd::AsFd;
5134        match &self.storage {
5135            TensorStorage::Dma(dma) => Ok(dma.fd.as_fd()),
5136            _ => Err(Error::NotImplemented(format!(
5137                "dmabuf requires DMA-backed tensor, got {:?}",
5138                self.storage.memory()
5139            ))),
5140        }
5141    }
5142
5143    /// Construct a Tensor from a PBO tensor (for GL backends that allocate PBOs).
5144    ///
5145    /// Infallible in this backend (`Ok` always) -- kept as `Result` to match
5146    /// `dynamic`'s own `Tensor::from_pbo` (`dynamic_tensor.rs`), which
5147    /// genuinely can fail: it must allocate a real `ef_tensor_*` handle
5148    /// alongside `pbo` to carry shape/dtype/format metadata (this backend
5149    /// has nowhere else to put it), an allocation this backend never
5150    /// needed since it just wraps `pbo` into `TensorStorage::Pbo` directly.
5151    /// One shared, fallible signature lets `edgefirst-image`'s own call
5152    /// sites (`gl/threaded.rs`) stay backend-agnostic.
5153    pub fn from_pbo(pbo: PboTensor<T>) -> Result<Self> {
5154        Ok(Self {
5155            storage: TensorStorage::Pbo(pbo),
5156            format: None,
5157            chroma: None,
5158            row_stride: None,
5159            plane_offset: None,
5160            quantization: None,
5161            cuda: None,
5162            colorimetry: None,
5163            cpu_access: CpuAccess::ReadWrite,
5164            compression: None,
5165            view_origin: None,
5166        })
5167    }
5168
5169    /// The CUDA registration for this tensor, if any (set at creation on CUDA devices).
5170    pub fn cuda(&self) -> Option<&crate::cuda::CudaHandle> {
5171        self.cuda.as_ref()
5172    }
5173
5174    /// Whether a CUDA registration is attached to this tensor.
5175    pub fn is_cuda_attached(&self) -> bool {
5176        self.cuda.is_some()
5177    }
5178
5179    /// Attach a CUDA handle (called by ImageProcessor::create_image after registering a PBO).
5180    pub fn set_cuda_handle(&mut self, h: crate::cuda::CudaHandle) {
5181        self.cuda = Some(h);
5182    }
5183
5184    /// Fast-fail CUDA map: None (no GL routing) when no handle; else map (PBO routes to the GL worker).
5185    ///
5186    /// Returns a scoped [`CudaMap`] guard holding the raw CUDA device pointer
5187    /// for the duration of the mapping. For GL-buffer-backed tensors the unmap is deferred until the
5188    /// guard drops, freeing the PBO for the next `convert()` call. When no CUDA handle is attached
5189    /// (the common case for plain `Mem`/`DMA` tensors without CUDA registration), returns `None`
5190    /// immediately — no GL routing, no allocation.
5191    ///
5192    /// # Example — zero-copy CUDA input with host fallback
5193    ///
5194    /// ```no_run
5195    /// use edgefirst_tensor::{Tensor, TensorMemory, TensorTrait};
5196    /// # fn feed_tensorrt(_dptr: *mut std::ffi::c_void, _bytes: usize) {}
5197    /// # fn demo(t: &Tensor<f32>) {
5198    /// // Try the zero-copy CUDA device pointer first.
5199    /// if let Some(cuda) = t.cuda_map() {
5200    ///     feed_tensorrt(cuda.device_ptr(), cuda.len());
5201    ///     // `cuda` (a CudaMap guard) unmaps when it goes out of scope, freeing
5202    ///     // the GPU buffer for the next convert().
5203    /// } else {
5204    ///     // Fall back to the host mapping when no CUDA handle is attached.
5205    ///     let _host = t.map().expect("host map fallback must succeed");
5206    ///     // `_host` is a TensorMap<f32> that derefs to &[f32].
5207    /// }
5208    /// # }
5209    /// ```
5210    ///
5211    /// # D3D11 texture tensors: the mapping is tight rows
5212    ///
5213    /// On Windows the mapping is a linear copy of the texture laid out at
5214    /// `width * bytes_per_texel`, and `len()` is that times the row count --
5215    /// *not* [`row_stride`](Self::row_stride), which reports the D3D11 staging
5216    /// pitch a CPU map sees and is the larger number on a padded backing. A
5217    /// consumer that strides the device pointer by `row_stride()` reads past
5218    /// the end of the mapping.
5219    ///
5220    /// Semi-planar formats (NV12, NV16, NV24) are the exception: their texture
5221    /// is allocated as wide as its own row pitch, so the tight row *is* the
5222    /// pitch and the mapping equals `row_stride() x combined_height` bytes.
5223    /// Striding the device pointer by `row_stride()` is exactly right there.
5224    pub fn cuda_map(&self) -> Option<crate::cuda::CudaMap<'_>> {
5225        self.cuda.as_ref()?.map()
5226    }
5227
5228    /// Writable counterpart of [`cuda_map`](Self::cuda_map). See
5229    /// [`CudaHandle::map_mut`](crate::cuda::CudaHandle::map_mut) for which
5230    /// backings distinguish the two.
5231    ///
5232    /// The D3D11 arm is the one that does: the guard's release copies the
5233    /// buffer back into the texture. Its layout is [`cuda_map`](Self::cuda_map)'s
5234    /// -- tight rows of `width * bytes_per_texel`, not `row_stride()` -- and
5235    /// writing it at the wrong pitch scrambles the image rather than failing.
5236    pub fn cuda_map_mut(&self) -> Option<crate::cuda::CudaMap<'_>> {
5237        self.cuda.as_ref()?.map_mut()
5238    }
5239
5240    /// Attempt to attach a CUDA `ExternalMemory` handle for DMA-backed tensors.
5241    ///
5242    /// On a CUDA-capable host, imports the DMA-BUF fd via
5243    /// `cudaImportExternalMemory(OpaqueFd)` and maps it to a device pointer.
5244    /// Sets `self.cuda` to a persistent `ExternalMem` handle on success. No-op
5245    /// if CUDA is unavailable, the tensor is not DMA-backed, or a handle is
5246    /// already set. Import failure is silently ignored — the tensor remains
5247    /// usable without a CUDA handle.
5248    ///
5249    /// # RUNTIME-UNVALIDATED
5250    ///
5251    /// No test platform has both `/dev/dma_heap` and a CUDA device. ABI is
5252    /// layout-asserted vs. CUDA 12.6 `driver_types.h`; the mechanism is proven
5253    /// by gpu-probe O5 on Orin. Best-effort: tensor creation never fails here.
5254    #[cfg(target_os = "linux")]
5255    pub fn try_init_dma_cuda(&mut self) {
5256        // Fast-path: already imported, CUDA not available, or not a DMA tensor.
5257        if self.cuda.is_some() || !crate::cuda::is_cuda_available() {
5258            return;
5259        }
5260        let (raw_fd, buf_size) = match &self.storage {
5261            TensorStorage::Dma(dma) => {
5262                use std::os::fd::AsRawFd;
5263                (dma.fd.as_raw_fd(), dma.buf_size)
5264            }
5265            _ => return,
5266        };
5267        if let Some((ext, dptr)) = crate::cuda::import_dma_fd(raw_fd, buf_size) {
5268            self.cuda = Some(crate::cuda::CudaHandle::new_external(ext, dptr, buf_size));
5269        }
5270    }
5271
5272    /// Attempt to attach a CUDA external-memory handle for D3D11 texture
5273    /// tensors — the Windows counterpart of
5274    /// [`try_init_dma_cuda`](Self::try_init_dma_cuda).
5275    ///
5276    /// Imports the texture's shared NT handle via
5277    /// `cudaImportExternalMemory(D3D11Resource)` and the process device's
5278    /// fence as an external semaphore, so [`cuda_map`](Self::cuda_map) hands
5279    /// out a linear device buffer whose refresh is ordered after the D3D11
5280    /// work a producer recorded with [`set_gpu_write`](Self::set_gpu_write).
5281    ///
5282    /// No-op when a handle is already attached or the tensor is not
5283    /// texture-backed. Import failure is silently ignored: the WARP adapter
5284    /// has no CUDA device at all, and a texture whose import fails is still a
5285    /// valid tensor whose `cuda_map()` is `None`.
5286    #[cfg(target_os = "windows")]
5287    pub fn try_init_d3d11_cuda(&mut self) {
5288        if self.cuda.is_some() {
5289            return;
5290        }
5291        let TensorStorage::Dma(tex) = &self.storage else {
5292            return;
5293        };
5294        if let Some(h) = tex.try_init_cuda() {
5295            self.cuda = Some(h);
5296        }
5297    }
5298}
5299
5300/// Windows D3D11 texture accessors. No `'static` bound: none of these needs
5301/// `dtype_of::<T>()`.
5302#[cfg(all(feature = "static", target_os = "windows"))]
5303impl<T> Tensor<T>
5304where
5305    T: Num + Clone + fmt::Debug + Send + Sync,
5306{
5307    /// The borrowed `ID3D11Texture2D*` backing this tensor, valid while the
5308    /// tensor lives. `None` when the tensor is not texture-backed.
5309    pub fn d3d11_texture(&self) -> Option<*mut std::ffi::c_void> {
5310        match &self.storage {
5311            TensorStorage::Dma(t) => Some(t.texture_ptr()),
5312            _ => None,
5313        }
5314    }
5315
5316    /// The texture geometry the HAL chose for this image: DXGI format, texel
5317    /// dimensions and the matching GL internal format. `None` when the tensor
5318    /// is not texture-backed.
5319    pub fn d3d11_layout(&self) -> Option<crate::d3d11_layout::D3d11ImageLayout> {
5320        match &self.storage {
5321            TensorStorage::Dma(t) => Some(t.layout()),
5322            _ => None,
5323        }
5324    }
5325
5326    /// A duplicated NT handle the caller owns: open it on a D3D12 device,
5327    /// another D3D11 device, or CUDA, or duplicate it into another process.
5328    /// Closing it does not affect this tensor.
5329    pub fn d3d11_shared_handle(&self) -> Result<std::os::windows::io::OwnedHandle> {
5330        match &self.storage {
5331            TensorStorage::Dma(t) => t.shared_handle(),
5332            _ => Err(Error::NotImplemented(
5333                "not a D3D11 texture tensor".to_owned(),
5334            )),
5335        }
5336    }
5337
5338    /// The fence handle plus value a GPU consumer waits on before reading this
5339    /// texture, or `None` when no GPU write has been recorded on it.
5340    pub fn gpu_completion(&self) -> Result<Option<crate::d3d11::GpuCompletion>> {
5341        match &self.storage {
5342            TensorStorage::Dma(t) => t.gpu_completion(),
5343            _ => Err(Error::NotImplemented(
5344                "not a D3D11 texture tensor".to_owned(),
5345            )),
5346        }
5347    }
5348
5349    /// Record that GPU work writing this texture completes at `value` of the
5350    /// process device's fence. Monotonic: an older value never displaces a
5351    /// newer one, so recording out of order is safe rather than merely
5352    /// tolerated.
5353    ///
5354    /// Takes `&self`, not `&mut self`: the fence slot is an `AtomicU64` and
5355    /// this is a `fetch_max` on it. A producer records a write on the very
5356    /// tensor its consumers already hold and are reading, so demanding
5357    /// exclusive access here would forbid the one call this exists for.
5358    pub fn set_gpu_write(&self, value: u64) -> Result<()> {
5359        match &self.storage {
5360            TensorStorage::Dma(t) => {
5361                t.set_gpu_write(value);
5362                Ok(())
5363            }
5364            _ => Err(Error::NotImplemented(
5365                "not a D3D11 texture tensor".to_owned(),
5366            )),
5367        }
5368    }
5369
5370    /// The tensor's own NT handle value -- not a duplicate -- for descriptors
5371    /// whose consumer keeps the producing tensor alive.
5372    ///
5373    /// Borrowed rather than duplicated because the C exports (task A6) hand
5374    /// out owned duplicates, which a `Copy` descriptor cannot close; the
5375    /// cross-package protocol's `kind::D3D11_TEXTURE` reads this instead.
5376    pub(crate) fn d3d11_shared_handle_value(&self) -> Option<usize> {
5377        match &self.storage {
5378            TensorStorage::Dma(t) => Some(t.shared_handle_value()),
5379            _ => None,
5380        }
5381    }
5382}
5383
5384/// The recorded GPU write value, on every platform: the descriptor path and
5385/// the Python bindings forward it without a `cfg` of their own, and only
5386/// Windows can record one.
5387#[cfg(feature = "static")]
5388impl<T> Tensor<T>
5389where
5390    T: Num + Clone + fmt::Debug + Send + Sync,
5391{
5392    /// The fence value of the newest GPU write recorded on this tensor, or 0
5393    /// when there is none: the `value` of `gpu_completion` without the
5394    /// duplicated fence handle, for a consumer that already holds the process
5395    /// fence. 0 for a tensor that is not a D3D11 texture, and on every
5396    /// platform other than Windows.
5397    pub fn gpu_write_value(&self) -> u64 {
5398        #[cfg(target_os = "windows")]
5399        {
5400            if let TensorStorage::Dma(t) = &self.storage {
5401                return t.last_gpu_write();
5402            }
5403        }
5404        0
5405    }
5406}
5407
5408/// Records the D3D11 backing's row pitch on `t` when it exceeds the natural
5409/// stride for its format and width.
5410///
5411/// The staging pitch is the row stride CPU consumers see, and for a
5412/// semi-planar tensor it is also the texture's own width, which the GL
5413/// backend's Path B shader takes as its `tex_width`. Three constructors need
5414/// it identically -- `Tensor::image`'s Windows arm and the two wrap
5415/// constructors below -- and a wrapped texture is the case that makes it
5416/// load-bearing: without it such a tensor reports the natural `even(width)`
5417/// stride while its texture is the driver's pitch wide, and the shader samples
5418/// a padded texture at the image width.
5419///
5420/// `t.format` must already be set: the natural stride comes from it.
5421#[cfg(all(feature = "static", target_os = "windows"))]
5422fn record_d3d11_row_stride<T>(t: &mut Tensor<T>, format: PixelFormat, what: &str)
5423where
5424    T: Num + Clone + fmt::Debug + Send + Sync,
5425{
5426    let pitch = match &t.storage {
5427        TensorStorage::Dma(tex) => tex.image_backing_row_stride(),
5428        _ => None,
5429    };
5430    let (Some(pitch), Some(natural)) = (pitch, t.effective_row_stride()) else {
5431        return;
5432    };
5433    if pitch > natural {
5434        log::debug!(
5435            "{what}: the D3D11 backing pitch for the {format:?} texture is {pitch} bytes \
5436             (natural {natural}); recording row stride"
5437        );
5438        t.set_row_stride_unchecked(pitch);
5439    }
5440}
5441
5442/// The two texture-importing constructors. Separate from the accessors above
5443/// because `dtype_of::<T>()` needs `T: 'static`.
5444#[cfg(all(feature = "static", target_os = "windows"))]
5445impl<T> Tensor<T>
5446where
5447    T: Num + Clone + fmt::Debug + Send + Sync + 'static,
5448{
5449    /// Wrap an existing `ID3D11Texture2D` as a tensor. The texture is checked
5450    /// against the layout the HAL would have chosen for `format`/`width`/
5451    /// `height` and must live on the HAL's process device; ownership stays
5452    /// with the caller, since this takes its own reference.
5453    ///
5454    /// The backing's row pitch is recorded as the tensor's
5455    /// [`row_stride`](Self::row_stride) when it exceeds the natural stride, so
5456    /// a wrapped semi-planar texture -- whose width *is* the pitch -- reports
5457    /// the geometry its consumers address it by.
5458    ///
5459    /// # Safety
5460    ///
5461    /// `texture` must be null or a live `ID3D11Texture2D` created on the HAL
5462    /// device ([`crate::d3d11::device()`]).
5463    pub unsafe fn from_d3d11_texture(
5464        texture: *mut std::ffi::c_void,
5465        width: usize,
5466        height: usize,
5467        format: PixelFormat,
5468        access: CpuAccess,
5469        name: Option<&str>,
5470    ) -> Result<Self> {
5471        let dtype = dtype_of::<T>().ok_or_else(|| {
5472            Error::InvalidArgument(format!(
5473                "from_d3d11_texture: element type {} has no DType",
5474                std::any::type_name::<T>()
5475            ))
5476        })?;
5477        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5478            Error::InvalidArgument(format!(
5479                "invalid dimensions {width}x{height} for format {format:?}"
5480            ))
5481        })?;
5482        // SAFETY: the caller guarantees `texture` is null or a live
5483        // `ID3D11Texture2D`; the storage takes its own reference.
5484        let storage = unsafe {
5485            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_texture(
5486                texture, width, height, format, dtype, &shape, access, name,
5487            )
5488        }?;
5489        let mut t = Self::wrap(TensorStorage::Dma(storage));
5490        t.format = Some(format);
5491        record_d3d11_row_stride(&mut t, format, "from_d3d11_texture");
5492        t.cpu_access = access;
5493        t.try_init_d3d11_cuda();
5494        Ok(t)
5495    }
5496
5497    /// Open a shared texture by its NT handle on the HAL's device. When
5498    /// `completion` is given, its fence value is waited for on the immediate
5499    /// context, so a same-device reader needs no further ordering. Both
5500    /// handles stay owned by the caller: this duplicates what it keeps.
5501    ///
5502    /// Records the backing's row pitch as
5503    /// [`row_stride`](Self::row_stride) on the same rule as
5504    /// [`from_d3d11_texture`](Self::from_d3d11_texture).
5505    ///
5506    /// # Safety
5507    ///
5508    /// `handle` must be an NT shared handle of a D3D11 texture, valid in this
5509    /// process, and `completion`'s handle a shared fence handle.
5510    pub unsafe fn from_d3d11_shared_handle(
5511        handle: std::os::windows::io::RawHandle,
5512        width: usize,
5513        height: usize,
5514        format: PixelFormat,
5515        access: CpuAccess,
5516        completion: Option<(std::os::windows::io::RawHandle, u64)>,
5517        name: Option<&str>,
5518    ) -> Result<Self> {
5519        let dtype = dtype_of::<T>().ok_or_else(|| {
5520            Error::InvalidArgument(format!(
5521                "from_d3d11_shared_handle: element type {} has no DType",
5522                std::any::type_name::<T>()
5523            ))
5524        })?;
5525        let shape = format.allocation_shape(width, height).ok_or_else(|| {
5526            Error::InvalidArgument(format!(
5527                "invalid dimensions {width}x{height} for format {format:?}"
5528            ))
5529        })?;
5530        // SAFETY: the caller guarantees the handles are valid in this process.
5531        let storage = unsafe {
5532            crate::d3d11::texture::D3d11TextureTensor::<T>::from_d3d11_shared_handle(
5533                handle, width, height, format, dtype, &shape, access, completion, name,
5534            )
5535        }?;
5536        let mut t = Self::wrap(TensorStorage::Dma(storage));
5537        t.format = Some(format);
5538        record_d3d11_row_stride(&mut t, format, "from_d3d11_shared_handle");
5539        t.cpu_access = access;
5540        t.try_init_d3d11_cuda();
5541        Ok(t)
5542    }
5543}
5544
5545// Quantization accessors — type-gated to integer element types via the
5546// sealed `IntegerType` trait. Calling `.quantization()` on a `Tensor<f32>`
5547// produces a compile error, not a runtime one.
5548#[cfg(feature = "static")]
5549impl<T> Tensor<T>
5550where
5551    T: IntegerType + Num + Clone + fmt::Debug + Send + Sync,
5552{
5553    /// Quantization metadata for this tensor, if set.
5554    pub fn quantization(&self) -> Option<&Quantization> {
5555        self.quantization.as_ref()
5556    }
5557
5558    /// Attach quantization metadata to this tensor. Validates against the
5559    /// tensor's shape — returns [`Error::QuantizationInvalid`] on any
5560    /// inconsistency (mismatched scale/zp lengths, out-of-range axis, etc.).
5561    pub fn set_quantization(&mut self, q: Quantization) -> Result<()> {
5562        q.validate(self.shape())?;
5563        self.quantization = Some(q);
5564        Ok(())
5565    }
5566
5567    /// Builder-style variant of [`Self::set_quantization`]. Consumes `self`
5568    /// and returns `Result<Self>` — on success yields the tensor with the
5569    /// attached quantization; on validation failure returns
5570    /// [`Error::QuantizationInvalid`] and drops `self` (the tensor is not
5571    /// returned in the error arm).
5572    pub fn with_quantization(mut self, q: Quantization) -> Result<Self> {
5573        self.set_quantization(q)?;
5574        Ok(self)
5575    }
5576
5577    /// Clear any quantization metadata on this tensor.
5578    pub fn clear_quantization(&mut self) {
5579        self.quantization = None;
5580    }
5581}
5582
5583#[cfg(feature = "static")]
5584fn map_strided_host_view<'a, T>(
5585    tensor: &Tensor<T>,
5586    stride: usize,
5587    access: CpuAccess,
5588    non_blocking: bool,
5589) -> Result<crate::view::HostView<'a, T>>
5590where
5591    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5592{
5593    // Rows sit at `stride`-byte spacing. The row count is the first
5594    // shape dim for packed `[H, W, C]` and semi-planar `[H*k, W]`,
5595    // but planar `[C, H, W]` stacks C planes of H rows — its surface
5596    // row count is `C × H` (`shape[0]` alone would expose a 3-row
5597    // window and truncate the map; first hit by Android planar-F16
5598    // AHardwareBuffers, whose gralloc pads the pitch — macOS/Linux
5599    // planar pitches happen to be naturally aligned so no stride was
5600    // ever recorded there).
5601    let rows = strided_map_rows(tensor)?;
5602    let total_bytes = stride.checked_mul(rows).ok_or_else(|| {
5603        Error::InvalidOperation(format!(
5604            "Tensor::map: row_stride {stride} × rows {rows} overflows usize"
5605        ))
5606    })?;
5607    map_strided_storage(tensor, stride, rows, total_bytes, access, non_blocking)
5608}
5609
5610#[cfg(feature = "static")]
5611fn strided_map_rows<T>(tensor: &Tensor<T>) -> Result<usize>
5612where
5613    T: Num + Clone + fmt::Debug + Send + Sync,
5614{
5615    match tensor.format.map(|f| f.layout()) {
5616        Some(PixelLayout::Planar) => {
5617            let s = tensor.shape();
5618            if s.len() < 2 {
5619                return Err(Error::InvalidOperation(
5620                    "Tensor::map: strided planar mapping requires [C, H, W] shape".into(),
5621                ));
5622            }
5623            s[0].checked_mul(s[1]).ok_or_else(|| {
5624                Error::InvalidOperation(format!(
5625                    "Tensor::map: planar rows {} × {} overflows usize",
5626                    s[0], s[1]
5627                ))
5628            })
5629        }
5630        _ => tensor.shape().first().copied().ok_or_else(|| {
5631            Error::InvalidOperation(
5632                "Tensor::map: strided mapping requires a non-empty shape".into(),
5633            )
5634        }),
5635    }
5636}
5637
5638#[cfg(feature = "static")]
5639fn map_strided_storage<'a, T>(
5640    tensor: &Tensor<T>,
5641    _stride: usize,
5642    _rows: usize,
5643    total_bytes: usize,
5644    access: CpuAccess,
5645    _non_blocking: bool,
5646) -> Result<crate::view::HostView<'a, T>>
5647where
5648    T: Num + Clone + fmt::Debug + Send + Sync + 'a,
5649{
5650    match &tensor.storage {
5651        #[cfg(target_os = "linux")]
5652        TensorStorage::Dma(dma) => {
5653            // `set_row_stride()` only validates `stride >= min_stride`,
5654            // not that `stride × rows` fits the DMA-BUF, so re-check
5655            // here — mapping past `buf_size` would SIGBUS on access.
5656            // `buf_size` is real kernel truth either way, not the
5657            // importer's say-so: for a self-allocated tensor it is
5658            // the allocation size; for an imported one it is
5659            // `fstat`-derived when the kernel reports a usable size
5660            // (`DmaTensor::from_fd`), or the declared logical size in
5661            // the rare fallback -- either way an out-of-range mmap
5662            // against the real dma-buf is still rejected by the
5663            // kernel, so this bound stays sound regardless.
5664            let available_bytes = dma.buf_size.saturating_sub(dma.mmap_offset);
5665            if total_bytes > available_bytes {
5666                return Err(Error::InvalidOperation(format!(
5667                    "Tensor::map: strided mapping needs {total_bytes} bytes \
5668                     but DMA buffer only has {available_bytes} available \
5669                     (buf_size={}, mmap_offset={}, stride={_stride}, rows={_rows}); \
5670                     the row_stride was likely set larger than the original allocation",
5671                    dma.buf_size, dma.mmap_offset
5672                )));
5673            }
5674            dma.map_with_byte_size(total_bytes, access)
5675        }
5676        TensorStorage::Mem(mem) => {
5677            let capacity = tensor.storage.capacity_bytes();
5678            if total_bytes > capacity {
5679                return Err(Error::InsufficientCapacity {
5680                    needed: total_bytes,
5681                    capacity,
5682                });
5683            }
5684            mem.map_with_byte_size(total_bytes, access)
5685        }
5686        #[cfg(unix)]
5687        TensorStorage::Shm(shm) => {
5688            let capacity = tensor.storage.capacity_bytes();
5689            if total_bytes > capacity {
5690                return Err(Error::InsufficientCapacity {
5691                    needed: total_bytes,
5692                    capacity,
5693                });
5694            }
5695            shm.map_with_byte_size(total_bytes, access)
5696        }
5697        // macOS/iOS: `TensorStorage::Dma` is the IOSurface. The lock yields
5698        // the full surface base address, and the row pitch
5699        // (`IOSurfaceGetBytesPerRow`) is known from the API for both
5700        // self-allocated and imported surfaces — unlike a foreign
5701        // DMA-BUF — so a strided CPU view is sound and zero-copy.
5702        #[cfg(any(target_os = "macos", target_os = "ios"))]
5703        TensorStorage::Dma(io) => {
5704            // A sub-view's window is `buf_size − view_offset`; the strided
5705            // span must fit the window, not the whole surface.
5706            let available = io.buf_size.saturating_sub(io.view_offset);
5707            if total_bytes > available {
5708                return Err(Error::InsufficientCapacity {
5709                    needed: total_bytes,
5710                    capacity: available,
5711                });
5712            }
5713            io.map_with_byte_size(total_bytes, access)
5714        }
5715        // Android: `TensorStorage::Dma` is the AHardwareBuffer. The lock
5716        // yields the full buffer base address, and the row pitch is
5717        // known from the allocator-filled descriptor — so a strided CPU
5718        // view is sound and zero-copy, same as IOSurface.
5719        #[cfg(target_os = "android")]
5720        TensorStorage::Dma(ahb) => {
5721            // A sub-view's window is `buf_size − view_offset`; the strided
5722            // span must fit the window, not the whole buffer.
5723            let available = ahb.buf_size.saturating_sub(ahb.view_offset);
5724            if total_bytes > available {
5725                return Err(Error::InsufficientCapacity {
5726                    needed: total_bytes,
5727                    capacity: available,
5728                });
5729            }
5730            ahb.map_with_byte_size(total_bytes, access)
5731        }
5732        // Windows: `TensorStorage::Dma` is the D3D11 texture, whose CPU side
5733        // is the staging copy at the driver's pitch -- the very pitch
5734        // `Tensor::image` recorded as this tensor's row stride. Its
5735        // `map_with_byte_size` bounds-checks the span against this tensor's
5736        // own window, as the IOSurface arm above does. This is also the only
5737        // backing with a real non-blocking map, so it is the only arm that
5738        // reads `_non_blocking`.
5739        #[cfg(target_os = "windows")]
5740        TensorStorage::Dma(tex) => {
5741            if _non_blocking {
5742                tex.try_map_with_byte_size(total_bytes, access)
5743            } else {
5744                tex.map_with_byte_size(total_bytes, access)
5745            }
5746        }
5747        TensorStorage::Pbo(pbo) => {
5748            // PBO: the GPU-side allocation may have a padded row stride
5749            // (e.g. 64-byte aligned). Expose the full padded buffer so a
5750            // CPU producer (JPEG decoder) and a strided convert source
5751            // can iterate rows via `effective_row_stride()` without
5752            // running past the slice — the logical `pbo.map()` view would
5753            // stop after `shape.product()` and lose bytes past row 0.
5754            // A sub-view's window is `capacity − view_offset`.
5755            let available = pbo.capacity_bytes().saturating_sub(pbo.view_offset);
5756            if total_bytes > available {
5757                return Err(Error::InsufficientCapacity {
5758                    needed: total_bytes,
5759                    capacity: available,
5760                });
5761            }
5762            pbo.map_with_byte_size(total_bytes, access)
5763        }
5764        // Every `TensorStorage` variant this build can construct is
5765        // matched explicitly above (Mem/Shm/Dma/Pbo, each cfg-gated
5766        // to the platforms it exists on) -- this catch-all exists
5767        // only so the match stays exhaustive across every
5768        // OS/cfg combination without a matching arm on every one of
5769        // them, not because any variant is meant to fall through to
5770        // it. `#[allow(unreachable_patterns)]` because on any given
5771        // platform it genuinely is unreachable.
5772        #[allow(unreachable_patterns)]
5773        _ => Err(Error::InvalidOperation(
5774            "CPU mapping of strided tensors is supported only for HAL-allocated \
5775             Mem/Shm (any platform), DMA (Linux, self-allocated or imported), \
5776             IOSurface (macOS), AHardwareBuffer (Android), D3D11 textures \
5777             (Windows), and PBO"
5778                .into(),
5779        )),
5780    }
5781}
5782
5783#[cfg(feature = "static")]
5784impl<T> Tensor<T>
5785where
5786    T: Num + Clone + fmt::Debug + Send + Sync,
5787{
5788    /// The shared body of [`TensorTrait::map_with`] and
5789    /// [`TensorTrait::try_map_with`]: the direction check, the
5790    /// declared-vs-requested telemetry, the strided and plane-offset routing,
5791    /// and finally the backing's own map. `non_blocking` chooses which of the
5792    /// backing's two maps that last step takes; everything before it is the
5793    /// same either way.
5794    fn map_impl<'a>(
5795        &self,
5796        access: CpuAccess,
5797        non_blocking: bool,
5798    ) -> Result<crate::view::HostView<'a, T>>
5799    where
5800        T: 'a,
5801    {
5802        let _span = tracing::trace_span!(
5803            "tensor.map",
5804            memory = ?self.storage.memory(),
5805            ?access,
5806            non_blocking,
5807        )
5808        .entered();
5809        if access == CpuAccess::None {
5810            return Err(Error::InvalidArgument(
5811                "map_with(CpuAccess::None) is not a mappable direction — use \
5812                 map_read()/map_write()/map_mut()"
5813                    .into(),
5814            ));
5815        }
5816        // Declared-vs-requested telemetry (all platforms): mapping beyond
5817        // the allocation-time declaration is best-effort — tolerated where
5818        // the backing is CPU-mappable regardless (Mem/Shm/dma-buf/
5819        // IOSurface), refused by the Android backend for CpuAccess::None
5820        // buffers — but always loud and counted, never silent.
5821        if !self.cpu_access.covers(access) {
5822            note_unplanned_cpu_access(
5823                self.buffer_identity().id(),
5824                &format!("{:?}", self.storage.memory()),
5825                "map access exceeds the declared CpuAccess",
5826            );
5827        }
5828        // CPU mapping of a strided tensor exposes the full padded buffer
5829        // (`row_stride × rows`) so callers can iterate rows via
5830        // `effective_row_stride()` without running past the slice. This is sound
5831        // only when the HAL owns and can size-check the allocation:
5832        //
5833        //   * Self-allocated Mem / Shm tensors (any platform) — the backing
5834        //     `Vec` / shm segment is sized by `capacity_bytes()`, checked here.
5835        //   * Self-allocated DMA tensors (Linux) — pitch padding from
5836        //     `image_with_stride()`; checked against the DMA-BUF `buf_size`.
5837        //
5838        //   * Self-allocated PBO tensors (any platform with GL) — the GL buffer
5839        //     is sized by `capacity_bytes()` and may carry 64-byte row padding;
5840        //     the JPEG decoder mmaps it and convert() reads it, both iterating
5841        //     by `row_stride`. Checked against the PBO capacity below.
5842        //
5843        // A strided **imported** DMA-BUF is accepted here too (Task 10b): the
5844        // `stride × rows <= buf_size` check just below is exactly what a
5845        // self-allocated Dma tensor relies on for its own soundness, and
5846        // `buf_size` for an imported fd is `fstat`-derived when the kernel
5847        // reports a usable size (`stat.st_size > 0` and at least the
5848        // declared shape's logical size; `DmaTensor::from_fd`) -- kernel
5849        // truth, not a caller's claim, in that case. The rare fallback (a
5850        // kernel that reports `st_size <= 0` for a DMA-BUF fd) uses the
5851        // declared logical size instead; that is not a hole here either, since
5852        // an out-of-range mmap against the real dma-buf is still rejected by
5853        // the kernel regardless of what this crate believed `buf_size` was.
5854        // So the check is equally load-bearing regardless of who allocated
5855        // the buffer. What
5856        // changed is only *which* callers can now reach this path: the
5857        // cross-package capsule protocol reconstructs an imported alias of a
5858        // HAL-owned Dma tensor and then calls `configure_image`, which
5859        // computes its stride with the exact same alignment rule a
5860        // self-allocated `Tensor::image()` used — so the value being
5861        // bounds-checked here is HAL-computed and format/width-derived, not
5862        // read from an external driver. Rejecting it unconditionally used to
5863        // make every foreign-imported GPU-backed destination fail any CPU
5864        // write path (the JPEG decoder's MCU writer among them) even when the
5865        // write was provably in-bounds. `set_row_stride()` was already
5866        // "trust the caller, bounds-check the map" for every *other* backing
5867        // (Mem/Shm/self-allocated Dma/PBO) — singling out imported Dma out
5868        // for a stricter standard was the inconsistency, not the trust model
5869        // itself. IOSurface keeps its own always-allowed strided path
5870        // unconditionally (see its arm below): a live `IOSurfaceRef` reports
5871        // its real `bytesPerRow` from the OS regardless of who allocated it,
5872        // so there was never anything to distrust there.
5873        if let Some(stride) = self.row_stride {
5874            return map_strided_host_view(self, stride, access, non_blocking);
5875        }
5876        // Offset tensors are supported for storages that apply the offset
5877        // inside their own `map()`: DMA (`DmaMap`/IOSurface adjust the mapped
5878        // base), Mem (`MemMap` adjusts the slice base), Shm (`ShmMap` adjusts
5879        // the slice base), and PBO (the staged copy starts at the offset). Every
5880        // self-allocated backing now carries a sub-region concept via `view`, so
5881        // a non-zero offset is honoured rather than rejected.
5882        if self.plane_offset.is_some_and(|o| o > 0) {
5883            let supported = matches!(self.storage, TensorStorage::Mem(_) | TensorStorage::Pbo(_));
5884            // macOS `Dma` is the IOSurface; Linux `Dma` is the DMA-BUF; Android
5885            // `Dma` is the AHardwareBuffer, Windows `Dma` is the D3D11 texture
5886            // (whose pin and map both add the storage's own `view_offset`, the
5887            // same way IOSurface does) — all apply the offset in their map.
5888            // (`Dma` is the same variant name on each, hence one `cfg(any(...))`
5889            // arm rather than four.)
5890            #[cfg(any(
5891                target_os = "linux",
5892                target_os = "macos",
5893                target_os = "ios",
5894                target_os = "android",
5895                target_os = "windows"
5896            ))]
5897            let supported = supported || matches!(self.storage, TensorStorage::Dma(_));
5898            #[cfg(unix)]
5899            let supported = supported || matches!(self.storage, TensorStorage::Shm(_));
5900            if !supported {
5901                return Err(Error::InvalidOperation(
5902                    "plane offset only supported for DMA, Mem, Shm, and PBO tensors".into(),
5903                ));
5904            }
5905        }
5906        if non_blocking {
5907            self.storage.try_map_with(access)
5908        } else {
5909            self.storage.map_with(access)
5910        }
5911    }
5912}
5913
5914#[cfg(feature = "static")]
5915impl<T> TensorTrait<T> for Tensor<T>
5916where
5917    T: Num + Clone + fmt::Debug + Send + Sync,
5918{
5919    fn new(shape: &[usize], name: Option<&str>) -> Result<Self>
5920    where
5921        Self: Sized,
5922    {
5923        Self::new(shape, None, name)
5924    }
5925
5926    #[cfg(unix)]
5927    fn from_fd(fd: std::os::fd::OwnedFd, shape: &[usize], name: Option<&str>) -> Result<Self>
5928    where
5929        Self: Sized,
5930    {
5931        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
5932        let mut t = Self::wrap(TensorStorage::from_fd(fd, shape, name)?);
5933        // Best-effort CUDA external memory import for DMA-backed tensors.
5934        // RUNTIME-UNVALIDATED: see try_init_dma_cuda().
5935        #[cfg(target_os = "linux")]
5936        t.try_init_dma_cuda();
5937        Ok(t)
5938    }
5939
5940    #[cfg(unix)]
5941    fn clone_fd(&self) -> Result<std::os::fd::OwnedFd> {
5942        self.storage.clone_fd()
5943    }
5944
5945    fn memory(&self) -> TensorMemory {
5946        self.storage.memory()
5947    }
5948
5949    fn name(&self) -> String {
5950        self.storage.name()
5951    }
5952
5953    fn shape(&self) -> &[usize] {
5954        self.storage.shape()
5955    }
5956
5957    /// Bytes available in the underlying allocation, from this tensor's
5958    /// window start -- may exceed [`TensorTrait::size`] when the backing
5959    /// carries spare room beyond the current logical shape (a pitch-aligned
5960    /// image, or a pool tensor sized for a larger image than it holds
5961    /// today). See [`crate::protocol::TensorDesc::capacity`], the
5962    /// cross-package descriptor field this backs.
5963    fn capacity_bytes(&self) -> usize {
5964        self.storage.capacity_bytes()
5965    }
5966
5967    fn reshape(&mut self, shape: &[usize]) -> Result<()> {
5968        if self.chroma.is_some() {
5969            return Err(Error::InvalidOperation(
5970                "cannot reshape a multiplane tensor — decompose planes first".into(),
5971            ));
5972        }
5973        self.storage.reshape(shape)?;
5974        self.format = None;
5975        self.row_stride = None;
5976        self.plane_offset = None;
5977        match self.storage {
5978            TensorStorage::Mem(ref mut m) => m.set_offset(0),
5979            #[cfg(target_os = "linux")]
5980            TensorStorage::Dma(ref mut dma) => dma.mmap_offset = 0,
5981            // No-op: `IoSurfaceTensor::reshape` (iosurface.rs) zeroes its
5982            // own `view_offset` before this match runs.
5983            #[cfg(any(target_os = "macos", target_os = "ios"))]
5984            TensorStorage::Dma(_) => {}
5985            // `D3d11TextureTensor::reshape` does not self-zero, so the clear
5986            // that pairs with `set_plane_offset`'s D3D11 arm lives here.
5987            #[cfg(target_os = "windows")]
5988            TensorStorage::Dma(ref mut tex) => tex.set_view_offset(0),
5989            // No-op: `AHardwareBufferTensor::reshape` (ahardwarebuffer.rs)
5990            // zeroes its own `view_offset` before this match runs, the same
5991            // as `IoSurfaceTensor`. Unlike `set_plane_offset`'s Android arm,
5992            // this one is a genuine self-zeroing no-op, not a still-
5993            // incomplete gap: `view_offset` never survives a reshape here
5994            // regardless of whether `set_plane_offset` can restore it.
5995            #[cfg(target_os = "android")]
5996            TensorStorage::Dma(_) => {}
5997            // No-op: `PboTensor::reshape` (pbo.rs) zeroes its own
5998            // `view_offset` before this match runs, the same as
5999            // `IoSurfaceTensor`/`AHardwareBufferTensor` above -- which is
6000            // why, unlike `set_format`'s clear block, this match never
6001            // needed a `Pbo` arm that writes anything.
6002            TensorStorage::Pbo(_) => {}
6003            // Unlike `PboTensor::reshape`, `ShmTensor::reshape` (shm.rs)
6004            // only revalidates the element count and swaps `self.shape` --
6005            // it never touches `self.offset` -- so this clear site needs
6006            // the arm `set_format`'s does. `cfg(unix)` mirrors the `Shm`
6007            // variant's own gate, not a per-OS multiplex.
6008            #[cfg(unix)]
6009            TensorStorage::Shm(ref mut s) => s.set_offset(0),
6010        }
6011        Ok(())
6012    }
6013
6014    /// Set the logical shape to any shape whose bytes fit the allocation,
6015    /// without [`reshape`](TensorTrait::reshape)'s equal-count constraint.
6016    ///
6017    /// Overridden rather than inherited, which it was not for most of this
6018    /// crate's life. The trait's default body is `self.reshape(shape)`, so
6019    /// a caller reaching this through `Tensor<T>` silently got the strict
6020    /// rule under a name that promises the opposite -- on both backends,
6021    /// which is why G13 cannot see it.
6022    ///
6023    /// The capacity-aware implementations were never dead: every storage
6024    /// type has one, and [`Tensor::configure_image`] reaches them by
6025    /// calling `self.storage.set_logical_shape(..)` directly. That is the
6026    /// pool-reuse path -- a decode into an oversized reusable buffer
6027    /// reconfiguring it to a smaller image without reallocating -- and it
6028    /// worked precisely because it bypassed this method. This forwards the
6029    /// same way, so the two agree.
6030    ///
6031    /// A narrower image inside a pitched backing (an IOSurface, an
6032    /// AHardwareBuffer, a D3D11 texture) still advances rows by the backing's
6033    /// pitch, so that pitch is recorded as the row stride whenever it exceeds
6034    /// the new shape's natural stride -- the same adoption `configure_image`
6035    /// makes. Without it a texture narrowed from 64 to 16 texels wide reports
6036    /// a 64-byte stride over rows that are 256 bytes apart. A single-row
6037    /// shape records its own tight stride instead, by [`view`](Self::view)'s
6038    /// rule: a strided `map()` spans `stride * rows`, and one pitched row
6039    /// runs past the backing when the window sits in the last row.
6040    fn set_logical_shape(&mut self, shape: &[usize]) -> Result<()> {
6041        self.storage.set_logical_shape(shape)?;
6042        // Only a backing with a pitch of its own, and only while the shape
6043        // still has the rank the format's row is measured on: a caller may
6044        // flatten an image to one dimension and leave the format behind.
6045        let Some(pitch) = self.storage.backing_row_stride() else {
6046            return Ok(());
6047        };
6048        let Some((rank, rows)) = self.format.map(|f| match f.layout() {
6049            PixelLayout::Packed => (3, shape.first().copied()),
6050            PixelLayout::Planar => (3, shape.get(1).copied()),
6051            PixelLayout::SemiPlanar => (2, shape.first().copied()),
6052        }) else {
6053            return Ok(());
6054        };
6055        if rank != shape.len() {
6056            return Ok(());
6057        }
6058        // The natural stride of the new shape, not of the stride recorded for
6059        // the old one.
6060        let prior = self.row_stride.take();
6061        let Some(natural) = self.effective_row_stride() else {
6062            self.row_stride = prior;
6063            return Ok(());
6064        };
6065        if rows == Some(1) {
6066            self.set_row_stride_unchecked(natural);
6067        } else if pitch > natural {
6068            self.set_row_stride_unchecked(pitch);
6069        } else {
6070            self.row_stride = prior;
6071        }
6072        Ok(())
6073    }
6074
6075    fn map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6076    where
6077        T: 'a,
6078    {
6079        self.map_impl(access, false)
6080    }
6081
6082    fn try_map_with<'a>(&self, access: CpuAccess) -> Result<crate::view::HostView<'a, T>>
6083    where
6084        T: 'a,
6085    {
6086        self.map_impl(access, true)
6087    }
6088
6089    fn buffer_identity(&self) -> &BufferIdentity {
6090        self.storage.buffer_identity()
6091    }
6092}
6093
6094// ============================================================================
6095// Platform availability helpers
6096// ============================================================================
6097
6098/// Cached result of the Linux DMA-BUF availability probe.
6099#[cfg(target_os = "linux")]
6100static DMA_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6101/// Cached result of the macOS/iOS IOSurface availability probe.
6102#[cfg(any(target_os = "macos", target_os = "ios"))]
6103static IOSURFACE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6104
6105/// Check if Linux DMA-BUF allocation is available on this system.
6106///
6107/// Linux-specific availability check (typically requires `/dev/dma_heap`
6108/// access — running as root or membership in a video/render group). For
6109/// portable code that wants "any zero-copy GPU buffer", use
6110/// [`is_gpu_buffer_available`] which also covers IOSurface on macOS.
6111///
6112/// This function caches its result after the first call.
6113#[cfg(target_os = "linux")]
6114pub fn is_dma_available() -> bool {
6115    *DMA_AVAILABLE
6116        .get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok())
6117}
6118
6119/// Always returns `false` on non-Linux platforms.
6120#[cfg(not(target_os = "linux"))]
6121pub fn is_dma_available() -> bool {
6122    false
6123}
6124
6125/// Check if macOS/iOS IOSurface allocation is available on this system.
6126///
6127/// IOSurface is part of the macOS/iOS OS and is essentially always present;
6128/// this probe catches degraded scenarios such as memory pressure or
6129/// sandboxed contexts where `IOSurfaceCreate` fails. The result is
6130/// cached after the first call.
6131///
6132/// Always returns `false` on non-Apple platforms.
6133#[cfg(any(target_os = "macos", target_os = "ios"))]
6134pub fn is_iosurface_available() -> bool {
6135    *IOSURFACE_AVAILABLE.get_or_init(|| {
6136        // Probe via the same Dma path — on macOS/iOS this routes through
6137        // IoSurfaceTensor::new.
6138        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6139    })
6140}
6141
6142#[cfg(not(any(target_os = "macos", target_os = "ios")))]
6143pub fn is_iosurface_available() -> bool {
6144    false
6145}
6146
6147/// Cached result of the Android AHardwareBuffer availability probe.
6148#[cfg(target_os = "android")]
6149static AHARDWAREBUFFER_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6150
6151/// Check if Android AHardwareBuffer allocation is available on this system.
6152///
6153/// AHardwareBuffer is part of the Android OS (public NDK ABI since API
6154/// 26) and is essentially always present; this probe catches degraded
6155/// scenarios such as memory pressure or gralloc failures. The result is
6156/// cached after the first call.
6157#[cfg(target_os = "android")]
6158pub fn is_ahardwarebuffer_available() -> bool {
6159    *AHARDWAREBUFFER_AVAILABLE.get_or_init(|| {
6160        // Probe via the same Dma path — on Android this routes through
6161        // AHardwareBufferTensor::new.
6162        Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None).is_ok()
6163    })
6164}
6165
6166/// Always returns `false` on non-Android platforms.
6167#[cfg(not(target_os = "android"))]
6168pub fn is_ahardwarebuffer_available() -> bool {
6169    false
6170}
6171
6172/// Portable probe for the platform's native zero-copy GPU buffer allocator
6173/// (DMA-BUF on Linux, IOSurface on macOS/iOS, AHardwareBuffer on Android,
6174/// D3D11 textures on Windows). Returns `false` on platforms with no
6175/// equivalent. Use this when writing cross-platform code that cares whether
6176/// the `Dma` tensor variant will work, not which underlying mechanism is used.
6177///
6178/// On Windows the `Dma` variant is image-formatted: `true` here means
6179/// [`Tensor::image`] can serve `TensorMemory::DmaBuf`, not [`Tensor::new`].
6180///
6181/// # Ordering on Windows
6182///
6183/// This probe **creates the process D3D11 device**, because the only honest
6184/// answer to "can this host allocate one" is to try. A host that must supply
6185/// its own device calls `d3d11::use_external_device` **before** this probe or
6186/// any allocation: the device is cached on first creation and installing one
6187/// afterwards is refused.
6188pub fn is_gpu_buffer_available() -> bool {
6189    #[cfg(target_os = "linux")]
6190    {
6191        is_dma_available()
6192    }
6193    #[cfg(any(target_os = "macos", target_os = "ios"))]
6194    {
6195        is_iosurface_available()
6196    }
6197    #[cfg(target_os = "android")]
6198    {
6199        is_ahardwarebuffer_available()
6200    }
6201    // No cached probe: `d3d11::device()` is itself a `OnceLock` over the
6202    // process device, so asking twice costs one atomic load.
6203    #[cfg(target_os = "windows")]
6204    {
6205        crate::d3d11::device().is_ok()
6206    }
6207    #[cfg(not(any(
6208        target_os = "linux",
6209        target_os = "macos",
6210        target_os = "ios",
6211        target_os = "android",
6212        target_os = "windows"
6213    )))]
6214    {
6215        false
6216    }
6217}
6218
6219/// Check if POSIX shared memory allocation is available on this system.
6220///
6221/// Returns `true` on Unix systems (Linux, macOS, BSD) where POSIX shared memory
6222/// is supported. Always returns `false` on non-Unix platforms (Windows).
6223///
6224/// This function caches its result after the first call for efficiency.
6225#[cfg(unix)]
6226static SHM_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6227
6228/// Check if POSIX shared memory allocation is available on this system.
6229#[cfg(unix)]
6230pub fn is_shm_available() -> bool {
6231    *SHM_AVAILABLE.get_or_init(|| Tensor::<u8>::new(&[64], Some(TensorMemory::Shm), None).is_ok())
6232}
6233
6234/// Check if POSIX shared memory allocation is available on this system.
6235///
6236/// Always returns `false` on non-Unix platforms since POSIX SHM is Unix-specific.
6237#[cfg(not(unix))]
6238pub fn is_shm_available() -> bool {
6239    false
6240}
6241
6242#[cfg(test)]
6243mod dtype_tests {
6244    use super::*;
6245
6246    #[test]
6247    fn dtype_size() {
6248        assert_eq!(DType::U8.size(), 1);
6249        assert_eq!(DType::I8.size(), 1);
6250        assert_eq!(DType::U16.size(), 2);
6251        assert_eq!(DType::I16.size(), 2);
6252        assert_eq!(DType::U32.size(), 4);
6253        assert_eq!(DType::I32.size(), 4);
6254        assert_eq!(DType::U64.size(), 8);
6255        assert_eq!(DType::I64.size(), 8);
6256        assert_eq!(DType::F16.size(), 2);
6257        assert_eq!(DType::F32.size(), 4);
6258        assert_eq!(DType::F64.size(), 8);
6259    }
6260
6261    #[test]
6262    fn dtype_name() {
6263        assert_eq!(DType::U8.name(), "u8");
6264        assert_eq!(DType::F16.name(), "f16");
6265        assert_eq!(DType::F32.name(), "f32");
6266    }
6267
6268    #[test]
6269    fn dtype_serde_roundtrip() {
6270        use serde_json;
6271        let dt = DType::F16;
6272        let json = serde_json::to_string(&dt).unwrap();
6273        let back: DType = serde_json::from_str(&json).unwrap();
6274        assert_eq!(dt, back);
6275    }
6276}
6277
6278#[cfg(test)]
6279mod image_tests {
6280    use super::*;
6281
6282    #[test]
6283    fn image_dims_from_shape_inverts_allocation_shape() {
6284        for (format, w, h) in [
6285            (PixelFormat::Rgba, 640usize, 480usize),
6286            (PixelFormat::Rgb, 641, 3),
6287            (PixelFormat::Grey, 32, 32),
6288            (PixelFormat::PlanarRgb, 640, 480),
6289            (PixelFormat::PlanarRgba, 8, 5),
6290            (PixelFormat::Nv12, 640, 481),
6291            (PixelFormat::Nv16, 33, 17),
6292            (PixelFormat::Nv24, 16, 9),
6293        ] {
6294            let shape = format.allocation_shape(w, h).unwrap();
6295            assert_eq!(
6296                image_dims_from_shape(format, &shape).unwrap(),
6297                (w, h),
6298                "{format:?} {w}x{h} via {shape:?}"
6299            );
6300        }
6301    }
6302
6303    #[test]
6304    fn image_dims_from_shape_rejects_the_wrong_rank() {
6305        // Packed and planar want rank 3, semi-planar rank 2.
6306        for (format, shape) in [
6307            (PixelFormat::Rgba, vec![480usize, 640usize]),
6308            (PixelFormat::PlanarRgb, vec![3, 480]),
6309            (PixelFormat::Nv12, vec![720, 640, 1]),
6310        ] {
6311            let err = image_dims_from_shape(format, &shape).unwrap_err();
6312            assert!(
6313                matches!(err, Error::InvalidShape(ref m) if m.contains("rank")),
6314                "{format:?} {shape:?}: {err}"
6315            );
6316        }
6317    }
6318
6319    #[test]
6320    fn image_dims_from_shape_rejects_an_unproduced_combined_height() {
6321        // NV12 maps 480 -> 720 and 481 -> 722; nothing maps to 721, so the
6322        // shape is rejected rather than rounded to 480.
6323        let err = image_dims_from_shape(PixelFormat::Nv12, &[721, 640]).unwrap_err();
6324        assert!(
6325            matches!(err, Error::InvalidShape(ref m) if m.contains("combined plane height")),
6326            "{err}"
6327        );
6328    }
6329
6330    #[test]
6331    fn allocation_shape_per_layout() {
6332        assert_eq!(
6333            PixelFormat::Rgb.allocation_shape(640, 480),
6334            Some(vec![480, 640, 3])
6335        );
6336        assert_eq!(
6337            PixelFormat::Grey.allocation_shape(640, 480),
6338            Some(vec![480, 640, 1])
6339        );
6340        assert_eq!(
6341            PixelFormat::Nv12.allocation_shape(640, 480),
6342            Some(vec![720, 640])
6343        );
6344        // Odd height: combined-plane height is `481 + ceil(481/2)` = 481 + 241
6345        // = 722 rows. Logical height is recovered as `722 * 2 / 3` = 481.
6346        assert_eq!(
6347            PixelFormat::Nv12.allocation_shape(640, 481),
6348            Some(vec![722, 640])
6349        );
6350        // Odd width: shape carries the LOGICAL width (641).
6351        // The 64-aligned stride (>= 642) is stored separately on the Tensor.
6352        assert_eq!(
6353            PixelFormat::Nv12.allocation_shape(641, 480),
6354            Some(vec![720, 641])
6355        );
6356        // NV16 odd width: same — logical width in shape, stride separate.
6357        assert_eq!(
6358            PixelFormat::Nv16.allocation_shape(641, 480),
6359            Some(vec![960, 641])
6360        );
6361        assert_eq!(
6362            PixelFormat::PlanarRgb.allocation_shape(640, 480),
6363            Some(vec![3, 480, 640])
6364        );
6365        assert_eq!(
6366            PixelFormat::Nv16.allocation_shape(640, 480),
6367            Some(vec![960, 640])
6368        );
6369    }
6370
6371    #[test]
6372    fn raw_tensor_has_no_format() {
6373        let t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6374        assert!(t.format().is_none());
6375        assert!(t.width().is_none());
6376        assert!(t.height().is_none());
6377        assert!(!t.is_multiplane());
6378        assert!(t.chroma().is_none());
6379    }
6380
6381    #[test]
6382    fn image_tensor_packed() {
6383        let t = Tensor::<u8>::image(
6384            640,
6385            480,
6386            PixelFormat::Rgba,
6387            None,
6388            crate::CpuAccess::ReadWrite,
6389        )
6390        .unwrap();
6391        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6392        assert_eq!(t.width(), Some(640));
6393        assert_eq!(t.height(), Some(480));
6394        assert_eq!(t.shape(), &[480, 640, 4]);
6395        assert!(!t.is_multiplane());
6396    }
6397
6398    #[test]
6399    fn image_tensor_planar() {
6400        let t = Tensor::<u8>::image(
6401            640,
6402            480,
6403            PixelFormat::PlanarRgb,
6404            None,
6405            crate::CpuAccess::ReadWrite,
6406        )
6407        .unwrap();
6408        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6409        assert_eq!(t.width(), Some(640));
6410        assert_eq!(t.height(), Some(480));
6411        assert_eq!(t.shape(), &[3, 480, 640]);
6412    }
6413
6414    #[test]
6415    #[cfg(target_os = "macos")]
6416    fn image_tensor_dma_non_aligned_packed_width_pads_zero_copy() {
6417        // Declares this test as an fd-opener; see FD_LOCK.
6418        let _lock = crate::tests::fd_lock_shared();
6419        // RGBA u8 at width=4 → 4*4 = 16 bytes/row, not 64-byte aligned. RGBA has
6420        // a real IOSurface FourCC, so an explicit `Some(TensorMemory::DmaBuf)`
6421        // request now allocates a padded image IOSurface (64-aligned
6422        // `bytes_per_row`) and records the stride — a fully zero-copy buffer GL
6423        // can bind and the CPU can map via the strided path. (Previously this
6424        // failed loudly to avoid an 'L008' byte-bag downgrade; with a real
6425        // FourCC surface that concern no longer applies.)
6426        let t = Tensor::<u8>::image(
6427            4,
6428            4,
6429            PixelFormat::Rgba,
6430            Some(TensorMemory::DmaBuf),
6431            crate::CpuAccess::ReadWrite,
6432        )
6433        .expect("padded RGBA IOSurface should allocate");
6434        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6435        assert_eq!(t.width(), Some(4));
6436        assert_eq!(t.height(), Some(4));
6437        let stride = t.effective_row_stride().expect("stride");
6438        assert_eq!(stride % 64, 0, "padded to 64-byte row alignment");
6439        assert!(stride >= 16);
6440        // A CPU map exposes the full padded surface for strided iteration.
6441        let m = t.map().expect("strided IOSurface map");
6442        assert_eq!(m.as_slice().len(), stride * 4);
6443    }
6444
6445    /// `per_pixel_bytes` that doesn't divide 64 evenly (e.g. RGB u8 with
6446    /// 3 B/pixel) makes a "Pad width to N" suggestion structurally
6447    /// impossible — there is no integer width whose `width * 3` is a
6448    /// multiple of 64. The error must still fire (no silent SHM
6449    /// fallback for explicit-DMA requests) and must spell out the
6450    /// alignment requirement; it just omits the misleading "pad to N"
6451    /// hint instead of printing a number whose row pitch still won't
6452    /// align.
6453    #[test]
6454    #[cfg(target_os = "macos")]
6455    fn image_tensor_dma_rejects_indivisible_pixel_pitch_without_pad_hint() {
6456        // Declares this test as an fd-opener; see FD_LOCK.
6457        let _lock = crate::tests::fd_lock_shared();
6458        // Width=10 RGB f32 → 120 B/row, not 64-byte aligned, and (Rgb,
6459        // F32) has no IOSurface mapping so the padded-stride tolerance
6460        // does not apply. The next 64-multiple (128 B) isn't an integer
6461        // multiple of 12 B/pixel, so the "pad width to N" hint can't
6462        // produce a valid number and must be omitted. (Rgb u8 used to be
6463        // this test's subject but now has a real RGBA8888 mapping with
6464        // padded-stride tolerance — see the test below.)
6465        let err = Tensor::<f32>::image(
6466            10,
6467            10,
6468            PixelFormat::Rgb,
6469            Some(TensorMemory::DmaBuf),
6470            crate::CpuAccess::ReadWrite,
6471        )
6472        .expect_err("RGB f32 with 12 B/pixel and non-aligned width must be rejected");
6473        match err {
6474            Error::InvalidArgument(msg) => {
6475                assert!(
6476                    msg.contains("64-byte aligned"),
6477                    "error must still name the alignment requirement: {msg}"
6478                );
6479                assert!(
6480                    !msg.contains("Pad width"),
6481                    "indivisible per-pixel pitch makes a width suggestion impossible; \
6482                     hint must be omitted, got: {msg}"
6483                );
6484                assert!(
6485                    msg.contains("memory=None") && msg.contains("TensorMemory::Mem"),
6486                    "error must still list the always-applicable alternatives: {msg}"
6487                );
6488            }
6489            other => panic!("expected InvalidArgument, got {other:?}"),
6490        }
6491    }
6492
6493    #[test]
6494    #[cfg(target_os = "macos")]
6495    fn image_tensor_dma_packed_rgb_u8_contract() {
6496        // Declares this test as an fd-opener; see FD_LOCK.
6497        let _lock = crate::tests::fd_lock_shared();
6498        // Packed RGB u8 @Dma is a designed RGBA8888 mapping at
6499        // (W*3/4, H) — the INT8 NPU input layout, shared with Android.
6500        // width%4 != 0 cannot form whole texels → loud InvalidArgument…
6501        let err = Tensor::<u8>::image(
6502            10,
6503            10,
6504            PixelFormat::Rgb,
6505            Some(TensorMemory::DmaBuf),
6506            crate::CpuAccess::ReadWrite,
6507        )
6508        .expect_err("Rgb u8 width%4!=0 must be rejected");
6509        assert!(
6510            matches!(&err, Error::InvalidArgument(m) if m.contains("width%4==0")),
6511            "got {err:?}"
6512        );
6513        // …width%4 == 0 with a non-64-aligned pitch allocates PADDED
6514        // (36 B rows → 64 B surface pitch, recorded on the tensor)…
6515        let t = Tensor::<u8>::image(
6516            12,
6517            4,
6518            PixelFormat::Rgb,
6519            Some(TensorMemory::DmaBuf),
6520            crate::CpuAccess::ReadWrite,
6521        )
6522        .expect("width 12 Rgb u8 must allocate padded");
6523        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6524        assert!(
6525            t.row_stride()
6526                .is_some_and(|s| s >= 64 && s.is_multiple_of(64)),
6527            "padded pitch must be recorded: {:?}",
6528            t.row_stride()
6529        );
6530        // …and the aligned model-input width stays flat (640*3 = 1920 is
6531        // 64-aligned → no recorded stride, the buffer IS [H, W, 3]).
6532        let t = Tensor::<u8>::image(
6533            640,
6534            8,
6535            PixelFormat::Rgb,
6536            Some(TensorMemory::DmaBuf),
6537            crate::CpuAccess::ReadWrite,
6538        )
6539        .expect("width 640 Rgb u8 must allocate flat");
6540        assert_eq!(t.row_stride(), None);
6541        // I8 shares the layout (INT8 shader bias, not a format change).
6542        let t = Tensor::<i8>::image(
6543            640,
6544            8,
6545            PixelFormat::Rgb,
6546            Some(TensorMemory::DmaBuf),
6547            crate::CpuAccess::ReadWrite,
6548        )
6549        .expect("Rgb i8 shares the RGBA8888 mapping");
6550        assert_eq!(t.memory(), TensorMemory::DmaBuf);
6551    }
6552
6553    #[test]
6554    #[cfg(target_os = "macos")]
6555    fn image_tensor_dma_planar_f16_alignment() {
6556        // Declares this test as an fd-opener; see FD_LOCK.
6557        let _lock = crate::tests::fd_lock_shared();
6558        // PlanarRgb F16 uses single-channel row pitch (width * 2 bytes).
6559        // Width=16 → 32 bytes/row (not aligned); width=32 → 64 bytes/row (aligned).
6560        let err = Tensor::<half::f16>::image(
6561            16,
6562            16,
6563            PixelFormat::PlanarRgb,
6564            Some(TensorMemory::DmaBuf),
6565            crate::CpuAccess::ReadWrite,
6566        )
6567        .expect_err("width=16 PlanarRgb F16 is 32-byte row, must reject");
6568        assert!(matches!(err, Error::InvalidArgument(_)), "got {err:?}");
6569        // 32 wide should work.
6570        let t = Tensor::<half::f16>::image(
6571            32,
6572            8,
6573            PixelFormat::PlanarRgb,
6574            Some(TensorMemory::DmaBuf),
6575            crate::CpuAccess::ReadWrite,
6576        )
6577        .expect("width=32 PlanarRgb F16 is 64-byte row, must succeed");
6578        assert_eq!(t.format(), Some(PixelFormat::PlanarRgb));
6579    }
6580
6581    #[test]
6582    fn image_tensor_semi_planar_contiguous() {
6583        let t = Tensor::<u8>::image(
6584            640,
6585            480,
6586            PixelFormat::Nv12,
6587            None,
6588            crate::CpuAccess::ReadWrite,
6589        )
6590        .unwrap();
6591        assert_eq!(t.format(), Some(PixelFormat::Nv12));
6592        assert_eq!(t.width(), Some(640));
6593        assert_eq!(t.height(), Some(480));
6594        // NV12: H*3/2 = 720
6595        assert_eq!(t.shape(), &[720, 640]);
6596        assert!(!t.is_multiplane());
6597    }
6598
6599    #[test]
6600    #[cfg(target_os = "linux")]
6601    fn image_tensor_with_stride_preserves_logical_width() {
6602        // Declares this test as an fd-opener; see FD_LOCK.
6603        let _lock = crate::tests::fd_lock_shared();
6604        // Skip if DMA not available (e.g. sandboxed CI lacking dma_heap access).
6605        if !is_dma_available() {
6606            crate::test_support::report_skip("DMA heap not available");
6607            return;
6608        }
6609        // 3004×1688 RGBA8: natural pitch 12016, padded to 12032 (64-aligned).
6610        let stride = 12032;
6611        let t = Tensor::<u8>::image_with_stride(
6612            3004,
6613            1688,
6614            PixelFormat::Rgba,
6615            stride,
6616            Some(TensorMemory::DmaBuf),
6617            crate::CpuAccess::ReadWrite,
6618        )
6619        .unwrap();
6620        // Logical dimensions unchanged by padding — this is the contract.
6621        assert_eq!(t.width(), Some(3004));
6622        assert_eq!(t.height(), Some(1688));
6623        assert_eq!(t.shape(), &[1688, 3004, 4]);
6624        // Stride is carried separately and reports the padded pitch.
6625        assert_eq!(t.effective_row_stride(), Some(stride));
6626        // Buffer is sized to stride × height so the full padded layout fits,
6627        // and CPU map() works for self-allocated strided DMA tensors.
6628        use crate::TensorMapTrait;
6629        {
6630            let map = t.map().unwrap();
6631            assert!(
6632                map.as_slice().len() >= stride * 1688,
6633                "mapped buffer {} bytes < expected {}",
6634                map.as_slice().len(),
6635                stride * 1688
6636            );
6637        }
6638        // CPU write access works too — iterate rows using the padded stride,
6639        // touch only the active `width × bpp` region, verify it round-trips.
6640        {
6641            let mut map = t.map().unwrap();
6642            let slice = map.as_mut_slice();
6643            for y in 0..1688 {
6644                let row_start = y * stride;
6645                for x in 0..3004 {
6646                    let p = row_start + x * 4;
6647                    slice[p] = (y & 0xFF) as u8;
6648                    slice[p + 1] = (x & 0xFF) as u8;
6649                    slice[p + 2] = 0x42;
6650                    slice[p + 3] = 0xFF;
6651                }
6652            }
6653        }
6654        {
6655            let map = t.map().unwrap();
6656            let slice = map.as_slice();
6657            // Sample a few pixels to confirm the round-trip.
6658            assert_eq!(slice[0], 0x00);
6659            assert_eq!(slice[1], 0x00);
6660            assert_eq!(slice[2], 0x42);
6661            assert_eq!(slice[3], 0xFF);
6662            let mid = 100 * stride + 50 * 4;
6663            assert_eq!(slice[mid], 100);
6664            assert_eq!(slice[mid + 1], 50);
6665            assert_eq!(slice[mid + 2], 0x42);
6666        }
6667    }
6668
6669    #[test]
6670    #[cfg(target_os = "linux")]
6671    fn image_tensor_foreign_strided_map_is_bounds_checked_not_refused() {
6672        // An imported (foreign) DMA-BUF with a row stride used to be refused
6673        // outright. That changed deliberately: the cross-package capsule
6674        // protocol reconstructs an imported alias of a HAL-owned Dma tensor,
6675        // and refusing every strided CPU map broke the flagship
6676        // create_image() -> decode_file_into() -> convert() pipeline on Linux.
6677        //
6678        // What replaced the refusal is a bounds check against `buf_size`,
6679        // which for an imported fd is `fstat`-derived kernel truth rather
6680        // than the caller's claim. So the property worth testing is no longer
6681        // "foreign strided maps fail" but "they succeed exactly when they
6682        // fit" -- which is what actually keeps the mapping sound.
6683        //
6684        // This test previously asserted the old refusal and went red on every
6685        // board with a dma-heap when the behaviour changed, unnoticed because
6686        // it needs both Linux and a dma-heap: it skips on macOS and on Orin
6687        // (nvmap, no CONFIG_DMABUF_HEAPS).
6688        if !is_dma_available() {
6689            crate::test_support::report_skip("DMA heap not available");
6690            return;
6691        }
6692        let backing =
6693            Tensor::<u8>::new(&[240 * 320 * 4], Some(TensorMemory::DmaBuf), None).unwrap();
6694
6695        // In bounds: the natural stride for the declared image fits the
6696        // allocation, so the map must succeed.
6697        {
6698            let fd = backing.clone_fd().unwrap();
6699            let shape = [240usize, 320, 4];
6700            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6701            let mut t = Tensor::<u8>::wrap(storage);
6702            t.set_format(PixelFormat::Bgra).unwrap();
6703            t.set_row_stride(320 * 4).unwrap();
6704            assert!(
6705                t.map().is_ok(),
6706                "an in-bounds strided map of an imported DMA-BUF must be \
6707                 accepted -- refusing it is what broke decode-into-GPU-tensor"
6708            );
6709        }
6710
6711        // Out of bounds: a stride the allocation cannot back must still be
6712        // refused, and the error must say so. This is the half that carries
6713        // the soundness.
6714        {
6715            let fd = backing.clone_fd().unwrap();
6716            let shape = [240usize, 320, 4];
6717            let storage = TensorStorage::<u8>::from_fd(fd, &shape, None).unwrap();
6718            let mut t = Tensor::<u8>::wrap(storage);
6719            t.set_format(PixelFormat::Bgra).unwrap();
6720            // Double the pitch: 240 rows x 2560 bytes is twice the allocation.
6721            t.set_row_stride(320 * 4 * 2).unwrap();
6722            let err = t.map();
6723            assert!(
6724                matches!(err, Err(Error::InvalidOperation(_))),
6725                "a strided map larger than the DMA allocation must be refused"
6726            );
6727        }
6728    }
6729
6730    #[test]
6731    #[cfg(target_os = "linux")]
6732    fn image_tensor_with_stride_map_rejects_tampered_stride() {
6733        // Declares this test as an fd-opener; see FD_LOCK.
6734        let _lock = crate::tests::fd_lock_shared();
6735        // Round-3 PR feedback (C1): `set_row_stride` is public and only
6736        // validates `stride >= min_stride`, not that the new stride × height
6737        // fits the underlying buffer. A caller that tampers with the stride
6738        // after allocation must not be able to coerce `Tensor::map()` into
6739        // returning a slice larger than the backing mmap (that would be UB
6740        // in `DmaMap::as_slice`).
6741        if !is_dma_available() {
6742            crate::test_support::report_skip("DMA heap not available");
6743            return;
6744        }
6745        // Allocate a 640×480 RGBA8 padded canvas (stride = 3072 = 768 px).
6746        // Backing buffer is 3072 × 480 = 1,474,560 bytes.
6747        let mut t = Tensor::<u8>::image_with_stride(
6748            640,
6749            480,
6750            PixelFormat::Rgba,
6751            3072,
6752            Some(TensorMemory::DmaBuf),
6753            crate::CpuAccess::ReadWrite,
6754        )
6755        .unwrap();
6756        // Tamper: push the stride up to 4 × the original. This is >=
6757        // min_stride (2560), so `set_row_stride` accepts it.
6758        t.set_row_stride(12288).unwrap();
6759        // Map must now refuse — 12288 × 480 = 5,898,240 > 1,474,560.
6760        let err = t.map();
6761        assert!(
6762            matches!(err, Err(Error::InvalidOperation(_))),
6763            "map() with oversized stride must return InvalidOperation"
6764        );
6765    }
6766
6767    #[test]
6768    fn dma_tensor_new_with_byte_size_rejects_shape_overflow() {
6769        // Round-3 PR feedback (C3): shape.product() * sizeof(T) must use
6770        // checked arithmetic so a pathological shape can't wrap usize and
6771        // make the byte_size-vs-logical-size comparison incorrect.
6772        //
6773        // This test only exercises the overflow rejection path, which is
6774        // pure-Rust and doesn't touch dma_heap — safe to run on any target.
6775        #[cfg(target_os = "linux")]
6776        {
6777            let err = crate::dma::DmaTensor::<u64>::new_with_byte_size(
6778                &[usize::MAX, 2, 2],
6779                usize::MAX,
6780                None,
6781            );
6782            assert!(
6783                matches!(err, Err(Error::InvalidArgument(_))),
6784                "new_with_byte_size must detect shape.product() overflow"
6785            );
6786        }
6787    }
6788
6789    #[test]
6790    #[cfg(target_os = "linux")]
6791    fn image_tensor_with_stride_rejects_too_small_stride() {
6792        // Declares this test as an fd-opener; see FD_LOCK.
6793        let _lock = crate::tests::fd_lock_shared();
6794        // 640×480 RGBA8 natural pitch = 2560, request 2400 → should error.
6795        let err = Tensor::<u8>::image_with_stride(
6796            640,
6797            480,
6798            PixelFormat::Rgba,
6799            2400,
6800            Some(TensorMemory::DmaBuf),
6801            crate::CpuAccess::ReadWrite,
6802        );
6803        assert!(matches!(err, Err(Error::InvalidArgument(_))));
6804    }
6805
6806    #[test]
6807    #[cfg(target_os = "linux")]
6808    fn image_tensor_with_stride_rejects_non_packed() {
6809        // Declares this test as an fd-opener; see FD_LOCK.
6810        let _lock = crate::tests::fd_lock_shared();
6811        // NV12 is SemiPlanar → not supported. (Linux-only because
6812        // `TensorMemory::DmaBuf` itself is a Linux-only enum variant.)
6813        let err = Tensor::<u8>::image_with_stride(
6814            640,
6815            480,
6816            PixelFormat::Nv12,
6817            640,
6818            Some(TensorMemory::DmaBuf),
6819            crate::CpuAccess::ReadWrite,
6820        );
6821        assert!(matches!(err, Err(Error::NotImplemented(_))));
6822    }
6823
6824    #[test]
6825    fn set_format_valid() {
6826        let mut t = Tensor::<u8>::new(&[480, 640, 3], None, None).unwrap();
6827        assert!(t.format().is_none());
6828        t.set_format(PixelFormat::Rgb).unwrap();
6829        assert_eq!(t.format(), Some(PixelFormat::Rgb));
6830        assert_eq!(t.width(), Some(640));
6831        assert_eq!(t.height(), Some(480));
6832    }
6833
6834    #[test]
6835    fn set_format_invalid_shape() {
6836        let mut t = Tensor::<u8>::new(&[480, 640, 4], None, None).unwrap();
6837        // RGB expects 3 channels, not 4
6838        let err = t.set_format(PixelFormat::Rgb);
6839        assert!(err.is_err());
6840        // Original tensor is unmodified
6841        assert!(t.format().is_none());
6842    }
6843
6844    #[test]
6845    fn reshape_clears_format() {
6846        let mut t = Tensor::<u8>::image(
6847            640,
6848            480,
6849            PixelFormat::Rgba,
6850            None,
6851            crate::CpuAccess::ReadWrite,
6852        )
6853        .unwrap();
6854        assert_eq!(t.format(), Some(PixelFormat::Rgba));
6855        // Reshape to flat — format cleared
6856        t.reshape(&[480 * 640 * 4]).unwrap();
6857        assert!(t.format().is_none());
6858    }
6859
6860    #[test]
6861    fn from_planes_nv12() {
6862        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6863        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6864        let img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
6865        assert_eq!(img.format(), Some(PixelFormat::Nv12));
6866        assert!(img.is_multiplane());
6867        assert!(img.chroma().is_some());
6868        assert_eq!(img.width(), Some(640));
6869        assert_eq!(img.height(), Some(480));
6870    }
6871
6872    #[test]
6873    fn from_planes_rejects_non_semiplanar() {
6874        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6875        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6876        let err = Tensor::from_planes(y, uv, PixelFormat::Rgb);
6877        assert!(err.is_err());
6878    }
6879
6880    #[test]
6881    fn reshape_multiplane_errors() {
6882        let y = Tensor::<u8>::new(&[480, 640], None, None).unwrap();
6883        let uv = Tensor::<u8>::new(&[240, 640], None, None).unwrap();
6884        let mut img = Tensor::from_planes(y, uv, PixelFormat::Nv12).unwrap();
6885        let err = img.reshape(&[480 * 640 + 240 * 640]);
6886        assert!(err.is_err());
6887    }
6888}
6889
6890#[cfg(test)]
6891mod compression_tests {
6892    use super::*;
6893
6894    #[test]
6895    fn desc_builder_roundtrips() {
6896        let desc = ImageDesc::new(640, 480, PixelFormat::Rgba, DType::U8)
6897            .with_memory(Some(TensorMemory::Mem))
6898            .with_access(CpuAccess::Read)
6899            .with_compression(Compression::Any);
6900        assert_eq!(desc.width(), 640);
6901        assert_eq!(desc.height(), 480);
6902        assert_eq!(desc.format(), PixelFormat::Rgba);
6903        assert_eq!(desc.dtype(), DType::U8);
6904        assert_eq!(desc.memory(), Some(TensorMemory::Mem));
6905        assert_eq!(desc.access(), CpuAccess::Read);
6906        assert_eq!(desc.compression(), Some(Compression::Any));
6907
6908        // Defaults: auto memory, hardware-only, no request.
6909        let plain = ImageDesc::new(2, 2, PixelFormat::Grey, DType::U8);
6910        assert_eq!(plain.memory(), None);
6911        assert_eq!(plain.access(), CpuAccess::None);
6912        assert_eq!(plain.compression(), None);
6913    }
6914
6915    #[test]
6916    fn desc_dtype_must_match_element_type() {
6917        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::F32);
6918        match Tensor::<u8>::image_desc(&desc) {
6919            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("dtype")),
6920            other => panic!("expected InvalidArgument, got {other:?}"),
6921        }
6922    }
6923
6924    #[test]
6925    fn compression_with_cpu_access_is_invalid() {
6926        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
6927            .with_access(CpuAccess::ReadWrite)
6928            .with_compression(Compression::Any);
6929        match Tensor::<u8>::image_desc(&desc) {
6930            Err(Error::InvalidArgument(msg)) => assert!(msg.contains("CpuAccess::None")),
6931            other => panic!("expected InvalidArgument, got {other:?}"),
6932        }
6933    }
6934
6935    #[cfg(not(target_os = "android"))]
6936    #[test]
6937    fn scheme_request_off_android_is_not_implemented() {
6938        let desc = ImageDesc::new(4, 4, PixelFormat::Rgba, DType::U8)
6939            .with_compression(Compression::Scheme(CompressionScheme::Ubwc));
6940        match Tensor::<u8>::image_desc(&desc) {
6941            Err(Error::NotImplemented(msg)) => assert!(msg.contains("Ubwc")),
6942            other => panic!("expected NotImplemented, got {other:?}"),
6943        }
6944    }
6945
6946    #[cfg(not(target_os = "android"))]
6947    #[test]
6948    fn any_request_off_android_resolves_linear_and_counts() {
6949        let before = compression_fallback_count();
6950        let desc = ImageDesc::new(64, 64, PixelFormat::Rgba, DType::U8)
6951            .with_memory(Some(TensorMemory::Mem))
6952            .with_compression(Compression::Any);
6953        let t = Tensor::<u8>::image_desc(&desc).unwrap();
6954        assert_eq!(t.compression(), None);
6955        assert!(compression_fallback_count() > before);
6956    }
6957
6958    #[test]
6959    fn desc_without_request_matches_classic_constructor() {
6960        let desc = ImageDesc::new(32, 32, PixelFormat::Rgba, DType::U8)
6961            .with_memory(Some(TensorMemory::Mem))
6962            .with_access(CpuAccess::ReadWrite);
6963        let t = Tensor::<u8>::image_desc(&desc).unwrap();
6964        assert_eq!(t.compression(), None);
6965        assert_eq!(t.cpu_access(), CpuAccess::ReadWrite);
6966        assert_eq!(t.width(), Some(32));
6967        // Mappable exactly like the classic constructor's result.
6968        let m = t.map_read().unwrap();
6969        assert_eq!(m.as_slice().len(), 32 * 32 * 4);
6970    }
6971
6972    #[test]
6973    fn configure_image_preserves_compression_and_views_inherit() {
6974        // No host platform records a scheme, so emulate the recording to
6975        // pin the preserve/inherit semantics (the physical layout does
6976        // not change when the logical image is reconfigured).
6977        let mut t = Tensor::<u8>::image(
6978            64,
6979            64,
6980            PixelFormat::Rgba,
6981            Some(TensorMemory::Mem),
6982            CpuAccess::None,
6983        )
6984        .unwrap();
6985        t.compression = Some(CompressionScheme::Ubwc);
6986        t.configure_image(32, 32, PixelFormat::Rgba).unwrap();
6987        assert_eq!(t.compression(), Some(CompressionScheme::Ubwc));
6988        let view = t.subview(0, &[16, 32, 4]).unwrap();
6989        assert_eq!(view.compression(), Some(CompressionScheme::Ubwc));
6990    }
6991
6992    #[test]
6993    fn tensor_dyn_dispatches_desc_and_compression() {
6994        let desc = ImageDesc::new(16, 16, PixelFormat::Rgba, DType::U8)
6995            .with_memory(Some(TensorMemory::Mem))
6996            .with_access(CpuAccess::ReadWrite);
6997        let t = TensorDyn::image_desc(&desc).unwrap();
6998        assert_eq!(t.compression(), None);
6999        assert!(matches!(t, TensorDyn::U8(_)));
7000    }
7001}
7002
7003#[cfg(test)]
7004mod cpu_access_tests {
7005    use super::*;
7006
7007    #[test]
7008    fn covers_matrix() {
7009        use CpuAccess::*;
7010        // Every declaration covers a narrower or equal request…
7011        for a in [None, Read, Write, ReadWrite] {
7012            assert!(a.covers(None), "{a:?} must cover None");
7013            assert!(ReadWrite.covers(a), "ReadWrite must cover {a:?}");
7014        }
7015        assert!(Read.covers(Read));
7016        assert!(Write.covers(Write));
7017        // …and never a wider one.
7018        assert!(!None.covers(Read));
7019        assert!(!None.covers(Write));
7020        assert!(!Read.covers(Write));
7021        assert!(!Read.covers(ReadWrite));
7022        assert!(!Write.covers(Read));
7023        assert!(!Write.covers(ReadWrite));
7024    }
7025
7026    #[test]
7027    fn map_with_none_is_invalid() {
7028        let t = Tensor::<u8>::new(&[16], Some(TensorMemory::Mem), None).unwrap();
7029        match t.map_with(CpuAccess::None) {
7030            Err(Error::InvalidArgument(_)) => {}
7031            Err(other) => panic!("expected InvalidArgument, got {other:?}"),
7032            Ok(_) => panic!("map_with(CpuAccess::None) must not succeed"),
7033        }
7034    }
7035
7036    #[test]
7037    fn read_map_rejects_mutation_uniformly() {
7038        // Mem backend: map_read yields a working read view whose mutable
7039        // accessor panics (the uniform cross-backend contract).
7040        let t = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7041        t.map_mut().unwrap().as_mut_slice().copy_from_slice(&[7; 8]);
7042        let ro = t.map_read().unwrap();
7043        assert_eq!(ro.as_slice(), &[7; 8]);
7044        drop(ro);
7045        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7046            let mut ro = t.map_read().unwrap();
7047            let _ = ro.as_mut_slice();
7048        }));
7049        assert!(result.is_err(), "as_mut_slice through map_read must panic");
7050    }
7051
7052    #[test]
7053    fn write_and_rw_maps_stay_mutable() {
7054        let t = Tensor::<u8>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
7055        t.map_write()
7056            .unwrap()
7057            .as_mut_slice()
7058            .copy_from_slice(&[1; 4]);
7059        t.map().unwrap().as_mut_slice().copy_from_slice(&[2; 4]);
7060        assert_eq!(t.map_read().unwrap().as_slice(), &[2; 4]);
7061    }
7062
7063    /// The read-only IOSurface lock path: a `map_read` must observe data
7064    /// written through a prior read-write lock, and its unlock (which
7065    /// skips the cache flush) must not disturb subsequent reads.
7066    #[test]
7067    #[cfg(target_os = "macos")]
7068    fn iosurface_read_only_lock_roundtrip() {
7069        // Declares this test as an fd-opener; see FD_LOCK.
7070        let _lock = crate::tests::fd_lock_shared();
7071        let Ok(t) = Tensor::<u8>::new(&[64], Some(TensorMemory::DmaBuf), None) else {
7072            crate::test_support::report_skip("IOSurface unavailable");
7073            return;
7074        };
7075        {
7076            let mut m = t.map_mut().unwrap();
7077            for (i, b) in m.as_mut_slice().iter_mut().enumerate() {
7078                *b = (i * 3) as u8;
7079            }
7080        }
7081        for _ in 0..2 {
7082            let ro = t.map_read().unwrap();
7083            for (i, b) in ro.as_slice().iter().enumerate() {
7084                assert_eq!(*b, (i * 3) as u8, "byte {i} through read-only lock");
7085            }
7086        }
7087        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
7088            let mut ro = t.map_read().unwrap();
7089            let _ = ro.as_mut_slice();
7090        }));
7091        assert!(result.is_err(), "IOSurface read map must reject mutation");
7092    }
7093}
7094
7095#[cfg(test)]
7096mod tests {
7097    #[cfg(target_os = "linux")]
7098    use nix::unistd::{access, AccessFlags};
7099    #[cfg(target_os = "linux")]
7100    use std::io::Write as _;
7101    use std::sync::{PoisonError, RwLock};
7102
7103    use super::*;
7104
7105    #[ctor::ctor(unsafe)]
7106    fn init() {
7107        env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
7108    }
7109
7110    /// Macro to get the current function name for logging in tests.
7111    #[cfg(target_os = "linux")]
7112    macro_rules! function {
7113        () => {{
7114            fn f() {}
7115            fn type_name_of<T>(_: T) -> &'static str {
7116                std::any::type_name::<T>()
7117            }
7118            let name = type_name_of(f);
7119
7120            // Find and cut the rest of the path
7121            match &name[..name.len() - 3].rfind(':') {
7122                Some(pos) => &name[pos + 1..name.len() - 3],
7123                None => &name[..name.len() - 3],
7124            }
7125        }};
7126    }
7127
7128    #[test]
7129    #[cfg(target_os = "linux")]
7130    fn test_tensor() {
7131        let _lock = crate::tests::fd_lock_shared();
7132        let shape = vec![1];
7133        let tensor = DmaTensor::<f32>::new(&shape, Some("dma_tensor"));
7134        let dma_enabled = tensor.is_ok();
7135
7136        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7137        // Auto-select priority is Dma > Mem; Shm is never auto-selected.
7138        match dma_enabled {
7139            true => assert_eq!(tensor.memory(), TensorMemory::DmaBuf),
7140            false => assert_eq!(tensor.memory(), TensorMemory::Mem),
7141        }
7142    }
7143
7144    #[test]
7145    #[cfg(any(target_os = "macos", target_os = "ios"))]
7146    fn test_tensor() {
7147        let shape = vec![1];
7148        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7149        // macOS/iOS auto-fallback chain: IOSurface (Dma) → Mem. Healthy systems
7150        // return Dma; Mem only appears under memory pressure or sandboxed
7151        // contexts where IOSurfaceCreate fails. Shm is never auto-selected.
7152        let m = tensor.memory();
7153        assert!(
7154            matches!(m, TensorMemory::DmaBuf | TensorMemory::Mem),
7155            "Unexpected auto-fallback result on macOS/iOS: {m:?}"
7156        );
7157    }
7158
7159    #[test]
7160    #[cfg(all(
7161        unix,
7162        not(any(target_os = "linux", target_os = "macos", target_os = "ios"))
7163    ))]
7164    fn test_tensor() {
7165        let shape = vec![1];
7166        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7167        // Other Unix (BSD): no DMA, so auto-selection is Mem (Shm is
7168        // explicit-only, never auto-selected).
7169        assert_eq!(tensor.memory(), TensorMemory::Mem);
7170    }
7171
7172    #[test]
7173    #[cfg(not(unix))]
7174    fn test_tensor() {
7175        let shape = vec![1];
7176        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
7177        assert_eq!(tensor.memory(), TensorMemory::Mem);
7178    }
7179
7180    #[test]
7181    #[cfg(target_os = "linux")]
7182    fn test_dma_tensor() {
7183        let _lock = crate::tests::fd_lock_shared();
7184        match access(
7185            "/dev/dma_heap/linux,cma",
7186            AccessFlags::R_OK | AccessFlags::W_OK,
7187        ) {
7188            Ok(_) => println!("/dev/dma_heap/linux,cma is available"),
7189            Err(_) => match access(
7190                "/dev/dma_heap/system",
7191                AccessFlags::R_OK | AccessFlags::W_OK,
7192            ) {
7193                Ok(_) => println!("/dev/dma_heap/system is available"),
7194                Err(e) => {
7195                    writeln!(
7196                        &mut std::io::stdout(),
7197                        "[WARNING] DMA Heap is unavailable: {e}"
7198                    )
7199                    .unwrap();
7200                    return;
7201                }
7202            },
7203        }
7204
7205        let shape = vec![2, 3, 4];
7206        let tensor =
7207            DmaTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7208
7209        const DUMMY_VALUE: f32 = 12.34;
7210
7211        assert_eq!(tensor.memory(), TensorMemory::DmaBuf);
7212        assert_eq!(tensor.name(), "test_tensor");
7213        assert_eq!(tensor.shape(), &shape);
7214        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7215        assert_eq!(tensor.len(), 2 * 3 * 4);
7216
7217        {
7218            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7219            tensor_map.fill(42.0);
7220            assert!(tensor_map.iter().all(|&x| x == 42.0));
7221        }
7222
7223        {
7224            let shared = Tensor::<f32>::from_fd(
7225                tensor
7226                    .clone_fd()
7227                    .expect("Failed to duplicate tensor file descriptor"),
7228                &shape,
7229                Some("test_tensor_shared"),
7230            )
7231            .expect("Failed to create tensor from fd");
7232
7233            assert_eq!(shared.memory(), TensorMemory::DmaBuf);
7234            assert_eq!(shared.name(), "test_tensor_shared");
7235            assert_eq!(shared.shape(), &shape);
7236
7237            let mut tensor_map = shared.map().expect("Failed to map DMA memory from fd");
7238            tensor_map.fill(DUMMY_VALUE);
7239            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7240        }
7241
7242        {
7243            let tensor_map = tensor.map().expect("Failed to map DMA memory");
7244            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7245        }
7246
7247        let mut tensor = DmaTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7248        assert_eq!(tensor.shape(), &shape);
7249        let new_shape = vec![3, 4, 4];
7250        assert!(
7251            tensor.reshape(&new_shape).is_err(),
7252            "Reshape should fail due to size mismatch"
7253        );
7254        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7255
7256        let new_shape = vec![2, 3, 4];
7257        tensor.reshape(&new_shape).expect("Reshape should succeed");
7258        assert_eq!(
7259            tensor.shape(),
7260            &new_shape,
7261            "Shape should be updated after successful reshape"
7262        );
7263
7264        {
7265            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7266            tensor_map.fill(1);
7267            assert!(tensor_map.iter().all(|&x| x == 1));
7268        }
7269
7270        {
7271            let mut tensor_map = tensor.map().expect("Failed to map DMA memory");
7272            tensor_map[2] = 42;
7273            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7274            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7275        }
7276    }
7277
7278    #[test]
7279    #[cfg(unix)]
7280    fn test_shm_tensor() {
7281        let _lock = crate::tests::fd_lock_shared();
7282        if !is_shm_available() {
7283            log::warn!("SKIPPED: test_shm_tensor - SHM allocation unavailable on this platform");
7284            return;
7285        }
7286        let shape = vec![2, 3, 4];
7287        let tensor =
7288            ShmTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7289        assert_eq!(tensor.shape(), &shape);
7290        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7291        assert_eq!(tensor.name(), "test_tensor");
7292
7293        const DUMMY_VALUE: f32 = 12.34;
7294        {
7295            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7296            tensor_map.fill(42.0);
7297            assert!(tensor_map.iter().all(|&x| x == 42.0));
7298        }
7299
7300        {
7301            let shared = Tensor::<f32>::from_fd(
7302                tensor
7303                    .clone_fd()
7304                    .expect("Failed to duplicate tensor file descriptor"),
7305                &shape,
7306                Some("test_tensor_shared"),
7307            )
7308            .expect("Failed to create tensor from fd");
7309
7310            assert_eq!(shared.memory(), TensorMemory::Shm);
7311            assert_eq!(shared.name(), "test_tensor_shared");
7312            assert_eq!(shared.shape(), &shape);
7313
7314            let mut tensor_map = shared.map().expect("Failed to map shared memory from fd");
7315            tensor_map.fill(DUMMY_VALUE);
7316            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7317        }
7318
7319        {
7320            let tensor_map = tensor.map().expect("Failed to map shared memory");
7321            assert!(tensor_map.iter().all(|&x| x == DUMMY_VALUE));
7322        }
7323
7324        let mut tensor = ShmTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7325        assert_eq!(tensor.shape(), &shape);
7326        let new_shape = vec![3, 4, 4];
7327        assert!(
7328            tensor.reshape(&new_shape).is_err(),
7329            "Reshape should fail due to size mismatch"
7330        );
7331        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7332
7333        let new_shape = vec![2, 3, 4];
7334        tensor.reshape(&new_shape).expect("Reshape should succeed");
7335        assert_eq!(
7336            tensor.shape(),
7337            &new_shape,
7338            "Shape should be updated after successful reshape"
7339        );
7340
7341        {
7342            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7343            tensor_map.fill(1);
7344            assert!(tensor_map.iter().all(|&x| x == 1));
7345        }
7346
7347        {
7348            let mut tensor_map = tensor.map().expect("Failed to map shared memory");
7349            tensor_map[2] = 42;
7350            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7351            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7352        }
7353    }
7354
7355    #[test]
7356    fn mem_subview_partitions_parent_buffer() {
7357        // One heap [2,4] u8 parent (8 bytes). Two [1,4] sub-views at byte
7358        // offsets 0 and 4 must share the parent allocation (zero-copy) and be
7359        // independently writable: view 0 owns bytes [0,4), view 1 owns [4,8).
7360        // Today this is impossible — heap offset is rejected and there is no
7361        // shared sub-view constructor.
7362        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Mem), None).unwrap();
7363        let view0 = parent.subview(0, &[1, 4]).expect("subview at offset 0");
7364        let view1 = parent.subview(4, &[1, 4]).expect("subview at offset 4");
7365
7366        view1
7367            .map()
7368            .unwrap()
7369            .as_mut_slice()
7370            .copy_from_slice(&[10, 20, 30, 40]);
7371        view0
7372            .map()
7373            .unwrap()
7374            .as_mut_slice()
7375            .copy_from_slice(&[1, 2, 3, 4]);
7376
7377        // Each view sees only its own window.
7378        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
7379        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
7380        // The parent buffer is correctly partitioned (shared, zero-copy).
7381        assert_eq!(
7382            parent.map().unwrap().as_slice(),
7383            &[1, 2, 3, 4, 10, 20, 30, 40]
7384        );
7385    }
7386
7387    #[test]
7388    fn batch_partitions_leading_dim() {
7389        // Raw [4,2,2,3] u8 batched tensor: 4 elements of 12 bytes each. batch(n)
7390        // yields element n at offset n*12, sharing the parent buffer (zero-copy).
7391        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7392        for i in 0..4u8 {
7393            let e = parent.batch(i as usize).expect("batch element");
7394            assert_eq!(e.shape(), &[2, 2, 3]);
7395            // A batch element shares the parent's BufferIdentity.
7396            assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
7397            for b in e.map().unwrap().as_mut_slice() {
7398                *b = i + 1;
7399            }
7400        }
7401        // Each element occupies its own 12-byte band of the parent.
7402        let whole = parent.map().unwrap();
7403        let s = whole.as_slice();
7404        for i in 0..4usize {
7405            assert!(
7406                s[i * 12..(i + 1) * 12].iter().all(|&b| b == (i as u8 + 1)),
7407                "band {i} not partitioned: {:?}",
7408                &s[i * 12..(i + 1) * 12]
7409            );
7410        }
7411    }
7412
7413    #[test]
7414    fn view_origin_snapshots_parent_and_composes() {
7415        // view() on a whole image snapshots the parent dims + the view's origin.
7416        let parent = Tensor::<u8>::image(
7417            100,
7418            80,
7419            PixelFormat::Rgba,
7420            Some(TensorMemory::Mem),
7421            crate::CpuAccess::ReadWrite,
7422        )
7423        .unwrap();
7424        assert_eq!(
7425            parent.view_origin(),
7426            None,
7427            "whole tensor has no view_origin"
7428        );
7429        let v = parent.view(Region::new(10, 20, 30, 40)).unwrap();
7430        assert_eq!(
7431            v.view_origin(),
7432            Some(ViewOrigin {
7433                parent_width: 100,
7434                parent_height: 80,
7435                parent_row_stride: 100 * 4, // tight RGBA pitch
7436                x: 10,
7437                y: 20
7438            })
7439        );
7440        // A view of a view keeps the ROOT parent and accumulates the origin.
7441        let v2 = v.view(Region::new(5, 5, 10, 10)).unwrap();
7442        assert_eq!(
7443            v2.view_origin(),
7444            Some(ViewOrigin {
7445                parent_width: 100,
7446                parent_height: 80,
7447                parent_row_stride: 100 * 4,
7448                x: 15,
7449                y: 25
7450            }),
7451            "nested view composes onto the root parent"
7452        );
7453    }
7454
7455    #[test]
7456    fn view_origin_none_for_raw_batch() {
7457        // A raw (unformatted) batched tensor has no pixel geometry, so batch()
7458        // leaves view_origin None (the per-slot path, not the one-import pivot).
7459        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7460        assert_eq!(parent.batch(2).unwrap().view_origin(), None);
7461    }
7462
7463    #[test]
7464    fn batch_rejects_out_of_bounds_index() {
7465        let parent = Tensor::<u8>::new(&[4, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7466        match parent.batch(4) {
7467            Err(Error::BatchIndexOutOfBounds { index, batch }) => {
7468                assert_eq!((index, batch), (4, 4));
7469            }
7470            other => panic!("expected BatchIndexOutOfBounds, got {other:?}"),
7471        }
7472    }
7473
7474    #[test]
7475    fn batch_zero_on_unit_n_is_whole() {
7476        // N == 1: batch(0) is the whole per-element block at offset 0 (no plane_offset).
7477        let parent = Tensor::<u8>::new(&[1, 2, 2, 3], Some(TensorMemory::Mem), None).unwrap();
7478        let e = parent.batch(0).unwrap();
7479        assert_eq!(e.shape(), &[2, 2, 3]);
7480        assert_eq!(e.plane_offset(), None);
7481        assert_eq!(e.buffer_identity().id(), parent.buffer_identity().id());
7482    }
7483
7484    #[test]
7485    fn mem_subview_rejects_unaligned_offset() {
7486        // f32 has align 4; a byte offset of 2 cannot back a valid `*const f32`.
7487        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7488        assert!(parent.subview(2, &[1]).is_err());
7489        // A correctly aligned offset is accepted.
7490        assert!(parent.subview(4, &[1]).is_ok());
7491    }
7492
7493    #[test]
7494    fn mem_subview_rejects_out_of_bounds() {
7495        let parent = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7496        // offset 6 + 4 bytes = 10 exceeds the 8-byte allocation.
7497        assert!(parent.subview(6, &[4]).is_err());
7498    }
7499
7500    /// Regression guard for the `TensorTrait::view` promotion (R2): a `subview`
7501    /// must share the parent's `BufferIdentity` on **every** backend, not mint a
7502    /// fresh one. Identity-keyed caches (the GL EGLImage import) rely on this to
7503    /// treat offset-distinct windows of one buffer as a single import; a fresh
7504    /// identity would silently break that and regress zero-copy import reuse.
7505    ///
7506    /// Runs each backend that can be allocated without a GPU/GL context on the
7507    /// test host: `Mem` (always); `Shm` (when POSIX shm is available); the
7508    /// platform-native zero-copy buffer `Dma` (DMA-BUF on Linux / IOSurface on
7509    /// macOS, when available). `Pbo` shares its identity the same way (see
7510    /// `pbo.rs` `view`) but needs a live GL context, so it is exercised by the
7511    /// image-crate GL tests rather than here.
7512    #[test]
7513    fn subview_shares_buffer_identity_all_backends() {
7514        // Declares this test as an fd-opener; see FD_LOCK.
7515        let _lock = crate::tests::fd_lock_shared();
7516        // u8 has align 1, so every byte offset is valid for the alignment check;
7517        // this isolates the identity-sharing contract from alignment concerns.
7518        // `window` is the view shape; a formatted parent validates it against
7519        // its own format, so an image parent needs an image-shaped window.
7520        let assert_parent_shares = |parent: Tensor<u8>, window: &[usize], label: &str| {
7521            let parent_id = parent.buffer_identity().id();
7522            // Two offset-distinct windows must both carry the parent's identity.
7523            let v0 = parent
7524                .subview(0, window)
7525                .unwrap_or_else(|e| panic!("{label}: subview(0) failed: {e:?}"));
7526            let v1 = parent
7527                .subview(16, window)
7528                .unwrap_or_else(|e| panic!("{label}: subview(16) failed: {e:?}"));
7529            assert_eq!(
7530                v0.buffer_identity().id(),
7531                parent_id,
7532                "{label}: subview(0) minted a fresh BufferIdentity"
7533            );
7534            assert_eq!(
7535                v1.buffer_identity().id(),
7536                parent_id,
7537                "{label}: subview(16) minted a fresh BufferIdentity"
7538            );
7539        };
7540        let assert_shares = |memory: TensorMemory, label: &str| {
7541            let parent = Tensor::<u8>::new(&[64], Some(memory), None)
7542                .unwrap_or_else(|e| panic!("{label}: parent alloc failed: {e:?}"));
7543            assert_parent_shares(parent, &[16], label);
7544        };
7545
7546        assert_shares(TensorMemory::Mem, "Mem");
7547
7548        #[cfg(unix)]
7549        if crate::is_shm_available() {
7550            assert_shares(TensorMemory::Shm, "Shm");
7551        }
7552
7553        // Dma == DMA-BUF on Linux, IOSurface on macOS, AHardwareBuffer on
7554        // Android; same public variant.
7555        #[cfg(not(target_os = "windows"))]
7556        if crate::is_gpu_buffer_available() {
7557            assert_shares(TensorMemory::DmaBuf, "Dma");
7558        }
7559        // Windows' Dma is an image-formatted D3D11 texture, which
7560        // `Tensor::new` deliberately refuses -- allocate it the only way it
7561        // can be allocated and hold it to the same contract.
7562        #[cfg(target_os = "windows")]
7563        if crate::is_gpu_buffer_available() {
7564            let parent = Tensor::<u8>::image(
7565                64,
7566                1,
7567                PixelFormat::Grey,
7568                Some(TensorMemory::DmaBuf),
7569                CpuAccess::ReadWrite,
7570            )
7571            .expect("D3D11 texture alloc failed");
7572            assert_parent_shares(parent, &[1, 16, 1], "Dma");
7573        }
7574    }
7575
7576    /// A sub-view records a plane offset, and on Windows the D3D11 storage
7577    /// already applied that offset itself when the view was made -- so the
7578    /// map's plane-offset gate must accept it, exactly as it does for the
7579    /// other zero-copy backings. It used to refuse, with a message that named
7580    /// every backend but this one.
7581    #[cfg(target_os = "windows")]
7582    #[test]
7583    fn subview_of_a_texture_tensor_maps_at_its_offset() {
7584        if !crate::is_gpu_buffer_available() {
7585            return;
7586        }
7587        let t = Tensor::<u8>::image(
7588            32,
7589            8,
7590            PixelFormat::Rgba,
7591            Some(TensorMemory::DmaBuf),
7592            CpuAccess::ReadWrite,
7593        )
7594        .unwrap();
7595        // For a texture tensor this is the backing pitch either way: the
7596        // allocation records the staging pitch when it exceeds the tight row,
7597        // and the two are equal when it does not.
7598        let stride = t.effective_row_stride().unwrap();
7599        let v = t.subview(stride * 2, &[2, 32, 4]).unwrap();
7600        assert!(
7601            v.plane_offset().is_some_and(|o| o > 0),
7602            "a sub-view at a row offset must carry one"
7603        );
7604        let m = v
7605            .map_read()
7606            .expect("a sub-view of a texture tensor must map");
7607        assert_eq!(m.as_slice().len(), 2 * stride, "two rows of the window");
7608    }
7609
7610    #[test]
7611    fn mem_subview_four_views_no_aliasing() {
7612        // One [4,3] f32 parent; four [1,3] views at 12-byte strides, each
7613        // written independently. Exercises a multi-byte element type (offsets
7614        // must stay element-aligned) and N-way zero-copy sharing.
7615        let parent = Tensor::<f32>::new(&[4, 3], Some(TensorMemory::Mem), None).unwrap();
7616        let frame = 3 * std::mem::size_of::<f32>();
7617        for i in 0..4 {
7618            let v = parent.subview(i * frame, &[1, 3]).unwrap();
7619            let val = i as f32 + 1.0;
7620            v.map()
7621                .unwrap()
7622                .as_mut_slice()
7623                .copy_from_slice(&[val, val, val]);
7624        }
7625        assert_eq!(
7626            parent.map().unwrap().as_slice(),
7627            &[1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0]
7628        );
7629    }
7630
7631    #[test]
7632    fn mem_subview_inherits_format_and_row_stride() {
7633        // A sub-view is a ready-to-use sub-image: it inherits the parent's
7634        // pixel format and (crucially) its padded row stride, so a strided
7635        // parent yields strided windows. Set a stride wider than the tight row
7636        // to exercise the row_stride inheritance path specifically.
7637        let mut parent = Tensor::<u8>::image(
7638            100,
7639            100,
7640            PixelFormat::Rgba,
7641            Some(TensorMemory::Mem),
7642            crate::CpuAccess::ReadWrite,
7643        )
7644        .unwrap();
7645        parent.set_row_stride_unchecked(512); // padded stride (> 100*4)
7646        let view = parent.subview(4096, &[10, 10, 4]).unwrap();
7647        assert_eq!(view.format(), Some(PixelFormat::Rgba), "format inherited");
7648        assert_eq!(view.row_stride(), Some(512), "row_stride inherited");
7649    }
7650
7651    #[test]
7652    fn mem_strided_subview_maps_offset_and_byte_size() {
7653        // Integration of the sub-region offset (PR #89) and the strided-map
7654        // `byte_size_override` (PR #90): a strided sub-view exposes its full
7655        // padded window (`row_stride × rows`) starting at the view's byte
7656        // offset, mapped zero-copy into the parent.
7657        let parent = Tensor::<u8>::new(&[2048], Some(TensorMemory::Mem), None).unwrap();
7658        let mut view = parent.subview(128, &[8, 16]).unwrap(); // 8 rows × 16 @ off 128
7659        assert_eq!(view.plane_offset(), Some(128));
7660        view.set_row_stride_unchecked(32); // padded stride (> 16)
7661
7662        {
7663            let mut m = view.map().unwrap();
7664            let s = m.as_mut_slice();
7665            // Strided map exposes the padded window: stride(32) × rows(8) = 256.
7666            assert_eq!(
7667                s.len(),
7668                256,
7669                "strided map exposes the full padded byte window"
7670            );
7671            s[0] = 0xAA; // row 0, col 0
7672            s[32] = 0xBB; // row 1, col 0 (one stride in)
7673        }
7674
7675        // Zero-copy: the writes land in the parent at the view's offset.
7676        let p = parent.map().unwrap();
7677        let pb = p.as_slice();
7678        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
7679        assert_eq!(
7680            pb[128 + 32],
7681            0xBB,
7682            "row 1 writes at parent offset 128 + stride"
7683        );
7684    }
7685
7686    #[test]
7687    #[cfg(unix)]
7688    fn shm_subview_partitions_parent_buffer() {
7689        // Declares this test as an fd-opener; see FD_LOCK.
7690        let _lock = crate::tests::fd_lock_shared();
7691        // Mirrors `mem_subview_partitions_parent_buffer` for Shm: one [2,4] u8
7692        // parent shared segment (8 bytes); two [1,4] sub-views at byte offsets 0
7693        // and 4 must share the segment (zero-copy, via cloned fd) and be
7694        // independently writable — view 0 owns [0,4), view 1 owns [4,8).
7695        if !crate::is_shm_available() {
7696            crate::test_support::report_skip("shm not available");
7697            return;
7698        }
7699        let parent = Tensor::<u8>::new(&[2, 4], Some(TensorMemory::Shm), None).unwrap();
7700        let view0 = parent.subview(0, &[1, 4]).expect("shm subview at offset 0");
7701        let view1 = parent.subview(4, &[1, 4]).expect("shm subview at offset 4");
7702
7703        view1
7704            .map()
7705            .unwrap()
7706            .as_mut_slice()
7707            .copy_from_slice(&[10, 20, 30, 40]);
7708        view0
7709            .map()
7710            .unwrap()
7711            .as_mut_slice()
7712            .copy_from_slice(&[1, 2, 3, 4]);
7713
7714        assert_eq!(view0.map().unwrap().as_slice(), &[1, 2, 3, 4]);
7715        assert_eq!(view1.map().unwrap().as_slice(), &[10, 20, 30, 40]);
7716        // The parent sees the full partitioned segment (shared, zero-copy).
7717        assert_eq!(
7718            parent.map().unwrap().as_slice(),
7719            &[1, 2, 3, 4, 10, 20, 30, 40]
7720        );
7721        // A sub-view of a sub-view composes the offset.
7722        let nested = view1.subview(2, &[1, 2]).expect("nested shm subview");
7723        assert_eq!(nested.map().unwrap().as_slice(), &[30, 40]);
7724    }
7725
7726    #[test]
7727    #[cfg(unix)]
7728    fn shm_subview_rejects_unaligned_and_oob() {
7729        // Declares this test as an fd-opener; see FD_LOCK.
7730        let _lock = crate::tests::fd_lock_shared();
7731        if !crate::is_shm_available() {
7732            crate::test_support::report_skip("shm not available");
7733            return;
7734        }
7735        // f32 align 4: a 2-byte offset cannot back a valid `*const f32`.
7736        let parent = Tensor::<f32>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
7737        assert!(parent.subview(2, &[1]).is_err());
7738        assert!(parent.subview(4, &[1]).is_ok());
7739        // Out of bounds: offset 6 + 4 bytes = 10 > 8-byte (u8) segment.
7740        let p2 = Tensor::<u8>::new(&[8], Some(TensorMemory::Shm), None).unwrap();
7741        assert!(p2.subview(6, &[4]).is_err());
7742    }
7743
7744    #[test]
7745    #[cfg(target_os = "linux")]
7746    fn dma_subview_matches_mem_subview() {
7747        // Serialize against the fd-leak tests: this test opens DMA fds (alloc +
7748        // clone_fd), which would otherwise perturb their fd counts.
7749        let _lock = crate::tests::fd_lock_shared();
7750        // Identical sub-view semantics across Dma (shared fd) and Mem (shared
7751        // Arc): same offsets → same logical windows → same partition.
7752        let dma = match Tensor::<u8>::new(&[8], Some(TensorMemory::DmaBuf), None) {
7753            Ok(t) => t,
7754            Err(_) => {
7755                crate::test_support::report_skip("DMA not available");
7756                return;
7757            }
7758        };
7759        let mem = Tensor::<u8>::new(&[8], Some(TensorMemory::Mem), None).unwrap();
7760        for parent in [&dma, &mem] {
7761            let v0 = parent.subview(0, &[4]).unwrap();
7762            let v1 = parent.subview(4, &[4]).unwrap();
7763            v0.map()
7764                .unwrap()
7765                .as_mut_slice()
7766                .copy_from_slice(&[1, 2, 3, 4]);
7767            v1.map()
7768                .unwrap()
7769                .as_mut_slice()
7770                .copy_from_slice(&[5, 6, 7, 8]);
7771            assert_eq!(parent.map().unwrap().as_slice(), &[1, 2, 3, 4, 5, 6, 7, 8]);
7772        }
7773    }
7774
7775    #[test]
7776    #[cfg(target_os = "linux")]
7777    fn dma_strided_subview_maps_padded_window() {
7778        // The strided-map path differs by backing: DMA maps through
7779        // `mmap_offset` + the `byte_size_override`, not the Mem `Arc` slice. A
7780        // padded sub-view of a DMA buffer must still expose its full
7781        // `row_stride × rows` window zero-copy at the view's offset (the GPU
7782        // batched-render-to-DMA case). Mirrors
7783        // `mem_strided_subview_maps_offset_and_byte_size` on a Dma parent.
7784        let _lock = crate::tests::fd_lock_shared();
7785        let parent = match Tensor::<u8>::new(&[2048], Some(TensorMemory::DmaBuf), None) {
7786            Ok(t) => t,
7787            Err(_) => {
7788                crate::test_support::report_skip("DMA not available");
7789                return;
7790            }
7791        };
7792        let mut view = parent.subview(128, &[8, 16]).unwrap();
7793        assert_eq!(view.plane_offset(), Some(128));
7794        view.set_row_stride_unchecked(32); // padded stride (> 16)
7795
7796        {
7797            let mut m = view.map().unwrap();
7798            let s = m.as_mut_slice();
7799            assert_eq!(s.len(), 256, "strided DMA map exposes stride(32) × rows(8)");
7800            s[0] = 0xAA; // row 0, col 0
7801            s[32] = 0xBB; // row 1, col 0 (one stride in)
7802        }
7803
7804        let p = parent.map().unwrap();
7805        let pb = p.as_slice();
7806        assert_eq!(pb[128], 0xAA, "row 0 writes at parent offset 128");
7807        assert_eq!(
7808            pb[128 + 32],
7809            0xBB,
7810            "row 1 writes at parent offset 128 + stride"
7811        );
7812    }
7813
7814    #[test]
7815    #[cfg(target_os = "linux")]
7816    fn view_single_row_snapshots_parent_stride() {
7817        // A single-row `view()` keeps a TIGHT `row_stride` for map-span safety,
7818        // but its `view_origin` snapshots the PARENT row stride — the GL backend
7819        // keys its EGLImage import/pitch on that snapshot (not the view's tight
7820        // stride), so single-row and multi-row sibling views collapse onto the
7821        // same parent import.
7822        let _lock = crate::tests::fd_lock_shared();
7823        // 8x4 RGBA with a padded 64-byte row stride (tight row = 8*4 = 32).
7824        let parent = match Tensor::<u8>::image_with_stride(
7825            8,
7826            4,
7827            PixelFormat::Rgba,
7828            64,
7829            Some(TensorMemory::DmaBuf),
7830            crate::CpuAccess::ReadWrite,
7831        ) {
7832            Ok(t) => t,
7833            Err(_) => {
7834                crate::test_support::report_skip("DMA not available");
7835                return;
7836            }
7837        };
7838        assert_eq!(parent.effective_row_stride(), Some(64));
7839        // Bottom row (y=3) at x>0 — the case the tight single-row stride guards.
7840        let row = parent.view(Region::new(2, 3, 4, 1)).unwrap();
7841        // The view's own stride is tight (4*4 = 16) so its strided map stays in
7842        // bounds; the GL-facing parent pitch (64) lives in `view_origin`.
7843        assert_eq!(row.effective_row_stride(), Some(16));
7844        let vo = row.view_origin().expect("a view carries a view_origin");
7845        assert_eq!(
7846            vo.parent_row_stride, 64,
7847            "GL keys/pitches a view on the parent stride, not its tight one"
7848        );
7849        // The tight stride keeps map() in-bounds for the bottom / x>0 single row.
7850        assert_eq!(row.map().unwrap().as_slice().len(), 16);
7851    }
7852
7853    #[test]
7854    fn test_mem_tensor() {
7855        let shape = vec![2, 3, 4];
7856        let tensor =
7857            MemTensor::<f32>::new(&shape, Some("test_tensor")).expect("Failed to create tensor");
7858        assert_eq!(tensor.shape(), &shape);
7859        assert_eq!(tensor.size(), 2 * 3 * 4 * std::mem::size_of::<f32>());
7860        assert_eq!(tensor.name(), "test_tensor");
7861
7862        {
7863            let mut tensor_map = tensor.map().expect("Failed to map memory");
7864            tensor_map.fill(42.0);
7865            assert!(tensor_map.iter().all(|&x| x == 42.0));
7866        }
7867
7868        let mut tensor = MemTensor::<u8>::new(&shape, None).expect("Failed to create tensor");
7869        assert_eq!(tensor.shape(), &shape);
7870        let new_shape = vec![3, 4, 4];
7871        assert!(
7872            tensor.reshape(&new_shape).is_err(),
7873            "Reshape should fail due to size mismatch"
7874        );
7875        assert_eq!(tensor.shape(), &shape, "Shape should remain unchanged");
7876
7877        let new_shape = vec![2, 3, 4];
7878        tensor.reshape(&new_shape).expect("Reshape should succeed");
7879        assert_eq!(
7880            tensor.shape(),
7881            &new_shape,
7882            "Shape should be updated after successful reshape"
7883        );
7884
7885        {
7886            let mut tensor_map = tensor.map().expect("Failed to map memory");
7887            tensor_map.fill(1);
7888            assert!(tensor_map.iter().all(|&x| x == 1));
7889        }
7890
7891        {
7892            let mut tensor_map = tensor.map().expect("Failed to map memory");
7893            tensor_map[2] = 42;
7894            assert_eq!(tensor_map[1], 1, "Value at index 1 should be 1");
7895            assert_eq!(tensor_map[2], 42, "Value at index 2 should be 42");
7896        }
7897    }
7898
7899    #[test]
7900    #[cfg(target_os = "linux")]
7901    fn test_dma_no_fd_leaks() {
7902        let _lock = fd_lock_exclusive();
7903        if !is_dma_available() {
7904            log::warn!(
7905                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7906                function!()
7907            );
7908            return;
7909        }
7910
7911        let baseline = open_fd_count(DMA_FD_TARGETS);
7912
7913        // Self-check — see `test_shm_no_fd_leaks`.
7914        {
7915            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
7916                .expect("Failed to create tensor");
7917            assert!(
7918                open_fd_count(DMA_FD_TARGETS) > baseline,
7919                "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
7920                 otherwise this test cannot detect a leak"
7921            );
7922            drop(probe);
7923        }
7924
7925        for _ in 0..100 {
7926            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None)
7927                .expect("Failed to create tensor");
7928            let mut map = tensor.map().unwrap();
7929            map.as_mut_slice().fill(233);
7930        }
7931
7932        let after = open_fd_count(DMA_FD_TARGETS);
7933        assert_eq!(
7934            baseline, after,
7935            "DMA-BUF file descriptor leak detected: {baseline} -> {after}"
7936        );
7937    }
7938
7939    #[test]
7940    #[cfg(target_os = "linux")]
7941    fn test_dma_from_fd_no_fd_leaks() {
7942        let _lock = fd_lock_exclusive();
7943        if !is_dma_available() {
7944            log::warn!(
7945                "SKIPPED: {} - DMA memory allocation not available (permission denied or no DMA-BUF support)",
7946                function!()
7947            );
7948            return;
7949        }
7950
7951        let baseline = open_fd_count(DMA_FD_TARGETS);
7952        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::DmaBuf), None).unwrap();
7953
7954        // Self-check — see `test_shm_no_fd_leaks`.
7955        let held = open_fd_count(DMA_FD_TARGETS);
7956        assert!(
7957            held > baseline,
7958            "a live DMA-BUF tensor must be visible to the dmabuf fd counter, \
7959             otherwise this test cannot detect a leak"
7960        );
7961
7962        for _ in 0..100 {
7963            let tensor =
7964                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
7965            assert_eq!(
7966                tensor.memory(),
7967                TensorMemory::DmaBuf,
7968                "DMA-BUF fd must import as Dma, not be silently downgraded"
7969            );
7970            let mut map = tensor.map().unwrap();
7971            map.as_mut_slice().fill(233);
7972        }
7973
7974        let after_loop = open_fd_count(DMA_FD_TARGETS);
7975        assert_eq!(
7976            held, after_loop,
7977            "DMA-BUF file descriptor leak detected: {held} -> {after_loop}"
7978        );
7979
7980        // And the source tensor's own fd goes back on drop.
7981        drop(orig);
7982        let after_drop = open_fd_count(DMA_FD_TARGETS);
7983        assert_eq!(
7984            baseline, after_drop,
7985            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
7986        );
7987    }
7988
7989    /// A filesystem magic must report its true 32-bit value regardless of
7990    /// how wide, and how signed, `fstatfs`'s `f_type` is on the target.
7991    ///
7992    /// `fs_type_t` is `__fsword_t` on Linux/gnu — `i64` on 64-bit but `i32`
7993    /// on 32-bit (armv7, i686, aarch64-ilp32) — `c_int` on uclibc, and
7994    /// unsigned on musl and s390x. Widening a *signed* 32-bit `f_type`
7995    /// sign-extends every magic with bit 31 set, so a naive widening cast
7996    /// reports e.g. `0xffffffff958458f6` for hugetlbfs. The docs tell
7997    /// callers to look the reported value up in `include/uapi/linux/magic.h`,
7998    /// so a sign-extended value is actively misleading.
7999    ///
8000    /// This cannot be reproduced on a 64-bit host — the defect only exists
8001    /// where `f_type` is a signed 32-bit type — so the test drives the
8002    /// conversion directly with the value such a target would produce.
8003    #[test]
8004    #[cfg(target_os = "linux")]
8005    fn test_fs_magic_normalizes_sign_extended_values() {
8006        // Declares this test as an fd-opener; see FD_LOCK.
8007        let _lock = crate::tests::fd_lock_shared();
8008        // Magics whose bit 31 is set. HUGETLBFS is the one that matters
8009        // most in practice: a MFD_HUGETLB memfd is the likeliest fd to land
8010        // in the UnknownBufferType arm.
8011        const HUGETLBFS_MAGIC: u32 = 0x9584_58f6;
8012        const F2FS_MAGIC: u32 = 0xf2f5_2010;
8013        const BTRFS_MAGIC: u32 = 0x9123_683e;
8014
8015        for magic in [HUGETLBFS_MAGIC, F2FS_MAGIC, BTRFS_MAGIC] {
8016            // 64-bit gnu: f_type is i64 and already holds the true value.
8017            assert_eq!(fs_magic(i64::from(magic)), magic);
8018
8019            // 32-bit gnu / uclibc: f_type is i32, so the value arrives
8020            // sign-extended once widened. It must still report its true
8021            // 32 bits.
8022            let sign_extended = i64::from(magic as i32);
8023            assert!(sign_extended < 0, "{magic:#x} should have bit 31 set");
8024            assert_eq!(
8025                fs_magic(sign_extended),
8026                magic,
8027                "{magic:#x} must survive a signed 32-bit f_type"
8028            );
8029        }
8030
8031        // The two magics we actually classify on are below 2^31, so they are
8032        // unaffected by signedness either way — this is why the DMA-vs-SHM
8033        // decision is correct on 32-bit even without this normalization.
8034        for magic in [DMA_BUF_MAGIC, TMPFS_MAGIC] {
8035            assert!(magic < 0x8000_0000);
8036            assert_eq!(fs_magic(i64::from(magic as i32)), magic);
8037        }
8038    }
8039
8040    /// A DMA-BUF fd must import as [`TensorMemory::DmaBuf`].
8041    ///
8042    /// Regression test for the `st_dev` minor-number classifier, which
8043    /// hardcoded `9 | 10` as "this is DMA". Those minors come from
8044    /// `get_anon_bdev()` and are assigned first-come-first-served at boot,
8045    /// so they vary by kernel build and boot order — a real DMA-BUF is
8046    /// minor 12 on x86 desktop and minor 8 on the ADIS Verdin. Both fell
8047    /// into the `_` arm and imported as SHM, which "works" (a DMA-BUF is
8048    /// mmap-able) but silently forfeits zero-copy.
8049    #[test]
8050    #[cfg(target_os = "linux")]
8051    fn test_from_fd_dma_imports_as_dma() {
8052        let _lock = crate::tests::fd_lock_shared();
8053        if !is_dma_available() {
8054            log::warn!("SKIPPED: {} - DMA memory not available", function!());
8055            return;
8056        }
8057
8058        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::DmaBuf), None).unwrap();
8059        assert_eq!(orig.memory(), TensorMemory::DmaBuf);
8060
8061        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8062
8063        assert_eq!(
8064            imported.memory(),
8065            TensorMemory::DmaBuf,
8066            "a DMA-BUF fd must import as Dma"
8067        );
8068    }
8069
8070    /// A tmpfs/SHM fd must import as [`TensorMemory::Shm`].
8071    ///
8072    /// The companion to `test_from_fd_dma_imports_as_dma`: confirms the
8073    /// magic-based classifier identifies SHM positively rather than by
8074    /// falling through.
8075    #[test]
8076    #[cfg(target_os = "linux")]
8077    fn test_from_fd_shm_imports_as_shm() {
8078        let _lock = crate::tests::fd_lock_shared();
8079        if !is_shm_available() {
8080            log::warn!("SKIPPED: {} - SHM memory not available", function!());
8081            return;
8082        }
8083
8084        let orig = Tensor::<u8>::new(&[64, 64], Some(TensorMemory::Shm), None).unwrap();
8085        assert_eq!(orig.memory(), TensorMemory::Shm);
8086
8087        let imported = Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8088
8089        assert_eq!(
8090            imported.memory(),
8091            TensorMemory::Shm,
8092            "a tmpfs fd must import as Shm"
8093        );
8094    }
8095
8096    /// An fd that is neither a DMA-BUF nor tmpfs must be rejected.
8097    ///
8098    /// A pipe is the convenient probe: it lives on `pipefs`, another
8099    /// `get_anon_bdev()` pseudo-filesystem, so it shares major 0 with the
8100    /// buffer types we do support and is only distinguishable by magic.
8101    /// Importing one as SHM is meaningless — `mmap` on a pipe fails — so
8102    /// the classifier must say "unknown" rather than guess.
8103    #[test]
8104    #[cfg(target_os = "linux")]
8105    fn test_from_fd_rejects_unknown_filesystem() {
8106        let _lock = crate::tests::fd_lock_shared();
8107
8108        let (read_end, _write_end) = nix::unistd::pipe().unwrap();
8109
8110        let result = Tensor::<u8>::from_fd(read_end, &[64], None);
8111
8112        match result {
8113            Err(Error::UnknownBufferType(magic)) => {
8114                // PIPEFS_MAGIC, from include/uapi/linux/magic.h
8115                assert_eq!(magic, 0x5049_5045, "expected PIPEFS_MAGIC");
8116            }
8117            other => panic!("expected UnknownBufferType for a pipe fd, got {other:?}"),
8118        }
8119    }
8120
8121    #[test]
8122    #[cfg(target_os = "linux")]
8123    fn test_shm_no_fd_leaks() {
8124        let _lock = fd_lock_exclusive();
8125        if !is_shm_available() {
8126            log::warn!(
8127                "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
8128                function!()
8129            );
8130            return;
8131        }
8132
8133        let baseline = open_fd_count(SHM_FD_TARGETS);
8134
8135        // Self-check: the counter must actually see a live shm tensor. If
8136        // it does not — a changed backing object, a different `/proc`
8137        // rendering — the leak assertion below would hold no matter how
8138        // much leaked, so fail loudly here instead.
8139        {
8140            let probe = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8141                .expect("Failed to create tensor");
8142            assert!(
8143                open_fd_count(SHM_FD_TARGETS) > baseline,
8144                "a live shm tensor must be visible to the shm fd counter, \
8145                 otherwise this test cannot detect a leak"
8146            );
8147            drop(probe);
8148        }
8149
8150        for _ in 0..100 {
8151            let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8152                .expect("Failed to create tensor");
8153            let mut map = tensor.map().unwrap();
8154            map.as_mut_slice().fill(233);
8155        }
8156
8157        let after = open_fd_count(SHM_FD_TARGETS);
8158        assert_eq!(
8159            baseline, after,
8160            "shm file descriptor leak detected: {baseline} -> {after}"
8161        );
8162    }
8163
8164    #[test]
8165    #[cfg(target_os = "linux")]
8166    fn test_shm_from_fd_no_fd_leaks() {
8167        let _lock = fd_lock_exclusive();
8168        if !is_shm_available() {
8169            log::warn!(
8170                "SKIPPED: {} - SHM memory allocation not available (permission denied or no SHM support)",
8171                function!()
8172            );
8173            return;
8174        }
8175
8176        let baseline = open_fd_count(SHM_FD_TARGETS);
8177        let orig = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None).unwrap();
8178
8179        // Self-check — see `test_shm_no_fd_leaks`.
8180        let held = open_fd_count(SHM_FD_TARGETS);
8181        assert!(
8182            held > baseline,
8183            "a live shm tensor must be visible to the shm fd counter, \
8184             otherwise this test cannot detect a leak"
8185        );
8186
8187        for _ in 0..100 {
8188            let tensor =
8189                Tensor::<u8>::from_fd(orig.clone_fd().unwrap(), orig.shape(), None).unwrap();
8190            let mut map = tensor.map().unwrap();
8191            map.as_mut_slice().fill(233);
8192        }
8193
8194        let after_loop = open_fd_count(SHM_FD_TARGETS);
8195        assert_eq!(
8196            held, after_loop,
8197            "shm file descriptor leak detected: {held} -> {after_loop}"
8198        );
8199
8200        // And the source tensor's own fd goes back on drop.
8201        drop(orig);
8202        let after_drop = open_fd_count(SHM_FD_TARGETS);
8203        assert_eq!(
8204            baseline, after_drop,
8205            "dropping the source tensor must release its fd: {baseline} -> {after_drop}"
8206        );
8207    }
8208
8209    #[cfg(feature = "ndarray")]
8210    #[test]
8211    fn test_ndarray() {
8212        let _lock = crate::tests::fd_lock_shared();
8213        let shape = vec![2, 3, 4];
8214        let tensor = Tensor::<f32>::new(&shape, None, None).expect("Failed to create tensor");
8215
8216        let mut tensor_map = tensor.map().expect("Failed to map tensor memory");
8217        tensor_map.fill(1.0);
8218
8219        let view = tensor_map.view().expect("Failed to get ndarray view");
8220        assert_eq!(view.shape(), &[2, 3, 4]);
8221        assert!(view.iter().all(|&x| x == 1.0));
8222
8223        let mut view_mut = tensor_map
8224            .view_mut()
8225            .expect("Failed to get mutable ndarray view");
8226        view_mut[[0, 0, 0]] = 42.0;
8227        assert_eq!(view_mut[[0, 0, 0]], 42.0);
8228        assert_eq!(tensor_map[0], 42.0, "Value at index 0 should be 42");
8229    }
8230
8231    #[test]
8232    fn test_buffer_identity_derived_equality() {
8233        // Two identities derived from the same kind and key must be equal —
8234        // that is the whole point of `derived`: two independently-linked
8235        // copies of this crate, each importing the same underlying buffer,
8236        // have to agree on its id without talking to each other.
8237        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8238        let id2 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8239        assert_eq!(
8240            id1.id(),
8241            id2.id(),
8242            "Identities derived from the same kind and key should match"
8243        );
8244
8245        // A different key still yields a different id.
8246        let id3 = BufferIdentity::derived(IdentityKind::DmaBuf, 4243);
8247        assert_ne!(
8248            id1.id(),
8249            id3.id(),
8250            "Identities derived from different keys should differ"
8251        );
8252    }
8253
8254    #[test]
8255    fn test_buffer_identity_clone_shares_guard() {
8256        let id1 = BufferIdentity::derived(IdentityKind::DmaBuf, 4242);
8257        let weak = id1.weak();
8258        assert!(
8259            weak.upgrade().is_some(),
8260            "Weak should be alive while original exists"
8261        );
8262
8263        let id2 = id1.clone();
8264        assert_eq!(id1.id(), id2.id(), "Cloned identity should have same id");
8265
8266        drop(id1);
8267        assert!(
8268            weak.upgrade().is_some(),
8269            "Weak should still be alive (clone holds Arc)"
8270        );
8271
8272        drop(id2);
8273        assert!(
8274            weak.upgrade().is_none(),
8275            "Weak should be dead after all clones dropped"
8276        );
8277    }
8278
8279    #[test]
8280    fn test_tensor_buffer_identity() {
8281        let t1 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t1")).unwrap();
8282        let t2 = Tensor::<u8>::new(&[100], Some(TensorMemory::Mem), Some("t2")).unwrap();
8283        assert_ne!(
8284            t1.buffer_identity().id(),
8285            t2.buffer_identity().id(),
8286            "Different tensors should have different buffer ids"
8287        );
8288    }
8289
8290    // ------------------------------------------------------------------------
8291    // Quantization — constructor validation + accessor correctness.
8292    // ------------------------------------------------------------------------
8293
8294    #[test]
8295    fn test_quantization_per_tensor_constructors() {
8296        let q = Quantization::per_tensor(0.1, -5);
8297        assert!(q.is_per_tensor());
8298        assert!(!q.is_per_channel());
8299        assert!(!q.is_symmetric());
8300        assert_eq!(q.scale(), &[0.1]);
8301        assert_eq!(q.zero_point(), Some(&[-5][..]));
8302
8303        let qs = Quantization::per_tensor_symmetric(0.05);
8304        assert!(qs.is_per_tensor());
8305        assert!(qs.is_symmetric());
8306        assert_eq!(qs.zero_point(), None);
8307    }
8308
8309    #[test]
8310    fn test_quantization_per_channel_constructors() {
8311        let q = Quantization::per_channel(vec![0.1, 0.2, 0.3], vec![0, -1, 1], 2).unwrap();
8312        assert!(q.is_per_channel());
8313        assert!(!q.is_symmetric());
8314        assert_eq!(q.axis(), Some(2));
8315        assert_eq!(q.scale().len(), 3);
8316
8317        let qs = Quantization::per_channel_symmetric(vec![0.054, 0.089, 0.195], 0).unwrap();
8318        assert!(qs.is_per_channel());
8319        assert!(qs.is_symmetric());
8320        assert_eq!(qs.axis(), Some(0));
8321    }
8322
8323    #[test]
8324    fn test_quantization_per_channel_length_mismatch_rejected() {
8325        // len(scales) != len(zero_points) → rejected at construction.
8326        let err = Quantization::per_channel(vec![0.1, 0.2], vec![0, 0, 0], 0).unwrap_err();
8327        assert!(matches!(err, Error::QuantizationInvalid { .. }));
8328    }
8329
8330    #[test]
8331    fn test_quantization_per_channel_empty_rejected() {
8332        let err = Quantization::per_channel_symmetric(vec![], 0).unwrap_err();
8333        assert!(matches!(err, Error::QuantizationInvalid { .. }));
8334    }
8335
8336    /// Constructors guard scale/zero_point length invariants, but
8337    /// `Quantization` is `Deserialize`, so malformed JSON (e.g. an
8338    /// empty `scale` array, or `zero_point` length that disagrees with
8339    /// `scale`) bypasses the constructor checks. `set_quantization`
8340    /// must reject these via `validate()` so they don't poison
8341    /// downstream `mode()` selection or per-channel kernel indexing.
8342    #[test]
8343    fn test_quantization_validate_rejects_malformed_deserialize() {
8344        let mut t = Tensor::<i8>::new(&[1, 1, 4], Some(TensorMemory::Mem), None).unwrap();
8345
8346        // Empty scale array: must be rejected.
8347        let q: Quantization = serde_json::from_str(r#"{"scale": []}"#).unwrap();
8348        assert!(matches!(
8349            t.set_quantization(q).unwrap_err(),
8350            Error::QuantizationInvalid { .. }
8351        ));
8352
8353        // Per-tensor with multi-element zero_point: must be rejected.
8354        let q: Quantization =
8355            serde_json::from_str(r#"{"scale": 0.1, "zero_point": [0, 0, 0]}"#).unwrap();
8356        assert!(matches!(
8357            t.set_quantization(q).unwrap_err(),
8358            Error::QuantizationInvalid { .. }
8359        ));
8360
8361        // Per-channel zero_point length != scale length: must be rejected.
8362        let q: Quantization = serde_json::from_str(
8363            r#"{"scale": [0.1, 0.2, 0.3, 0.4], "zero_point": [0, 0], "axis": 2}"#,
8364        )
8365        .unwrap();
8366        assert!(matches!(
8367            t.set_quantization(q).unwrap_err(),
8368            Error::QuantizationInvalid { .. }
8369        ));
8370    }
8371
8372    #[test]
8373    fn test_quantization_mode_dispatch() {
8374        let pt = Quantization::per_tensor(0.1, -5);
8375        assert!(matches!(
8376            pt.mode(),
8377            QuantMode::PerTensor { scale, zero_point } if scale == 0.1 && zero_point == -5
8378        ));
8379
8380        let pts = Quantization::per_tensor_symmetric(0.05);
8381        assert!(matches!(
8382            pts.mode(),
8383            QuantMode::PerTensorSymmetric { scale } if scale == 0.05
8384        ));
8385
8386        let pc = Quantization::per_channel(vec![0.1, 0.2], vec![0, -1], 2).unwrap();
8387        assert!(matches!(pc.mode(), QuantMode::PerChannel { axis: 2, .. }));
8388
8389        let pcs = Quantization::per_channel_symmetric(vec![0.1, 0.2], 0).unwrap();
8390        assert!(matches!(
8391            pcs.mode(),
8392            QuantMode::PerChannelSymmetric { axis: 0, .. }
8393        ));
8394    }
8395
8396    #[test]
8397    fn test_tensor_quantization_roundtrip_integer() {
8398        let mut t = Tensor::<i8>::new(&[2, 3, 4], Some(TensorMemory::Mem), None).unwrap();
8399        assert!(t.quantization().is_none());
8400        t.set_quantization(Quantization::per_tensor(0.1, -5))
8401            .unwrap();
8402        let q = t.quantization().unwrap();
8403        assert_eq!(q.scale(), &[0.1]);
8404        t.clear_quantization();
8405        assert!(t.quantization().is_none());
8406    }
8407
8408    #[test]
8409    fn test_tensor_with_quantization_builder() {
8410        let t = Tensor::<i8>::new(&[4, 4], Some(TensorMemory::Mem), None)
8411            .unwrap()
8412            .with_quantization(Quantization::per_tensor_symmetric(0.05))
8413            .unwrap();
8414        assert!(t.quantization().is_some());
8415    }
8416
8417    #[test]
8418    fn test_tensor_dyn_quantization_float_arm_returns_none() {
8419        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8420        let td = TensorDyn::F32(t);
8421        assert!(td.quantization().is_none());
8422    }
8423
8424    #[test]
8425    fn test_tensor_dyn_set_quantization_float_arm_errors() {
8426        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8427        let mut td = TensorDyn::F32(t);
8428        let err = td
8429            .set_quantization(Quantization::per_tensor(0.1, 0))
8430            .unwrap_err();
8431        // float path returns a QuantizationInvalid error.
8432        assert!(matches!(err, Error::QuantizationInvalid { .. }));
8433    }
8434
8435    /// Compile-time type gate — calling `Tensor::<f32>::quantization()` must
8436    /// fail to compile (the `IntegerType` trait bound is not satisfied by
8437    /// `f32`). This doctest anchors the invariant.
8438    ///
8439    /// ```compile_fail
8440    /// use edgefirst_tensor::{Tensor, TensorMemory};
8441    /// let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8442    /// let _ = t.quantization(); // compile error: f32 not IntegerType
8443    /// ```
8444    fn _compile_fail_doctest_anchor() {}
8445
8446    // Any test that cares about the fd count must grab it exclusively.
8447    // Any tests which modifies the fd count by opening or closing fds must grab it
8448    // shared.
8449    pub static FD_LOCK: RwLock<()> = RwLock::new(());
8450
8451    /// Take [`FD_LOCK`] shared, tolerating poisoning.
8452    ///
8453    /// A test that panics while holding the lock poisons it, and every later
8454    /// `.unwrap()` on it panics too — one real failure turns into a handful
8455    /// of unrelated ones that bury it. The guarded data is `()`, so a panic
8456    /// cannot have left an invariant broken and there is nothing to protect
8457    /// against by refusing the lock.
8458    pub fn fd_lock_shared() -> std::sync::RwLockReadGuard<'static, ()> {
8459        FD_LOCK.read().unwrap_or_else(PoisonError::into_inner)
8460    }
8461
8462    /// Take [`FD_LOCK`] exclusively. Poison-tolerant, see [`fd_lock_shared`].
8463    ///
8464    /// Linux-only, like all four of its callers: the fd-leak tests read fd
8465    /// state out of `/proc`, which no other platform provides. Ungated, it
8466    /// is dead code on macOS and the lint denies that.
8467    /// [`fd_lock_shared`] stays available everywhere — macOS tests declare
8468    /// fd activity too.
8469    #[cfg(target_os = "linux")]
8470    pub fn fd_lock_exclusive() -> std::sync::RwLockWriteGuard<'static, ()> {
8471        FD_LOCK.write().unwrap_or_else(PoisonError::into_inner)
8472    }
8473
8474    /// `/proc` fd-target fragments identifying a POSIX shared memory
8475    /// segment. `shm_open` then `shm_unlink` leaves the target rendered as
8476    /// `/dev/shm/<name> (deleted)`.
8477    #[cfg(target_os = "linux")]
8478    const SHM_FD_TARGETS: &[&str] = &["/dev/shm/"];
8479
8480    /// `/proc` fd-target fragments identifying the fds a DMA tensor owns.
8481    ///
8482    /// Both the exported buffer and the heap it came from are listed, so a
8483    /// leak of either is still caught — narrowing this to the buffer alone
8484    /// would silently stop covering leaked heap handles.
8485    #[cfg(target_os = "linux")]
8486    const DMA_FD_TARGETS: &[&str] = &["dmabuf", "dma_heap"];
8487
8488    /// How many of this process's open fds point at an object whose `/proc`
8489    /// target contains `needle`.
8490    ///
8491    /// The fd-leak tests deliberately do NOT compare total fd counts. Under
8492    /// `cargo test` every test in this crate runs as a thread in one
8493    /// process, and roughly twenty of them create shm or DMA tensors —
8494    /// while anything else may open a file, a socket, or a pipe — so an
8495    /// unrelated test opening or closing an fd inside the measurement
8496    /// window moved the total and failed the assertion. Worse, the panic
8497    /// poisoned [`FD_LOCK`] and took four more tests down with it, so the
8498    /// visible failure had little to do with the cause.
8499    ///
8500    /// Counting only fds that name the object under test makes the
8501    /// measurement immune to every unrelated fd, while still catching the
8502    /// leak each test exists to catch. (CI runs nextest, one process per
8503    /// test, which is why this only ever flaked when run locally.)
8504    #[cfg(target_os = "linux")]
8505    fn open_fd_count(needles: &[&str]) -> usize {
8506        let proc = procfs::process::Process::myself()
8507            .expect("Failed to get current process using /proc/self");
8508        proc.fd()
8509            .expect("Failed to list open file descriptors")
8510            .filter_map(Result::ok)
8511            .filter(|entry| {
8512                let target = format!("{:?}", entry.target);
8513                needles.iter().any(|needle| target.contains(needle))
8514            })
8515            .count()
8516    }
8517
8518    /// Test that DMA is NOT available on non-Linux platforms.
8519    /// This verifies the cross-platform behavior of is_dma_available().
8520    #[test]
8521    #[cfg(not(target_os = "linux"))]
8522    fn test_dma_not_available_on_non_linux() {
8523        assert!(
8524            !is_dma_available(),
8525            "DMA memory allocation should NOT be available on non-Linux platforms"
8526        );
8527    }
8528
8529    #[test]
8530    fn colorimetry_defaults_none_and_roundtrips_without_auto_fill() {
8531        use crate::{ColorEncoding, ColorRange, Colorimetry, PixelFormat, TensorMemory};
8532        let mut t = Tensor::<u8>::image(
8533            1280,
8534            720,
8535            PixelFormat::Nv12,
8536            Some(TensorMemory::Mem),
8537            crate::CpuAccess::ReadWrite,
8538        )
8539        .unwrap();
8540        assert_eq!(t.colorimetry(), None); // default undefined
8541        let c = Colorimetry::default()
8542            .with_encoding(ColorEncoding::Bt709)
8543            .with_range(ColorRange::Limited);
8544        t.set_colorimetry(Some(c));
8545        assert_eq!(t.colorimetry(), Some(c));
8546        // configure_image must NOT touch colorimetry
8547        t.configure_image(640, 480, PixelFormat::Grey).unwrap();
8548        assert_eq!(t.colorimetry(), Some(c));
8549    }
8550
8551    #[test]
8552    fn configure_image_within_capacity() {
8553        let mut t = Tensor::<u8>::image_with_capacity(
8554            640,
8555            480,
8556            PixelFormat::Rgb,
8557            None,
8558            crate::CpuAccess::ReadWrite,
8559        )
8560        .unwrap();
8561        t.configure_image(320, 240, PixelFormat::Nv12).unwrap();
8562        assert_eq!(t.format(), Some(PixelFormat::Nv12));
8563        assert_eq!(t.width(), Some(320));
8564        assert_eq!(t.height(), Some(240));
8565        assert_eq!(t.shape(), &[360, 320]); // 240*3/2
8566    }
8567
8568    #[test]
8569    fn configure_image_too_large_errors() {
8570        let mut t = Tensor::<u8>::image_with_capacity(
8571            64,
8572            64,
8573            PixelFormat::Grey,
8574            None,
8575            crate::CpuAccess::ReadWrite,
8576        )
8577        .unwrap();
8578        let err = t
8579            .configure_image(1920, 1080, PixelFormat::Nv12)
8580            .unwrap_err();
8581        assert!(matches!(err, Error::InsufficientCapacity { .. }));
8582    }
8583
8584    /// A reused max-sized IOSurface pool keeps its physical `bytesPerRow` when
8585    /// reconfigured to a smaller logical image (physical-grid / logical-ROI
8586    /// decoupling), instead of collapsing to the frame's natural row stride.
8587    #[test]
8588    #[cfg(target_os = "macos")]
8589    fn configure_image_preserves_iosurface_physical_stride() {
8590        // Declares this test as an fd-opener; see FD_LOCK.
8591        let _lock = crate::tests::fd_lock_shared();
8592        // Pool: GREY/R8 IOSurface 100 wide → bytesPerRow padded to 128.
8593        let mut pool = Tensor::<u8>::image(
8594            100,
8595            64,
8596            PixelFormat::Grey,
8597            Some(TensorMemory::DmaBuf),
8598            crate::CpuAccess::ReadWrite,
8599        )
8600        .unwrap();
8601        let pitch = pool.effective_row_stride().unwrap();
8602        assert!(
8603            pitch >= 128 && pitch.is_multiple_of(64),
8604            "padded bytesPerRow, got {pitch}"
8605        );
8606
8607        // Reconfigure to a smaller NV12 frame; the physical pitch must survive
8608        // (natural would be 32, but the surface stride is the 128-padded pitch).
8609        pool.configure_image(32, 16, PixelFormat::Nv12).unwrap();
8610        assert_eq!(pool.format(), Some(PixelFormat::Nv12));
8611        assert_eq!(pool.width(), Some(32));
8612        assert_eq!(pool.height(), Some(16));
8613        assert_eq!(
8614            pool.effective_row_stride(),
8615            Some(pitch),
8616            "configure_image must preserve the IOSurface physical bytesPerRow"
8617        );
8618
8619        // Reconfigure again to NV24 — pitch still preserved.
8620        pool.configure_image(32, 16, PixelFormat::Nv24).unwrap();
8621        assert_eq!(pool.effective_row_stride(), Some(pitch));
8622    }
8623
8624    /// `configure_image` on a Mem backing reconfigures to the format's
8625    /// **64-byte-aligned** row stride (the odd-dim contract: every image tensor
8626    /// carries a 64-aligned `row_stride`). For NV12 32×16 the minimum is
8627    /// `even(32)=32`, rounded up to the 64-byte alignment → 64. The capacity
8628    /// (64×64×4 RGBA = 16 KiB) easily holds the 24×64 = 1.5 KiB NV12 layout.
8629    #[test]
8630    fn configure_image_mem_aligns_stride() {
8631        let mut t = Tensor::<u8>::image_with_capacity(
8632            64,
8633            64,
8634            PixelFormat::Rgba,
8635            Some(TensorMemory::Mem),
8636            crate::CpuAccess::ReadWrite,
8637        )
8638        .unwrap();
8639        t.configure_image(32, 16, PixelFormat::Nv12).unwrap();
8640        let s = t.effective_row_stride().unwrap();
8641        assert_eq!(s % 64, 0, "stride must be 64-aligned");
8642        assert!(s >= 32, "stride must cover the even-width minimum");
8643        assert_eq!(s, 64);
8644    }
8645
8646    #[test]
8647    fn strided_mem_tensor_cpu_maps_full_padded_buffer() {
8648        // A packed RGBA image with row padding (GPU-pitch style): logical width
8649        // 8 px (32 B/row) but a 48-byte row stride. Over-allocate capacity (for
8650        // 16 px), narrow the logical width, then record the padded stride.
8651        // Previously `map()` rejected this on non-Linux with
8652        // "DMA backing is Linux-only"; HAL-owned Mem is now mappable.
8653        let mut t = Tensor::<u8>::image_with_capacity(
8654            16,
8655            3,
8656            PixelFormat::Rgba,
8657            Some(TensorMemory::Mem),
8658            crate::CpuAccess::ReadWrite,
8659        )
8660        .unwrap(); // capacity 3 × 16 × 4 = 192 B
8661        t.configure_image(8, 3, PixelFormat::Rgba).unwrap(); // logical [3, 8, 4] = 96 B
8662        t.set_row_stride(48).unwrap(); // padded stride (>= 32 B min)
8663
8664        let map = t.map().expect("strided Mem tensor should CPU-map");
8665        // Full padded buffer (stride 48 × 3 rows = 144 B), not the 96 B logical
8666        // view — callers iterate rows via `effective_row_stride()`.
8667        assert_eq!(map.as_slice().len(), 144);
8668        // Logical shape is still reported for shape-aware consumers.
8669        assert_eq!(map.shape(), &[3, 8, 4]);
8670    }
8671
8672    #[test]
8673    fn strided_mem_tensor_over_capacity_errors() {
8674        // Stride larger than the allocation: 64 B × 3 rows = 192 B > 96 B cap.
8675        let mut t = Tensor::<u8>::new(&[3, 8, 4], Some(TensorMemory::Mem), None).unwrap();
8676        t.set_format(PixelFormat::Rgba).unwrap();
8677        t.set_row_stride(64).unwrap();
8678        assert!(matches!(t.map(), Err(Error::InsufficientCapacity { .. })));
8679    }
8680
8681    /// Test that SHM memory allocation is available and usable on Unix systems.
8682    /// This is a basic functional test; Linux has additional FD leak tests using procfs.
8683    #[test]
8684    #[cfg(unix)]
8685    fn test_shm_available_and_usable() {
8686        // Declares this test as an fd-opener; see FD_LOCK.
8687        let _lock = crate::tests::fd_lock_shared();
8688        // "Unix" is not one platform here: Android is unix but its bionic libc
8689        // has no POSIX shared memory, so allocation legitimately reports
8690        // NotImplemented (see shm.rs module docs). Asserting availability
8691        // outright fails there for a documented reason.
8692        if !is_shm_available() {
8693            log::warn!(
8694                "SKIPPED: test_shm_available_and_usable - SHM allocation \
8695                 unavailable on this platform"
8696            );
8697            return;
8698        }
8699
8700        // Create a tensor with SHM backing
8701        let tensor = Tensor::<u8>::new(&[100, 100], Some(TensorMemory::Shm), None)
8702            .expect("Failed to create SHM tensor");
8703
8704        // Verify we can map and write to it
8705        let mut map = tensor.map().expect("Failed to map SHM tensor");
8706        map.as_mut_slice().fill(0xAB);
8707
8708        // Verify the data was written correctly
8709        assert!(
8710            map.as_slice().iter().all(|&b| b == 0xAB),
8711            "SHM tensor data should be writable and readable"
8712        );
8713    }
8714
8715    // =========================================================================
8716    // packed_rgba16f_layout — host-runnable geometry unit tests (TDD)
8717    // =========================================================================
8718
8719    #[test]
8720    fn packed_rgba16f_layout_planar_rgb_f16() {
8721        let layout =
8722            packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 640, 640).expect("Some");
8723        assert_eq!(layout.surface_w, 160);
8724        assert_eq!(layout.surface_h, 1920);
8725        assert_eq!(layout.bytes_per_texel, 8);
8726        assert_eq!(layout.pitch, 1280);
8727    }
8728
8729    #[test]
8730    fn packed_rgba16f_layout_planar_rgba_f16() {
8731        let layout =
8732            packed_rgba16f_layout(PixelFormat::PlanarRgba, DType::F16, 640, 640).expect("Some");
8733        assert_eq!(layout.surface_w, 160);
8734        assert_eq!(layout.surface_h, 2560); // 4 planes
8735        assert_eq!(layout.bytes_per_texel, 8);
8736        assert_eq!(layout.pitch, 1280);
8737    }
8738
8739    #[test]
8740    fn packed_rgba16f_layout_rejects_misaligned() {
8741        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::F16, 642, 640).is_none());
8742    }
8743
8744    #[test]
8745    fn packed_rgba16f_layout_rejects_non_f16() {
8746        // Non-F16 dtype with planar RGB
8747        assert!(packed_rgba16f_layout(PixelFormat::PlanarRgb, DType::U8, 640, 640).is_none());
8748        // Non-planar format with F32
8749        assert!(packed_rgba16f_layout(PixelFormat::Rgb, DType::F32, 640, 640).is_none());
8750        // Packed Rgba with F16 is not a planar format → None
8751        assert!(packed_rgba16f_layout(PixelFormat::Rgba, DType::F16, 640, 640).is_none());
8752    }
8753
8754    #[test]
8755    fn cuda_map_fast_fails_to_none_without_handle() {
8756        let t = Tensor::<f32>::new(&[4], Some(TensorMemory::Mem), None).unwrap();
8757        assert!(t.cuda().is_none());
8758        assert!(t.cuda_map().is_none()); // pure local check, no GL routing
8759    }
8760
8761    #[test]
8762    fn cuda_returns_none_without_handle() {
8763        // A plain Mem-backed tensor has no CUDA handle attached.
8764        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8765        assert!(t.cuda().is_none(), "no CUDA handle on a Mem tensor");
8766        assert!(t.cuda_map().is_none(), "fast-fail map → None");
8767    }
8768
8769    #[test]
8770    fn cuda_map_then_host_map_fallback() {
8771        // The documented client pattern: try cuda_map() first; when it is None
8772        // (no CUDA handle — the case for a plain Mem tensor), fall back to map().
8773        let t = Tensor::<f32>::new(&[2, 2], Some(TensorMemory::Mem), None).unwrap();
8774        // Bind to a named variable so the CudaMap guard (and its borrow of `t`)
8775        // is dropped at the end of this statement, before the else branch borrows `t` again.
8776        let cuda = t.cuda_map();
8777        if let Some(_c) = cuda {
8778            // On a CUDA-registered tensor we'd use the device ptr here.
8779            unreachable!("a Mem tensor has no CUDA handle");
8780        } else {
8781            let host = t.map().expect("host map fallback must succeed");
8782            // TensorMapTrait::len() returns the element count (not bytes).
8783            assert_eq!(host.len(), 4); // 2*2 f32 elements
8784        }
8785    }
8786
8787    // -------------------------------------------------------------------------
8788    // Tensor::from_foreign — public API tests at the Tensor<T> layer.
8789    //
8790    // The low-level MemTensor::from_foreign mechanics (owner-drop, view sharing)
8791    // are covered in mem.rs.  These tests exercise the Tensor<T> guard paths
8792    // (null ptr, empty shape, size overflow) and the basic wrap+readback
8793    // contract, confirming the public unsafe API wires through correctly.
8794    // -------------------------------------------------------------------------
8795
8796    #[test]
8797    fn from_foreign_valid_wrap_and_readback() {
8798        // The canonical CUDA zero-copy shape: wrap a caller allocation as a
8799        // Mem tensor and verify the tensor reads the exact same bytes.
8800        let mut buf: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
8801        let ptr = buf.as_mut_ptr();
8802        let t = unsafe { Tensor::<f32>::from_foreign(ptr, &[2, 3], None, Some("test_foreign")) }
8803            .expect("valid from_foreign must succeed");
8804        assert_eq!(t.shape(), &[2, 3]);
8805        assert_eq!(t.memory(), TensorMemory::Mem);
8806        assert_eq!(t.name(), "test_foreign");
8807        let m = t.map().unwrap();
8808        // The tensor is a zero-copy borrow — it sees the caller's data.
8809        assert_eq!(m.as_slice(), &[1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0]);
8810    }
8811
8812    #[test]
8813    fn from_foreign_write_visible_in_caller_allocation() {
8814        // Writes through the tensor's map land in the caller's buffer (zero-copy).
8815        let mut buf: Vec<u8> = vec![0u8; 6];
8816        let ptr = buf.as_mut_ptr();
8817        let t = unsafe { Tensor::<u8>::from_foreign(ptr, &[2, 3], None, None) }.unwrap();
8818        {
8819            let mut m = t.map().unwrap();
8820            m.as_mut_slice().copy_from_slice(&[10, 20, 30, 40, 50, 60]);
8821        }
8822        drop(t);
8823        // Mutations are visible in the original Vec — same physical buffer.
8824        assert_eq!(buf, vec![10, 20, 30, 40, 50, 60]);
8825    }
8826
8827    #[test]
8828    fn from_foreign_rejects_null_ptr() {
8829        let err = unsafe { Tensor::<u8>::from_foreign(std::ptr::null_mut(), &[4], None, None) }
8830            .unwrap_err();
8831        assert!(
8832            matches!(err, Error::InvalidArgument(ref m) if m.contains("non-null")),
8833            "expected InvalidArgument(non-null), got {err:?}"
8834        );
8835    }
8836
8837    #[test]
8838    fn from_foreign_rejects_empty_shape() {
8839        let mut dummy: u8 = 0;
8840        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &[], None, None) }.unwrap_err();
8841        assert!(
8842            matches!(err, Error::InvalidSize(0)),
8843            "expected InvalidSize(0) for empty shape, got {err:?}"
8844        );
8845    }
8846
8847    #[test]
8848    fn from_foreign_rejects_overflow_shape() {
8849        // Two dimensions whose product overflows usize — the overflow guard must
8850        // fire before any pointer arithmetic is attempted.
8851        let mut dummy: u8 = 0;
8852        let huge = [usize::MAX / 2 + 1, 2];
8853        let err = unsafe { Tensor::<u8>::from_foreign(&mut dummy, &huge, None, None) }.unwrap_err();
8854        assert!(
8855            matches!(err, Error::InvalidArgument(ref m) if m.contains("overflow")),
8856            "expected InvalidArgument(overflow), got {err:?}"
8857        );
8858    }
8859
8860    #[test]
8861    fn from_foreign_owner_keeps_allocation_alive() {
8862        // When `owner` is `Some`, dropping the Tensor must not free the backing
8863        // before the owner is also gone — the owner's Drop fires on last ref.
8864        use std::sync::atomic::{AtomicBool, Ordering};
8865        let flag = std::sync::Arc::new(AtomicBool::new(false));
8866        let flag2 = flag.clone();
8867        struct Guard(std::sync::Arc<AtomicBool>);
8868        impl Drop for Guard {
8869            fn drop(&mut self) {
8870                self.0.store(true, Ordering::SeqCst);
8871            }
8872        }
8873        let mut buf: Vec<u32> = vec![42u32; 4];
8874        let ptr = buf.as_mut_ptr();
8875        let owner: ForeignOwner = Box::new(Guard(flag2));
8876        let t = unsafe { Tensor::<u32>::from_foreign(ptr, &[4], Some(owner), None) }.unwrap();
8877        // Map co-owns the backing Arc; the owner must stay alive while the map lives.
8878        let m = t.map().unwrap();
8879        assert_eq!(m.as_slice()[0], 42);
8880        drop(t); // tensor dropped while map is still live
8881        assert!(
8882            !flag.load(Ordering::SeqCst),
8883            "owner must not drop while a map shares the backing"
8884        );
8885        drop(m);
8886        assert!(
8887            flag.load(Ordering::SeqCst),
8888            "owner Drop must fire when the last Arc reference is released"
8889        );
8890    }
8891}