Python Bindings Requirements — Hurray Implementation Requirements

Overview

The hurray-python package is the Python face of the Hurray interchange format: the library a Python program uses to produce Hurray tensors, consume them, and hand them off to the surrounding array ecosystem without copying. It is built on PyO3 and is a codec and zero-copy bridge — not a numerical or compute library, and not an implementation of the Python Array API Standard (see ADR-029). Its two goals are:

  1. Produce / consume Hurray tensors — construct tensors and serialize them to the Hurray format, and parse Hurray data into usable Python objects, including the types the ecosystem cannot represent natively (Tier 2, quantized, sparse, composite).
  2. Zero-copy interop — share buffers with NumPy, PyTorch, JAX, and CuPy without copying, via DLPack, the NumPy array protocols, and the native Hurray buffer protocol.

These interop protocols are standalone — DLPack in particular is an independent specification, not part of the Array API (a consumer's from_dlpack works on any object exposing __dlpack__, with no Array API namespace involved). hurray-python therefore does not claim, and MUST NOT advertise, Array API conformance; it is Array-API-interoperable (a Tier 1 tensor handed to NumPy/PyTorch becomes a real array those ecosystems can compute on), not Array-API-implementing.

Rationale

Note (non-normative): This section explains why hurray-python exists and the incentives that shape its surface. It is motivation, not a requirement.

hurray-python is the Python face of the Hurray format — the library a Python program uses to produce Hurray tensors (serialize its data into the format), consume them (load Hurray data into usable Python objects), and hand them off to the surrounding array ecosystem without copying. It is a codec and an interchange bridge, in the same spirit as the Python packages for other data formats: not a numerical or compute library, and not a place where array math lives. Its reason to exist is simply that Python is where the machine-learning ecosystem lives, and a format with no ergonomic Python entry point would never be adopted there.

Three incentives drive its design:

  • Reach the ecosystem on day one. The array ecosystem already shares tensors through a widely implemented zero-copy handoff. By speaking that same handoff, hurray-python interoperates with NumPy, PyTorch, JAX, and CuPy immediately, for the common case (ordinary dense tensors of the standard element types), with no per-library adapters to write. This is why Hurray tensors are made to feel like ordinary arrays in that ecosystem rather than opaque blobs.

  • Preserve full fidelity between Hurray-aware components. The common ecosystem handoff can only describe the ordinary dense case. Everything that makes Hurray worth having — compressed and quantized data, sparse and other specialized layouts, richer element types, and the metadata that travels with a tensor — falls outside what that handoff can carry. So hurray-python also offers a native Hurray interchange path that preserves the tensor in full, for sharing between components that both understand Hurray.

  • Bridge the gap while adoption grows. In an ideal end state, producer and consumer libraries would understand Hurray natively, the same way they understand the common handoff today. Until then, hurray-python is the adapter that speaks both sides: it ingests tensors from the existing ecosystem and emits Hurray, and vice versa. This bridging role is deliberately temporary in ambition — it recedes naturally as more of the ecosystem adopts Hurray directly.

Finally, hurray-python offers a handful of direct convenience methods for the most common partners (NumPy, PyTorch) even though the generic zero-copy handoff already exists. This is a deliberate, adoption-minded choice: a new format is accepted or rejected on how much friction it adds, so a one-call, discoverable path matters. These methods also cover the cases the generic handoff cannot express, so that no data is silently left behind.

Tensor Surface

hurray-python MUST expose a hurray.Tensor class with an inspection and interop surface. This surface describes the tensor and hands it off zero-copy; it does not include array computation (elementwise math, reductions, linear algebra, indexing, or operators) — those belong to the framework the buffer is handed to. hurray.Tensor MUST NOT implement __array_namespace__, and the hurray module is not an Array API namespace (see ADR-029).

Inspection and interop methods

MethodRequirement
__dlpack__(stream=None)MUST return a DLPack capsule for zero-copy buffer sharing. See DLPack Interoperability for stream semantics.
__dlpack_device__()MUST return a (DLDeviceType, device_id) tuple. device_id is passed as runtime metadata at Tensor construction time and defaults to 0. See DLPack Interoperability.
__hurray__(stream=None)MUST return a native Hurray capsule (full-fidelity, all dtypes). See Native Interchange Protocol.
dtypeMUST return the tensor's hurray.dtype.* object.
shapeMUST return a Tuple[Optional[int], ...]. Each element is an int for a known dimension, or None for a dynamic (unknown) dimension.
ndimMUST return the number of dimensions.
sizeMUST return Optional[int]: the total number of elements, or None if one or more dimensions are dynamic (unknown).
deviceMUST return a device object consistent with __dlpack_device__.
TMUST return a transposed view without copying for rank-2 tensors. MUST raise ValueError if the tensor is not rank-2.

Dtype surface

hurray.Dtype MUST expose, in addition to name, bit_width, tier, and the is_float / is_integer / is_signed / is_sub_byte predicates:

MemberRequirement
tagMUST return the type's normative wire tag (element-types.md § Type Tags).
element_alignmentMUST return the natural alignment of one element in bytes. Sub-byte types MUST report 1.
Dtype.from_tag(tag)MUST return the type for a wire tag, and MUST raise hurray.InvalidDescriptorError for the permanently invalid sentinels (0x00, 0xFF) and for tags reserved by this version. A tag in the private-extension range (0xF0–0xFE) is not an error; it MUST resolve to an extension type that preserves the tag.

Dtype.from_tag and Dtype.from_name MUST return the singleton for the type, not a new object: the class documents hurray.float32 is hurray.dtype.float32, and a lookup that returned an equal-but-not-identical object would break that quietly.

Because every private extension type shares the name "extension", repr MUST include the tag, which is the only thing distinguishing two of them.

Buffer sizing

The module MUST expose hurray.buffer_size_bytes(dtype, count) -> int, applying the packing rules of memory-layout.md § Sub-byte packing. count * dtype.bit_width // 8 is not a substitute: it is wrong for every sub-byte type.

Dynamic dimensions

A dynamic dimension MUST be spelled None in a shape passed to hurray.Tensor, matching what Tensor.shape returns for one, so that a shape read from a tensor can be passed back to build an equal descriptor (ADR-032 § 4). Implementations MUST NOT also expose a sentinel constant for it.

Functions that allocate a buffer or validate one against the shape — zeros, ones, empty, full, sparse_coo, Composite — MUST refuse a dynamic dimension with hurray.InvalidDescriptorError, naming the call and the offending index.

A tensor with a dynamic dimension MUST NOT render its buffer as data in repr or str: the wire sentinel reaches NumPy as -1, which means "infer this extent", so an unresolved dimension would print as an empty tensor.

Tier 1 dtype interop correspondence

Note (non-normative): Tier 1 element types use the standard numeric vocabulary, so a Tier 1 hurray.Tensor maps to a NumPy dtype without translation when handed to the ecosystem. This correspondence is an interop detail, not an Array API claim.

Hurray typeNumPy dtype (for interop)
boolnumpy.bool_ (via __array__; not representable over DLPack — see below)
int8numpy.int8
uint8numpy.uint8
int16numpy.int16
uint16numpy.uint16
int32numpy.int32
uint32numpy.uint32
int64numpy.int64
uint64numpy.uint64
float16numpy.float16
bfloat16no native NumPy dtype (e.g. ml_dtypes.bfloat16); crosses via DLPack to PyTorch/JAX
float32numpy.float32
float64numpy.float64
complex64numpy.complex64
complex128numpy.complex128

Tier 2 and quantized types

For Tier 2 element types (sub-byte integers, float8 variants) and quantized types, hurray-python MUST expose a hurray-namespaced dtype object (e.g., hurray.dtype.int4, hurray.dtype.float8_e4m3). These have no standard NumPy dtype and cross between Hurray-aware components via the native protocol or save/load.

Tensors with Tier 2 / quantized dtypes MAY still implement __dlpack__ where DLPack supports the element type. For element types outside the DLPack type enum, __dlpack__ MUST raise the Python built-in BufferError.

DLPack Interoperability

hurray-python MUST support DLPack for all element types that DLPack defines: float16, bfloat16, float32, float64, int8, uint8, int16, uint16, int32, uint32, int64, uint64, complex64, complex128, bool.

  • __dlpack__() MUST return a PyCapsule named "dltensor_versioned" conforming to the DLPack specification (v1.0 or later).
  • __dlpack__() MUST raise the Python built-in BufferError for any element type not in the DLPack type enum (e.g., int4, float8 variants, quantized types). BufferError is the conventional signal used across the ecosystem (NumPy, PyTorch) for a tensor that cannot be represented in DLPack.
  • The DLPack capsule MUST reference the original buffer without copying. The buffer's reference count MUST be incremented when the capsule is created and decremented when the capsule is consumed or deleted.

Stream parameter semantics

The stream parameter of __dlpack__(stream=None) maps to the tensor's SyncMode:

stream valueRequirement
NoneThe tensor MUST have SyncMode::ProducerSynced. The buffer is already fully written; no synchronisation is required by the consumer.
-1The binding layer MUST perform a device-level synchronisation (equivalent to cudaDeviceSynchronize on CUDA) before returning the capsule.
Positive integer (stream handle)If the tensor is ProducerSynced, the buffer is already ready; the stream argument MUST be ignored. Tensors with SyncMode::Event or SyncMode::ConsumerStream are out of scope for the initial Layer 8a implementation; the binding MUST raise BufferError for these modes.

Private device tags and memory classes

0xF0–0xFE is the private range in both spaces: an agreement between one producer and one consumer, which the spec gives no name.

  • hurray.Device's kind and memory_class MUST each accept a name or a wire byte. The names cover what the spec assigns; the byte is how a private value is reached, since there is no name to pass.
  • Device.tag and Device.memory_class_tag MUST expose the wire bytes, and Device.is_private MUST report whether the device tag is in the private range.
  • kind and memory_class MUST report "private" for every value in the range — that is what the spec calls them — so repr MUST include the tag. Without it two different vendor devices print identically, which is a reader unable to tell what it is holding.
  • A reserved or permanently-invalid byte MUST be refused rather than accepted as a private one: a later version of the format may assign it, and a descriptor built on a guess would then mean something else.

The same rule governs private element types, whose repr carries the tag for the same reason (§ Dtype surface).

Device ID

DLPack requires a device_id integer (e.g., GPU index) that is not stored in the Hurray wire format. The device_id MUST be passed as runtime metadata when constructing a hurray.Tensor and defaults to 0 (the first device of that type). __dlpack_device__() MUST return this runtime device_id.

Device and Memory Class Mapping (Hurray → DLPack)

The correct DLPack DLDeviceType for a Hurray buffer is determined by the combination of device_tag and memory_class. DLPack encodes what Hurray separates into two orthogonal fields as a single flat enum (e.g., kDLCUDAHost, kDLCUDAManaged). The binding layer is responsible for the translation; neither raw Hurray values NOR raw DLPack integers are stored in the other system's fields.

See ADR-020 for the rationale behind the two-field design.

Full mapping table

Hurray device_tagHurray memory_classDLPack DLDeviceTypeDLPack int
0x00 CPUSTANDARDkDLCPU1
0x00 CPUHOST_PINNEDkDLCPU1
0x00 CPUUNIFIEDkDLCPU1
0x01 CUDASTANDARDkDLCUDA2
0x01 CUDAHOST_PINNEDkDLCUDAHost3
0x01 CUDAUNIFIEDkDLCUDAManaged13
0x01 CUDAPEER—raise hurray.UnsupportedError
0x02 ROCmSTANDARDkDLROCM10
0x02 ROCmHOST_PINNEDkDLROCMHost11
0x02 ROCmUNIFIED—raise hurray.UnsupportedError
0x02 ROCmPEER—raise hurray.UnsupportedError
0x03 MetalSTANDARDkDLMetal8
0x03 MetalHOST_PINNEDkDLMetal8
0x03 MetalUNIFIEDkDLMetal8
0x04 VulkanSTANDARDkDLVulkan7
0x04 VulkanHOST_PINNEDkDLVulkan7
0x04 VulkanUNIFIEDkDLVulkan7
0x04 VulkanPEER—raise hurray.UnsupportedError
0x05 WebGPUSTANDARDkDLWebGPU15
0x06 HexagonSTANDARDkDLHexagon16
0x06 HexagonHOST_PINNEDkDLHexagon16
0x06 HexagonUNIFIEDkDLHexagon16
0x07 Level ZeroSTANDARDkDLOneAPI14
0x07 Level ZeroHOST_PINNEDkDLOneAPI14
0x07 Level ZeroUNIFIEDkDLOneAPI14
0x07 Level ZeroPEER—raise hurray.UnsupportedError
0x08 OpenCLSTANDARDkDLOpenCL4
0x08 OpenCLHOST_PINNEDkDLOpenCL4
0x08 OpenCLUNIFIEDkDLOpenCL4
0xF0–0xFEany—raise hurray.UnsupportedError

Combinations not listed above (e.g., WebGPU + HOST_PINNED, which is invalid per the per-device validity table in ADR-020) MUST NOT occur in a conforming descriptor; if encountered, the binding layer SHOULD raise hurray.UnsupportedError.

Notes on the mapping:

  1. Hurray's device_tag values are intentionally distinct from DLPack's DLDeviceType integers (ADR-016). Translation is the binding layer's responsibility — Hurray tag values MUST NOT be passed directly to DLPack consumers, and DLPack integers MUST NOT be stored in a Hurray buffer handle.
  2. When implementing __dlpack_device__, the binding layer MUST derive the DLDeviceType from the (device_tag, memory_class) pair using the table above. It MUST NOT return the raw Hurray device_tag value.
  3. CPU HOST_PINNED and UNIFIED both map to kDLCPU because DLPack does not distinguish host-pinned from ordinary host memory from the CPU's perspective. The memory_class field carries this information for consumers that need it.
  4. Metal STANDARD, HOST_PINNED, and UNIFIED all map to kDLMetal (8) because DLPack does not distinguish Metal storage modes. Consumers that need to distinguish them MUST read the Hurray memory_class field directly. Note: HOST_PINNED (StorageManaged) is deprecated on Apple Silicon; see ADR-020.
  5. ROCm UNIFIED has no DLPack equivalent (kDLROCMManaged does not exist in DLPack v1.0). The binding MUST raise hurray.UnsupportedError.
  6. PEER memory has no DLPack equivalent for any device type. The binding MUST raise hurray.UnsupportedError for any PEER buffer exposed via DLPack.
  7. For implementation-private device tags (0xF0–0xFE), the binding layer MUST NOT fabricate a DLPack mapping. It MUST raise hurray.UnsupportedError unless the consumer has explicitly agreed on a private mapping out of band.
  8. For combinations in notes 5, 6, and 7 where hurray.UnsupportedError is raised, Hurray-aware consumers SHOULD use the native interchange protocol (__hurray__) instead. See Native Interchange Protocol.

Native Interchange Protocol

hurray-python MUST expose a native interchange protocol for hurray-to-hurray zero-copy transfers covering (device_tag, memory_class) combinations that DLPack v1.0 cannot represent (see ADR-023).

  • hurray.Tensor.__hurray__(stream=None) -> PyCapsule — MUST return a PyCapsule named "hurray_tensor_handle" wrapping a HurrayTensor pointer from hurray-ffi (ADR-043). Available for all dtypes (Tier 1, Tier 2, quantized).
  • hurray.from_hurray(obj, /) -> hurray.Tensor — MUST accept any object whose __hurray__ returns a valid capsule and reconstruct a hurray.Tensor that owns the transferred buffers.

Multi-buffer tensors

A tensor whose descriptor references more than one buffer — per-channel / NF4 / MXFP quantization, sparse layouts, block-paged, composite — MUST carry every buffer in a single capsule (ADR-030).

  • The capsule pointer MUST be a HurrayTensor owning the descriptor and one HurrayBuffer per buffer, reached through hurray_tensor_buffers (ADR-043). A single-buffer tensor is the N = 1 case, not a separate path.
  • Element i of that buffer list MUST be the buffer at index i of the descriptor's buffer table. Buffer indices appearing in quantization descriptors (scale_buffer_index, zero_point_buffer_index), layout descriptors, and composite members index the list directly.
  • A producer MUST NOT emit a capsule whose buffer count differs from the descriptor's buffer count, and a consumer MUST reject such a capsule rather than construct a tensor whose buffer indices do not resolve.
  • hurray.Tensor MUST expose the descriptor's optional sections for reading: quantization (returning the scheme class, or None), statistics, shard, and buffer_count. The quantization getter MUST return an object of the same class the constructor accepts, so an inspected scheme can be reused to build another tensor without conversion.
  • A sparse-layout hurray.Tensor MUST carry its component buffers through this protocol, with its values and index buffers in descriptor order. A separate __hurray_sparse_buffer__ protocol MUST NOT be introduced: sparse is the multi-buffer case, not a distinct one.
  • hurray.save() MUST write every buffer of a tensor, and hurray.load() MUST accept multi-buffer tensors, rejecting any whose buffer count disagrees with its descriptor.

Capsule lifetime

The PyCapsule lifetime rules MUST match DLPack discipline:

  • Capsule name on creation: "hurray_tensor_handle".
  • Capsule name after consumption: "used_hurray_tensor_handle". The consumer MUST rename the capsule before taking ownership, exactly as DLPack consumers rename "dltensor" to "used_dltensor".
  • Capsule destructor: if the capsule is destroyed while still named "hurray_tensor_handle", the destructor MUST call hurray_tensor_destroy on the wrapped pointer, which releases the descriptor, every buffer the tensor owns, and the owner reference. If the capsule has been consumed (renamed), the consumer MUST call hurray_tensor_destroy exactly once. That one call replaces the two-call teardown the pointer/context pair required, and has no ordering requirement.
  • The consumer MUST keep the HurrayTensor alive for as long as it reads the buffers: the descriptor and the buffer list obtained from hurray_tensor_descriptor and hurray_tensor_buffers, and the handles obtained from hurray_buffer_list_get, are borrowed and MUST NOT be destroyed individually.
  • The source Tensor's Python reference count MUST be incremented when the capsule is created and decremented in both the destructor and consume paths. See Buffer Lifetime and Ownership.

stream parameter

__hurray__(stream=None) uses the same stream semantics as __dlpack__: see Stream parameter semantics.

One handle, no capsule context

There is no capsule context. The capsule carries one pointer — a HurrayTensor from hurray-ffi (ADR-043) — and the producer MUST NOT set a context on it. The descriptor and the producing build's HURRAY_C_ABI_VERSION are reached through that handle's accessors, so the pairing is structural: a consumer that has the pointer has the description of the bytes it points at, in any language, with no structure private to hurray-python in the way.

A producer MUST build the handle so that it owns the descriptor and the finished HurrayBufferList before the capsule exists. A producer reaching the ABI from C does that with hurray_tensor_new, which decodes the encoded descriptor it is given, so a capsule whose descriptor does not parse is refused where it is created rather than where it is consumed. hurray-python itself holds a decoded TensorDescriptor already and hands that to hurray-ffi's Rust constructor instead: encoding it only for hurray_tensor_new to decode it straight back would buy validation a hurray.Tensor has by construction (ADR-043, Consequences).

The strong reference that keeps the source tensor alive MUST travel as the handle's opaque owner, released through its owner_release callback, so that no Python type reaches the C ABI.

ABI versioning

hurray.from_hurray MUST read the version with hurray_tensor_abi_version before any other accessor and before trusting anything else about the handle; a mismatch MUST raise hurray.UnsupportedError. See C FFI § Tensor Handle for why that ordering is normative.

Discovery

Consumers MUST discover support by probing hasattr(obj, '__hurray__'). There is no separate capability flag on the hurray namespace.

Descriptor Encoding

This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.

The descriptor is the format's central artifact. hurray-python MUST be able to produce and consume it, or a Python program cannot carry a Hurray tensor in a container of its own nor read one that arrived out of band.

hurray.Descriptor

A Descriptor is what a tensor declares, without its data. It MUST expose dtype, shape, ndim, size, layout, buffer_handles, buffer_count, device, quantization, shard, statistics, byte_offset, version, and encoded_len, and MUST agree with the tensor it came from on every one of them.

It MUST NOT be constructible from Python: a constructor would duplicate hurray.Tensor's entire parameter list to build the half of it that carries no data. Descriptors come from Tensor.descriptor, from Composite.descriptor (the head — the one descriptor that is only a descriptor), or from Descriptor.decode.

decode MUST return a Descriptor and MUST NOT return a Tensor: a decoded descriptor has no buffers, and a Tensor holding none would be the same class of false statement ADR-037 removed from alignment.

Encoding

  • Descriptor.encode() MUST produce the binary descriptor exactly as it appears on the wire, and Descriptor.decode(bytes) MUST recover an equal descriptor from it.
  • decode MUST accept trailing bytes and ignore them. The descriptor carries its own length; in a stream what follows it is the data it describes.
  • encoded_len MUST equal len(encode()).

Standalone quantization sections

Each quantization class MUST expose encode(), and the module MUST expose hurray.decode_quantization(bytes) returning whichever class the section's scheme tag names — a caller reads the bytes without knowing in advance which scheme wrote them.

The classes MUST compare by value, like every other descriptor value object in this binding. A class carrying a floating-point field MUST NOT define __hash__: NaN would break the hash/equality contract, and being unhashable is the correct outcome rather than an omission.

Buffer Lifetime and Ownership

Zero-copy interop requires that the source object's buffer remains valid for the entire lifetime of any object that holds a pointer to it. The binding layer MUST enforce the following rules:

  • hurray.from_numpy(array) → hurray.Tensor: The Tensor MUST hold a strong Python reference to the source ndarray for its own lifetime. The ndarray MUST NOT be garbage-collected while the Tensor is alive.

  • hurray.Tensor.__array__() → ndarray: The returned ndarray MUST reference the source Tensor as its base object (via NumPy's base attribute or an equivalent mechanism), so that the Tensor is kept alive for as long as the ndarray holds the buffer.

  • hurray.Tensor.__dlpack__() → capsule: The DLPack capsule destructor MUST decrement the Tensor's Python reference count when the capsule is consumed or deleted. The reference count MUST be incremented when the capsule is created. This ensures the Tensor (and therefore its buffer) is not freed while a DLPack consumer holds the capsule.

The same rules apply to the component Tensor views a sparse-layout tensor hands out.

Buffer Handles

This section uses RFC 2119 key words: MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL.

The binding MUST expose one hurray.BufferHandle per entry of a tensor's buffer table (ADR-037).

The class

hurray.BufferHandle MUST be immutable and MUST expose, as read-only properties:

PropertyTypeMeaning
byte_sizeintThe buffer's declared size in bytes
alignmentintThe alignment the buffer's base address satisfies
sync_modestr"producer_synced", "event", or "consumer_stream"
devicehurray.DeviceThe device and memory class the buffer lives in
is_emptyboolWhether byte_size is zero

It MUST compare and hash by value, and MUST NOT be constructible from Python: every field is settled by the buffers a tensor already holds, so there is nothing a caller could supply.

A BufferHandle MUST NOT hold a reference to its tensor or to any buffer. In a zero-copy format a metadata accessor that extends buffer lifetime is a defect: collecting handles across a stream MUST pin nothing.

Tensor.buffer_handles

hurray.Tensor.buffer_handles MUST be a tuple with one handle per buffer, in descriptor order, so len(t.buffer_handles) == t.buffer_count. It MUST be available for every layout and every device, including tensors whose bytes cannot be handed out.

Tensor.buffer(i) is unchanged and remains the byte-view accessor. The two are separate deliberately: on a device tensor the metadata question has an answer where the byte question does not.

BufferHandle.device MUST be the same object as Tensor.device, such that t.buffer_handles[i].device is t.device holds. buffer-protocol.md § Device Colocation requires every buffer of one descriptor to share a device and memory class, so there is exactly one to report.

Alignment is measured

A descriptor emitted by the binding MUST declare, for each buffer, an alignment its base address actually satisfies:

  • An empty buffer MUST declare 1.
  • An owned buffer MUST be allocated to at least MIN_BUFFER_ALIGNMENT and declare what it allocated.
  • A borrowed buffer MUST have its base address measured, and MUST declare the largest power of two that address satisfies, capped at PAGE_ALIGNMENT. A stronger true declaration is legal and useful to IPC and RDMA consumers.

The binding MUST NOT declare an alignment it has not established. A consumer issues aligned loads on the strength of this field.

copy on ingest

Because buffer-protocol.md § Alignment sets a 64-byte floor that NumPy does not promise, every ingest entry point — from_numpy, from_torch, from_scipy, sparse_coo, from_dlpack, asarray — MUST accept a keyword-only copy: bool | None = None:

copyRequired behaviour
NoneShare the source when its address meets the floor; otherwise copy into an allocation that does
FalseNever copy; raise hurray.CopyRequiredError naming the alignment the source actually has
TrueAlways copy

For a multi-buffer source the decision MUST be made per buffer: SciPy's .data, .indices and .indptr are three allocations with three addresses.

The aligned allocator

hurray-python MUST expose hurray.aligned_allocator(), a context manager that installs a 64-byte-aligned NumPy data-memory handler (NEP 49) for the duration of a block, so that arrays allocated inside it need no copy on ingest.

  • It MUST restore the previously installed handler on exit, including when the block raises, and MUST support nesting.
  • It MUST NOT be offered as a module-level install. The handler is thread-local, so a process-wide "install once" would silently fail to cover arrays allocated on other threads.
  • It MUST raise hurray.UnsupportedError on NumPy older than 1.22, which predates NEP 49.

Implementations MUST document that a thread started inside the block does not inherit the policy.

sync_mode

sync_mode MUST be read-only, and no constructor MAY accept it. "event" asserts that a device event exists for a consumer to wait on; no Python API supplies one, so a settable field could only author a contract nothing could honour. Everything the binding constructs is therefore producer_synced.

A tensor holding any buffer whose sync_mode is not producer_synced MUST refuse the paths that hand out its bytes — buffer(i), the component views (.values, .indices, .indptr), __array__, to_torch — with hurray.UnsupportedError naming the mode. __dlpack__ MUST raise the built-in BufferError instead, as Stream parameter semantics already requires.

__hurray__ and StreamWriter.write MUST continue to relay such tensors unchanged: relaying a declaration is not reading a byte.

Alignment and the round-trip obligation

alignment is exempt from the round-trip obligation that governs layout, quantization, statistics and shard (ADR-032 § 4). Alignment describes an address, and a rebuild that copies bytes has a different address; a tensor that arrived declaring 4096 MAY honestly declare 64 once rebuilt. Implementations MUST NOT add a settable alignment= to force equality.

Constants

The module MUST expose hurray.MIN_BUFFER_ALIGNMENT and hurray.PAGE_ALIGNMENT, mirroring hurray-core.

NumPy Interoperability

For CPU tensors with Tier 1 element types, hurray-python MUST support:

  • hurray.Tensor.__array__() — MUST return a NumPy ndarray backed by the same buffer (zero-copy for C-contiguous / row-major tensors; a copy is acceptable for other layouts if NumPy cannot represent them natively). See Buffer Lifetime and Ownership.
  • hurray.from_numpy(array) — MUST create a hurray.Tensor that shares the NumPy array's buffer without copying, for C-contiguous arrays. See Buffer Lifetime and Ownership.

For tensors with Tier 2 / quantized element types, hurray.Tensor.__array__() MUST raise hurray.UnsupportedError. NumPy has no dtype for int4, float8 variants, or quantized/scaled types; returning an ndarray is structurally impossible. Callers that need the raw bytes SHOULD use the native protocol or DLPack directly.

PyTorch Interoperability

For CPU and CUDA tensors, hurray-python MUST support zero-copy conversion via DLPack:

  • hurray.from_torch(tensor) — MUST call tensor.__dlpack__() and wrap the result in a hurray.Tensor without copying.
  • hurray.Tensor.to_torch() — MUST call self.__dlpack__() and construct a torch.Tensor via torch.utils.dlpack.from_dlpack without copying.

Layouts and Sparse Tensor Support

hurray-python MUST expose exactly one tensor class, hurray.Tensor, for every layout (ADR-031). There MUST NOT be a separate class per layout family: a sparse tensor is a hurray.Tensor whose layout happens to be COO, CSR, or CSC, matching the format, where sparse is a layout_tag inside the ordinary tensor descriptor.

  • .layout — MUST return a hurray.Layout object, not a string (ADR-032). See § Layout Descriptor Classes below.
  • .values — a hurray.Tensor view over the values buffer.
  • .indices (COO) or .col_indices / .row_ptr (CSR) or .row_indices / .col_ptr (CSC) — hurray.Tensor views over the index buffers.
  • .nnz — the stored non-zero count, for layouts that track one.
  • .to_scipy() — MUST convert a CSR or CSC tensor to the corresponding scipy.sparse matrix type without copying where scipy's memory layout is compatible.
  • hurray.from_scipy(matrix) — MUST wrap a scipy.sparse matrix as a hurray.Tensor without copying.

Accessors that do not apply to a tensor's layout MUST raise AttributeError, so that hasattr reports whether a tensor actually supports them (ADR-031 § 2, extending design decision D10). They MUST NOT raise hurray.UnsupportedError, which would make hasattr return True for every accessor on every tensor.

Protocols that require a densely addressable element buffer — __dlpack__, __array__, __array_interface__, to_torch — MUST reject non-dense layouts, naming the layout in the error message.

hurray.Tensor MUST additionally expose buffer(index), returning a 1-D uint8 view of exactly the declared byte size of the buffer at that tensor-descriptor buffer index. CSF has 2 * rank + 1 buffers and block-paged has three, none of which have named accessors; without a generic accessor their parameters would be reachable while their buffers were not. uint8 is the only honest element type for a generic view — the buffers within one tensor differ (values take the tensor's dtype, index buffers are uint64, MXFP scales are e8m0) — and it cannot misreport a dtype. The layout object tells the caller what each index means. An index with no buffer MUST raise IndexError.

Layout Descriptor Classes

hurray-python MUST expose a class hierarchy of layout descriptors (ADR-032). A layout carries parameters — nnz, strides, page_size — that a string cannot.

The hierarchy

hurray.Layout                        # base: holds the core layout descriptor
├── RowMajorLayout   ColMajorLayout
├── StridedLayout    TiledLayout     MortonLayout    HilbertLayout
├── CooLayout        CsrLayout       CscLayout       CsfLayout
├── BlockPagedLayout
├── CompositeLayout
├── PrivateExtensionLayout
└── UnknownLayout

The base MUST implement tag, name, buffer_count, is_dense and is_virtual once; subclasses are typed façades reading their own fields off the same descriptor. The base MUST NOT be constructible from Python.

buffer_count MUST be 0 for a composite head — a known zero — and None for a private or unknown layout, whose count is genuinely not knowable.

When the core layout enum gains a variant a build has not bound, .layout MUST return a bare Layout carrying tag and name. It MUST NOT be reported as UnknownLayout, which asserts that the tag was unrecognised rather than merely unbound.

Value semantics

Layout objects MUST be immutable, MUST compare by value, and MUST hash consistently with that equality. .layout is read-only, and MAY return a fresh object per access: t.layout is t.layout is not guaranteed, t.layout == t.layout is.

Layout classes MUST NOT define equality against strings. repr MUST take the form CsrLayout(nnz=4).

Frozen name and enumeration strings

Layout.name is one of: "row_major", "col_major", "strided", "tiled", "morton", "hilbert", "coo", "csr", "csc", "csf", "block_paged", "composite", or "extension" for a private or unrecognised tag. These MUST match the layout names in docs/spec/memory-layout.md. PrivateExtensionLayout and UnknownLayout therefore share a name; isinstance is what distinguishes them, and they MUST remain separate classes because "a private layout I can identify" and "a tag from a newer spec version I could not parse" are different facts a permissive relay depends on.

A single internal helper MUST produce the name string, so that .name and the layout named in error messages (__dlpack__, __array__, to_scipy) cannot drift.

Small closed enumerations are exposed as lowercase strings, not as Python enum classes:

PropertyValues
BlockPagedLayout.kv_role"key", "value", "fused"
BlockPagedLayout.block_table_index_type"uint32", "uint64"
TiledLayout.outer_layout"row_major", "col_major", "strided"
TiledLayout.inner_layoutthe above plus "tiled"
CompositeLayout.composition_rule"partition", "overlay", "group"
CompositeLayout.combine_op"replace", "add", or None

CompositeLayout MUST expose the composition rule and the combine operation as two properties, with combine being None for non-overlay rules. They MUST NOT be flattened into one string, and the raw combine byte MUST NOT be exposed for rules where it means "not applicable".

Strides are in logical elements

StridedLayout.strides and the tiled layouts' outer_strides and inner_strides are in logical elements, signed, and may be negative or zero — not in bytes as NumPy's are.

Authoring

hurray.Tensor MUST accept a layout keyword holding a hurray.Layout instance, mirroring quantization=. Omitting it means row-major. A non-Layout value MUST raise TypeError.

A layout string MUST NOT be accepted. A string cannot carry nnz or strides, so layout="csr" is a request that cannot be honoured; accepting it would create a second, lossy authoring path.

The layout object is a declaration; the buffers are evidence. They MUST agree, and the constructor MUST check three tiers:

TierCheckError
Shaperank and shape constraints (CSR rank 2, CSF rank ≥ 3, len(strides) == rank, …)hurray.InvalidDescriptorError
Buffer countsupplied buffers ≥ the layout's required count; quantization buffer indices fall beyond themhurray.InvalidDescriptorError
Buffer sizeeach buffer at least as large as the layout's parameters implyhurray.BufferError

A layout MUST NOT be inferred from the buffers, and the buffers MUST NOT be reinterpreted to fit the layout. CooLayout(nnz=4) supplied with a two-element values buffer MUST raise: the descriptor MUST NOT be silently corrected to nnz=2, and MUST NOT be accepted as given, since it would encode and decode cleanly and hand the consumer an out-of-bounds read. Over-sized buffers MUST be permitted; alignment and padding slack are legitimate.

Consequently nnz MUST be a required argument on the sparse layout constructors. Inference belongs to the array-shaped constructors — hurray.sparse_coo, hurray.from_scipy — which are handed the arrays and can derive it.

For every constructible layout, rebuilding a tensor from another tensor's layout, quantization, statistics, shard and buffers MUST produce an equal descriptor.

Tag classification

The module MUST expose hurray.layout_tag_kind(tag) -> str, returning "named", "reserved", "private", or "invalid". The four MUST partition the whole byte space.

A single function rather than the four predicates hurray-core exposes: the caller's purpose is to branch on the answer, and the four categories are mutually exclusive. The distinction is actionable — a reserved tag suggests a producer newer than this reader, so relaying the tensor is reasonable while interpreting its buffer is not; a private tag refers to an out-of-band agreement; an invalid tag cannot appear in a conformant descriptor at all.

Checking a layout against a shape

Layout.validate_against_shape(shape) MUST apply the layout's own rank and extent constraints, raising hurray.InvalidDescriptorError when the pair cannot go together. It MUST accept a dynamic dimension without complaint: an unresolved extent cannot violate an extent constraint.

The result MUST agree with what hurray.Tensor enforces at construction — the two are the same rule reached by two doors.

Block-paged validation

BlockPagedLayout MUST expose two checks:

  • validate_index_buffers(seq_ptr, block_table) — the four storage invariants of block-paged.md § Storage. num_pages and num_seqs come from the layout, so only the buffers are passed. Aliasing MUST be accepted: two sequences naming one page is how a shared prefix is represented.
  • validate_quantization_compatibility(quantization) — that a scheme and the paging can go together. It MUST take the quantization descriptor object, not a scheme tag.

Both raise hurray.InvalidDescriptorError. They matter because a descriptor describes its index buffers rather than containing them: their contents are unchecked at construction, and they are what stands between a consumer and an out-of-bounds read.

Element addressing (element_offset) is deliberately not exposed. Resolving one logical coordinate at a time is indexing, which this package does not do.

Composite, private, and unknown

  • CompositeLayout MUST be readable in full, so a composite head decoded from a stream reports its own layout truthfully. Constructing a hurray.Tensor with a composite layout MUST raise hurray.UnsupportedError: a composite head owns no buffers, which the Python Tensor cannot represent.
  • UnknownLayout MUST be constructible, so a permissive relay can reconstruct a descriptor it decoded and write it back out. Its constructor MUST reject any tag for which a named variant exists — calling a known tag "unknown" would smuggle an unvalidated descriptor past every rank and buffer check.
  • PrivateExtensionLayout exposes tag, extension_layout_id, and extension_data.

Known gap: because the buffer count of a private or unknown layout is unknowable, the buffer-count and buffer-size tiers cannot run for them. Nothing in such a descriptor states how many buffers it needs or how large they should be.

Composites

hurray-python MUST expose composite tensors through a hurray.Composite class (ADR-036). A composite MUST NOT be a hurray.Tensor: a composite contains tensors where a tensor has data, its head owns zero buffers, and len(members) has no meaning on a tensor.

composite = hurray.Composite(
    "partition", shape=[8, 8], dtype=hurray.float32, members=[tile0, tile1]
)

Construction

  • composition_rule — "partition", "overlay", or "group".
  • shape and dtype — the logical view the head presents. Both are required and MUST NOT be derived from the members: a partition's shape could be computed from its members' shards, and deriving it would silently reshape the head to match a caller's miscomputed offset.
  • members — a sequence of hurray.Tensor or hurray.Composite, since the format nests. Anything else MUST raise TypeError naming the offending index.
  • combine_op — required for "overlay", and MUST be rejected for the other rules, matching hurray.CompositeLayout, which is the same field.
  • member_count is taken from members rather than stated: it counts what was passed, so it cannot disagree with it.

Validation MUST delegate to hurray-core's CompositeValidator — per-member boxes, partition coverage, overlay ordering, member count. The binding MUST NOT keep a second copy of those rules. Failures MUST surface as hurray.InvalidDescriptorError.

Surface

MemberMeaning
membersthe members, in wire order, as a tuple
member_counthow many the head declares
layouta hurray.CompositeLayout
shape, ndim, dtypethe head's logical view

A Composite MUST compare by value — head and members alike — so that the round-trip obligation can be stated as equality. Its repr MUST be depth-aware, since composites nest.

Composite MUST NOT expose values, buffer, buffer_count, __array__, or __dlpack__. There is no data to expose; the members hold it.

I/O

  • StreamWriter.write MUST accept a Composite and emit it as head-then-members.
  • StreamReader MUST yield a Composite as one item. It MUST NOT surface a head and its members as separate items — that would lose the composition without raising.
  • save MUST accept a Composite as a named entry; load MUST return one.
  • Because the file container gives every tensor an index entry, a composite's members MUST be named "{head}.{index}", recursively. Those names are an artifact of the container, not of the composite, so they are generated rather than requested.
  • load MUST return a composite under its head's name and MUST NOT also return its members as top-level entries. A member requested explicitly by name MUST still be returned.

Overlay member roles

An overlay's members carry a role — the first is the base and spans the whole index space, the rest are corrections. The format fixes this by position, so hurray.Composite MUST derive the roles rather than accept them: a parameter would ask the caller to restate what the rule already determines, and give them a way to state it wrongly.

This is not inference in ADR-032 § 4's sense. Nothing is read out of the buffers; the role follows from the composition rule and the member's position, both of which the caller stated.

A member that already carries a role — one decoded from the wire — MUST keep it, so a round trip cannot relabel it.

The roles MUST be readable as Composite.member_roles, a tuple of "base" / "correction" / None, and MUST NOT be exposed on the member tensor: a tensor has no role, a tensor within an overlay does. Reading from the composite also makes an authored overlay and a decoded one answer identically.

Because the role lives on the composite rather than on the caller's tensor, the descriptor a member presents to its parent differs from the one on its tensor. Implementations MUST use the former when validating, when writing, and when comparing composites for equality — a composite that does not equal its own round trip is the symptom of getting this wrong.

Not on the native protocol

Composite MUST NOT implement __hurray__. That capsule carries one tensor — one descriptor and its buffers; a composite is a tree, and flattening one would require wire structure the format does not define. hasattr(obj, "__hurray__") therefore keeps meaning what it means. Use the streaming or file path, which the format defines for exactly this.

Streaming

hurray-python MUST expose the streaming interchange format through two classes (ADR-035). The format's defining property is that neither side buffers the whole sequence, and the Python surface MUST preserve it: a reader MUST yield an item as soon as it is complete — a tensor once its buffers have arrived, a composite at its last member — and a writer MUST emit each item as it is given one.

with hurray.StreamWriter(destination) as writer:
    writer.write(tensor)

for item in hurray.StreamReader(source):
    ...

Blocking

Both classes are blocking and MUST release the GIL around every call that touches the transport, so other Python threads run while a stream waits on it. How the bytes are moved is not specified here: the reference binding drives a hurray-io sans-io core over blocking I/O (ADR-041 § 9), having previously given each object its own tokio runtime, and neither is a requirement on an implementation. An asyncio surface is a second, additive surface; see § Asyncio surface below, ADR-035 § 1 and ADR-041 § 9.

hurray.StreamReader

  • MUST implement __iter__ returning itself and __next__ yielding a hurray.Tensor or a hurray.Composite, raising StopIteration at a clean end of stream.
  • MUST implement the context-manager protocol, and expose close(), so the transport can be released without waiting for collection. A closed reader MUST behave as exhausted rather than raise.
  • MUST read with next_item semantics, not next_tensor, so that a composite arrives as one item: a composite head owns no buffers, so reading it as an ordinary tensor would yield an empty tensor and then surface the head's members as top-level tensors — a stream that decoded "successfully" having lost the composition.

hurray.StreamWriter

  • MUST accept a hurray.Tensor in write, transmitting every buffer its descriptor references, in descriptor order.
  • MUST accept a hurray.Composite in write, emitting the head and then its members, recursively, in the wire order ADR-027 § Binding fixes. Any other argument MUST raise TypeError, not a hurray.* exception: nothing reached the transport, so nothing about the stream is wrong.
  • MUST implement the context-manager protocol, finishing the stream on exit. finish() MUST also be callable explicitly and MUST be idempotent, so the two paths compose.
  • MUST raise hurray.StreamError when written to after finishing.
  • With no destination, MUST build the stream in memory and return it from getvalue(). getvalue() on a writer that has a destination MUST raise hurray.StreamError.

Reader limits

hurray.StreamReader MUST accept keyword arguments bounding what a single frame may claim: max_descriptor_bytes, max_buffer_bytes, and max_composite_depth, each defaulting to hurray-io's own default. Exceeding one MUST raise hurray.StreamError.

These are not optional hygiene. A descriptor's length field is read before its contents, so a stream from an untrusted peer can ask a reader to allocate an arbitrary amount.

They MUST be keyword arguments rather than an options object: Python has keyword arguments, and an options class here would be a Rust shape in Python clothing.

Cross-machine transport

hurray.StreamReader and hurray.StreamWriter MUST accept cross_machine: bool = False, enforcing that every buffer's sync_mode is producer_synced. A device event or stream handle is meaningless on the far side of a network, so a descriptor that carries one across is unsatisfiable by construction; the check refuses it at the boundary rather than leaving a consumer to wait on an event that does not exist.

Transports

ArgumentMeaning
stra filesystem path
an object with fileno()a socket, a pipe, or an open file
bytes (reader only)an in-memory stream
omitted (writer only)build in memory; retrieve with getvalue()

A file descriptor MUST be duplicated, so that finishing or closing the stream does not close the caller's. An object with no descriptor — io.BytesIO — is not accepted directly; the TypeError raised MUST say so, since the fix (getvalue()) is not guessable from a bare type error.

Exceptions

FailureException
framing — truncated, malformed, or oversized framehurray.StreamError
the descriptor did not decode or validatehurray.InvalidDescriptorError
the transport failedhurray.FileError

Note (non-normative): a stream has no end marker — frames are self-delimiting and the stream ends at EOF, which is the property that also forbids end-of-file indexes. A stream truncated mid-frame therefore raises hurray.StreamError, while one truncated exactly on a frame boundary is indistinguishable from a shorter stream and yields no error. That is a property of the format, not of the binding.

File metadata

hurray.load_kv(path) -> dict MUST return a file's key-value section, and MUST be the exact inverse of what save(path, tensors, kv=...) writes: a dict saved and reloaded MUST compare equal. A file with no KV section MUST read as an empty dict.

It MUST be a separate call rather than an argument to load, which answers a different question and returns a different thing.

bool MUST be tested before int in both directions, since Python's bool is a subclass of int. Python's int writes as int64, so a value written from Python never carries the uint64 tag; a uint64 written by another producer MUST read back as int.

A KvValue variant this build does not recognise MUST raise rather than being dropped: a metadata dict with a silent hole in it is worse than a refusal.

Writer output

StreamWriter.getvalue() MUST be repeatable, like io.BytesIO.getvalue(). A destructive implementation makes the second call return empty, which turns a later read into a clean end of stream — data loss reported as success.

Asyncio surface

On Unix, hurray-python MUST expose a second streaming surface, hurray.aio, holding hurray.aio.StreamReader and hurray.aio.StreamWriter (ADR-042). It is additive: the blocking classes above keep their behaviour, and the async classes drive the same stream state machines over the same wire format.

  • For the same items, the async writer's output MUST be byte-identical to the blocking writer's (ADR-042, Consequences). There is one implementation of the protocol with two drivers over it, not two protocols.
  • Everything the blocking surface can stream, the async surface MUST be able to stream, composites included.
  • The surface MUST be driven by the caller's running event loop. It MUST NOT link an executor of its own, and MUST NOT drive the blocking transport vtable of ADR-041 § 8 (ADR-041 § 9): one core, two drivers, no blocking call inside a coroutine.
  • § Exceptions applies unchanged — framing failures raise hurray.StreamError, a descriptor that did not decode raises hurray.InvalidDescriptorError, and a failed transport raises hurray.FileError.

How the driver is divided between Python source and the extension is not specified here; the reference binding writes the readiness logic in Python over a private seam (ADR-042 § 1), and that seam is not part of this contract.

Import and platform

  • import hurray MUST NOT import hurray.aio. A caller MUST reach the surface with an explicit import hurray.aio. import hurray has to succeed on every platform the package ships wheels for, and this surface does not exist on all of them.
  • The surface is Unix-only. Importing hurray.aio on a platform where it is unavailable MUST raise hurray.UnsupportedError, naming the restriction and naming the blocking classes as what works everywhere. It MUST NOT surface as an ImportError on a private name: the obstacle is a platform decision (issue #215), not a missing module.

Readiness, not transfers

Every await in this surface MUST wait for the descriptor to become readable or writable, and MUST NOT cover a transfer.

  • A transfer MUST move the bytes and report the byte count to the core in one synchronous step, with no suspension point between the two (ADR-041 § 6a, ADR-042 § 2).
  • A driver MUST NOT await a transfer. This excludes loop.sock_recv_into and loop.sock_sendall: each performs its transfer inside a loop callback and then resolves a future, so a cancellation landing between those two events loses the byte count. A lost count is unrecoverable — 0x00 is valid tensor data, so no inspection of the stream can recover the position.
  • The apparent escape MUST NOT be used either. Reading through asyncio.StreamReader keeps cancelled data in asyncio's own buffer and so cannot lose a count, but it hands back a byte sequence that must then be copied into the core's destination. That is the second, tensor-sized payload copy ADR-041 § 2 exists to forbid; resumability MUST NOT be bought with it.
  • A destination the core owns MUST NOT be exposed to Python, as a memoryview or otherwise. The core replaces that destination when a buffer completes, so a view that outlived one step would be a use-after-free (ADR-042 § 2). Keeping the transfer below the Python boundary is what prevents such a view from existing, rather than documenting that callers must not keep one.
  • A transport that reports not ready — EAGAIN, or a syscall interrupted by a signal — MUST NOT be reported to the core as a transferred count of zero; the driver MUST wait for readiness again. On the read path a reported zero means end of input, so reporting a spurious wakeup as zero turns it into a stream that appears to end cleanly mid-item.
  • On the write path, a transfer that accepts zero bytes on a descriptor the loop reported writable MUST be treated as a closed sink, not retried. It MUST raise hurray.StreamError, and it MUST leave the writer poisoned: the item in flight can no longer be completed.

Because the transfer cannot block — the descriptor is non-blocking and the loop has already reported it ready — § Blocking's GIL-release requirement does not extend here. An implementation MAY hold the GIL across an async transfer.

Note (non-normative): this is the one rule in this section whose violation is silent. A binding built on loop.sock_recv_into passes every test that is not a cancellation test, and loses a byte count only under cancellation, where nothing can detect it afterwards.

Cancellation

Cancellation is observable only at a readiness await, which is what the rule above buys. Its consequences are not symmetrical, and both halves are contract.

  • A cancelled read MUST leave the reader usable. No byte moved at the only cancellation point the read path has, so the next call resumes the same item and reads it through (ADR-041 § 6). A partially filled destination MUST NOT be observable: it belongs to the core until the item completes, so cancellation can never hand a caller a half-written tensor.
  • A cancelled write MUST poison the writer if, and only if, the item had already put a byte on the wire (ADR-041 § 6b). The item is truncated at the peer, a stream has no resynchronisation point, and no forward-only writer can make that output conforming again. A write cancelled before its first byte MUST leave the writer intact, and the same item MUST be writable again.
  • A poisoned writer MUST refuse every later write with hurray.StreamError, and the message MUST state that a fresh writer over the same connection is not a recovery: the truncation is at the peer, which must be told, or the connection replaced.
  • asyncio.CancelledError MUST propagate. An implementation MUST NOT convert it to a hurray.* exception and MUST NOT swallow it. It MUST drop the operation in flight before re-raising, so that the core's own record of whether a byte was emitted is what answers the question above — a driver MUST NOT keep a second copy of that predicate.

The reader

hurray.aio.StreamReader is § hurray.StreamReader's contract with an event loop in place of a blocking thread. It MUST:

  • implement __aiter__ returning itself and __anext__ yielding a hurray.Tensor or a hurray.Composite, raising StopAsyncIteration at a clean end of stream;
  • expose next_item() as an awaitable returning the next item, or None at a clean end of stream, with __anext__ layered on it. The primitive is named because outside an async for a caller wants the end of stream as a value, not as an exception;
  • read with next_item semantics, so that a composite arrives as one item, for the reason § hurray.StreamReader gives;
  • implement the asynchronous context-manager protocol, and expose close(). A closed reader MUST behave as exhausted rather than raise. close() MUST NOT be a coroutine: it releases a descriptor and has nothing to wait for;
  • accept max_descriptor_bytes, max_buffer_bytes, max_composite_depth and cross_machine as keyword arguments, with the same defaults and the same meaning § Reader limits and § Cross-machine transport require of the blocking reader. The limits are not optional hygiene on this surface either, and an untrusted peer is the case an event loop is reached for.

The writer

hurray.aio.StreamWriter MUST:

  • accept a hurray.Tensor or a hurray.Composite in an awaitable write, transmitting every buffer the item references in descriptor order and, for a composite, the head and then its members recursively in the wire order ADR-027 § Binding fixes. Any other argument MUST raise TypeError, not a hurray.* exception, for the reason § hurray.StreamWriter gives;
  • expose finish() as an idempotent awaitable that releases the descriptor, and implement the asynchronous context-manager protocol, finishing the stream on exit;
  • raise hurray.StreamError when written to after finishing;
  • accept cross_machine as § Cross-machine transport requires;
  • hold a strong reference to every object that owns payload bytes for as long as the item is in flight, releasing it when the item completes or is abandoned and not before (ADR-041 § 6). The async writer borrows the payload across suspensions, so a source tensor or composite MUST NOT become collectable mid-item;
  • refuse a second write submitted while one is in flight with hurray.StreamError. One stream object is driven by one task; concurrency across streams is what this surface exists for, and asyncio.gather over several readers or writers already expresses it.

Note (non-normative): finish() flushes nothing. Every byte of an accepted item has already left through a write on the descriptor, so finishing releases the descriptor and nothing else — earlier than collection would, which is the whole of its value here.

Transport and the descriptor

The async surface accepts a narrower transport than § Transports, and each exclusion is a refusal with a named exception rather than a caveat:

ArgumentAsync surface
an object with fileno() naming a socket or a pipeaccepted
an object with no fileno() — a str path, bytes, io.BytesIOTypeError
an object whose fileno() names a regular filehurray.UnsupportedError
omitted (writer)not accepted: no in-memory mode, no getvalue()
  • A path, a byte sequence, or an in-memory destination has no readiness to await. The TypeError MUST name the blocking class as the thing to use instead, since the fix is not guessable from a bare type error — the same requirement § Transports makes for io.BytesIO.
  • A regular file MUST be refused with hurray.UnsupportedError: an event loop cannot poll one, and a transfer against one does not block, so readiness has nothing to contribute.
  • The descriptor MUST be duplicated, as § Transports requires, so that finishing or closing the stream does not close the caller's. Readiness MUST be awaited on the same descriptor the transfers use, so that the two cannot drift apart.
  • The descriptor MUST be put in non-blocking mode, and implementations MUST document that this is observable through the object the caller passed in: O_NONBLOCK belongs to the open file description, which dup shares. Any descriptor an event loop drives has to be in that mode regardless.

No async file API

hurray.load, hurray.save and hurray.load_kv MUST NOT be given hurray.aio counterparts. asyncio has no asynchronous random access, so a coroutine over them would be the synchronous call with a thread behind it — which asyncio.to_thread already expresses in one line, at a cost the caller can see (ADR-042 § 2). The absence is a decision, not a gap.

Error Handling

All errors from the Rust core MUST be surfaced as Python exceptions:

Rust errorPython exception
Parse / validation errorshurray.InvalidDescriptorError (subclass of ValueError)
Buffer size / alignment errorshurray.BufferError (subclass of ValueError)
Unsupported type or layouthurray.UnsupportedError (subclass of NotImplementedError)
File I/O errors (Layer 8b)hurray.FileError (subclass of OSError)
Stream I/O errors (Layer 8b)hurray.StreamError (subclass of OSError)

hurray.FileError is raised by file-level operations (hurray.load(), hurray.save()): file not found, permission denied, corrupt HRRYFILE container, unexpected EOF.

hurray.StreamError is raised by the streaming reader/writer: frame corruption mid-stream, unexpected stream termination, framing errors on a pipe or socket.

Both FileError and StreamError are subclasses of OSError; callers that do not need to distinguish between the two MAY catch OSError directly.

FileError and StreamError are introduced in Layer 8b (file I/O bridge) and are not present in Layer 8a (core types + DLPack).

Panics from the Rust core MUST NOT propagate as Python crashes. The PyO3 binding layer MUST catch panics and convert them to hurray.InternalError (subclass of RuntimeError).

hurray.from_hurray (Layer 8c) MUST raise:

  • hurray.BufferError if the capsule is null, already consumed (named "used_hurray_tensor_handle"), or otherwise invalid.
  • hurray.UnsupportedError if the HURRAY_C_ABI_VERSION the capsule's HurrayTensor reports does not match the consumer's linked version.

Conformance and Validation

hurray-python is validated against the shared golden test-vector corpus (conformance/vectors/), the same corpus the Rust implementation is checked against, plus the binding's own unit and integration tests.

  • The Python binding MUST decode every descriptor and buffer in the golden corpus to the same logical values as the Rust reference, and MUST re-encode round-trippable vectors to byte-identical output. This is exercised in CI (see the python-conformance job).
  • The array-api-tests suite is not used: it targets a whole conforming Array API namespace and presupposes the compute core, which hurray-python deliberately does not implement (see ADR-029). Cross-checking against the golden corpus maps directly onto the parts of the Hurray specification the binding actually covers.
  • New behaviour MUST ship with tests that exercise the public interop surface (DLPack, NumPy/PyTorch bridges, the native protocol, save/load).

Benchmark Suite

hurray-python SHOULD maintain a benchmark suite that measures performance across the Hurray interchange and interop paths — the operations that define the binding's purpose: constructing tensors, moving buffers zero-copy, and serializing/parsing the Hurray format.

Benchmark categories

The suite SHOULD cover the following categories:

CategoryRepresentative benchmarks
DLPack capsule__dlpack__() round-trip (create + consume); capsule destructor overhead; from_dlpack() from NumPy and PyTorch.
NumPy interopfrom_numpy() on an aligned source (shared) and an under-aligned one (copied); __array__() (zero-copy); dtype coverage (all Tier 1 types).
PyTorch interopfrom_torch() and to_torch() round-trip on CPU and CUDA.
Native protocol__hurray__() / from_hurray() round-trip (full-fidelity, all dtypes).
Constructionzeros, ones, full, arange, linspace for representative shapes and dtypes.
Serializationsave/load and streaming read/write throughput (GiB/s) for representative tensors.
Memory lifecycleTensor allocation + deallocation throughput; large-tensor zero-copy overhead (GiB-scale).
Sparse layoutsCOO/CSR/CSC construction; SciPy round-trip; file round-trip.

Tooling

  • Benchmarks MUST be runnable via a standard Python benchmarking tool (e.g., pytest-benchmark or airspeed-velocity (asv)).
  • Benchmarks SHOULD report: mean, standard deviation, and minimum latency; throughput in GiB/s for memory-bound operations.
  • Regression tracking (detecting performance regressions across commits) SHOULD be automated in CI. Benchmarks that regress by more than 10% relative to the baseline SHOULD trigger a warning in the pull request.
  • The benchmark suite MUST be runnable independently from the validation tests.

Compatibility Matrix

The hurray-python package MUST maintain a compatibility matrix document at hurray-python/COMPAT-MATRIX.md. This document lives alongside the code — not in the spec — because it changes with every release.

The compatibility matrix MUST record, for each hurray-python release series:

  • The DLPack specification version(s) supported as producer and as consumer.
  • The supported CPython version range.
  • The Hurray format/descriptor version(s) the binding produces and consumes.

The matrix MUST be updated whenever a new hurray-python release changes any of these.

Packaging

  • The package MUST be installable via pip install pyhurray. The distribution name and the import name differ: the module imported MUST be hurray.
  • Wheels MUST be provided for CPython ≥ 3.10 on Linux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64).
  • The package MUST NOT require a Rust toolchain at install time (pre-built wheels are mandatory for distribution).
  • Optional dependencies: numpy, torch, scipy (none required at import time; interop functions raise ImportError if the target library is not installed).