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