Hurray Format Specification

Version: 0.1.0-draft

Hurray is a language-agnostic, zero-copy runtime interchange format for multi-dimensional tensor data, optimized for the memory layout diversity, quantization schemes, and access patterns of modern AI/ML inference pipelines and scientific arrays.

Scope and Goals

  • Define a binary tensor descriptor encoding that is language- and runtime-agnostic.
  • Enable zero-copy buffer sharing across runtimes, processes, and devices.
  • Support the full range of quantization schemes used in modern inference.
  • Be streamable (streaming format): a reader MUST be able to start processing tensor data without buffering the entire input, and a writer MUST be able to emit tensor data incrementally without buffering the entire output. Tensor descriptors always precede their data buffers; the format is self-delimiting; back-references are not permitted. File format writers operate in a single forward pass and append a footer index at the end.
  • Extensible and evolvable: extension points are stable across 1.x; new named values go through the spec amendment process; backward and forward-additive compatibility is guaranteed within major version 1.x. See Versioning § Evolvability Contract.
  • Use the standard numeric dtype vocabulary (shared by NumPy and the Python Array API Standard) for Tier 1 element types, enabling zero-copy interoperability without dtype translation. This is an interop convenience, not an Array API conformance claim. See Python Bindings for binding-level requirements.
  • Serve as the storage foundation of an array database engine: the file format, tiled/blocked layout, Morton and Hilbert curve layouts, and footer index are designed to be compatible with sub-array queries, tile-skipping, and range-based retrieval. A concrete target use case is an embeddable SQL/MDA query engine (ISO 9075-15) backed by Hurray buffers, with zero-copy handoff from query results to an ML inference pipeline. Spec decisions that would foreclose chunk-based access, spatial locality, dimension-range indexing, or SQL/MDA interoperability MUST be evaluated against this use case before being adopted.

RFC 2119 Notice

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in these documents are to be interpreted as described in RFC 2119.

Versioning

This specification follows semantic versioning. A reader MUST reject a tensor descriptor whose major version field exceeds the reader's supported major version.

Table of Contents

SectionDescription
Element TypesNumeric element type system: type tags, bit widths, encoding, sub-byte packing
Data ModelElement type system, shape and dimension model
QuantizationQuantization scheme registry: descriptor header, scheme tag space, partial-block policy, buffer placement rules
Per-Tensor AffinePer-tensor affine quantization — scheme tag 0x01 (Tier 1)
Per-Channel AffinePer-channel (per-axis) affine quantization — scheme tag 0x02 (Tier 1)
Per-Block AffinePer-block affine quantization — scheme tag 0x03 (Tier 1)
NF4 (NormalFloat4)NF4 (NormalFloat4) block quantization — scheme tag 0x04 (Tier 2)
MXFP (OCP Microscaling)MXFP (OCP Microscaling) block quantization — scheme tag 0x05 (Tier 2)
Memory LayoutLayout taxonomy, common fields, element address computation, alignment, sharding, buffer table
Row-Major (C Order)Row-major (C order) layout — tag 0x01
Column-Major (Fortran Order)Column-major (Fortran order) layout — tag 0x02
StridedStrided layout with negative/zero stride support — tag 0x03
Tiled / BlockedTiled / blocked layout with recursive nesting — tag 0x04
Morton (Z-Order Curve)Morton (Z-order curve) layout — tag 0x05
COO (Coordinate)COO (Coordinate) sparse layout — tag 0x06
CSR (Compressed Sparse Row)CSR (Compressed Sparse Row) sparse layout — tag 0x07
CSC (Compressed Sparse Column)CSC (Compressed Sparse Column) sparse layout — tag 0x08 (also known as CCS)
CSF (Compressed Sparse Fiber)CSF (Compressed Sparse Fiber) sparse layout — tag 0x09
Block-PagedBlock-paged (PagedAttention KV cache) layout — tag 0x0A
Composite / Virtual TensorComposite / Virtual tensor (head + members) — tag 0x0B
Hilbert CurveHilbert curve layout — tag 0x40
Buffer ProtocolZero-copy semantics, alignment, device memory
MetadataTensor descriptor binary encoding
InterchangeStreaming IPC format: in-process, IPC, cross-machine network transport
File FormatFile format: random-access container with named tensors, footer index, KV metadata
VersioningFormat version field and compatibility policy
ReferencesNormative references

Data Model — Hurray Format Specification

Status: Draft

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

Scope

This section defines the logical data model of a Hurray tensor: the abstract description of its shape and element structure, independent of how it is stored in memory or encoded on the wire. All other sections of the specification operate on the model defined here.


Tensor

A tensor is a multi-dimensional array of elements, all of the same element type. A tensor is characterised by:

  • a rank — the number of dimensions,
  • a shape — the size of each dimension,
  • an element type — defined in element-types.md.

The element type and shape fully determine the logical content of the tensor. How the elements are mapped to memory is defined separately by the tensor's memory layout (memory-layout.md).


Rank

The rank of a tensor is a non-negative integer that counts the number of dimensions. It is encoded as a uint32 in the tensor descriptor (metadata.md).

  • A rank-0 tensor is a scalar: it contains exactly one element and has no shape array.
  • A rank-1 tensor is a vector.
  • A rank-2 tensor is a matrix.
  • Higher ranks are used for batched inputs, activation maps, and weight tensors in neural networks.

The maximum rank is 64. A writer MUST NOT emit a descriptor with rank > 64. A reader MUST reject a descriptor with rank > 64. A conforming implementation MUST support tensors of rank 0 through 64 inclusive (see docs/adr/ADR-008-normative-rank-cap-64.md).

Note (non-normative): The uint32 rank field can encode values above 64, but those values are reserved and will be rejected. The cap matches PyTorch's MAX_DIMS = 64 and bounds the shape-array size at 512 bytes (64 × 8), enabling stack allocation on the descriptor-parsing hot path.


Shape

The shape of a tensor is an ordered sequence of rank dimension sizes, indexed from 0. Dimension 0 is the outermost (slowest-varying) dimension in row-major order; dimension rank − 1 is the innermost (fastest-varying).

Each dimension size is encoded as a uint64. A dimension size:

  • MUST be greater than or equal to 0.
  • A size of 0 denotes an empty dimension: the tensor contains no elements along that axis. The total element count of a tensor with any zero-size dimension is 0.
  • The value 0xFFFFFFFFFFFFFFFF (UINT64_MAX) is the dynamic dimension sentinel — see § Dynamic Dimensions below.
  • Any other value in the range [1, 0xFFFFFFFFFFFFFFFE] is a static dimension size.

For a scalar tensor (rank = 0), the shape is the empty sequence; no shape bytes are present in the descriptor.

Total Element Count

For a tensor with no dynamic dimensions, the total number of logical elements is:

element_count = product(shape[i] for i in 0 .. rank-1)

This product is 1 for a scalar. For a tensor with one or more zero-size dimensions, element_count = 0.

A reader MUST NOT compute element_count for a tensor that has any dynamic dimension without first resolving all dynamic dimensions to concrete values.


Dynamic Dimensions

A dimension whose size is 0xFFFFFFFFFFFFFFFF is dynamic: its concrete value is not known at descriptor-write time and MUST be supplied by the reader or the interchange protocol before the tensor's data buffer can be safely accessed.

Rules for dynamic dimensions:

  1. A reader MUST NOT compute buffer sizes, strides, or element counts for a tensor containing a dynamic dimension without first resolving it to a concrete value.
  2. A writer that sets a dimension to 0xFFFFFFFFFFFFFFFF MUST ensure the interchange channel provides a mechanism to communicate the resolved value before the data buffer is transferred (see interchange.md).
  3. A quantization scheme that requires a statically known dimension size along its quantization axis (axis) MUST reject a descriptor whose shape[axis] is 0xFFFFFFFFFFFFFFFF. The specific constraint is documented per scheme in quantization/.
  4. Shard descriptors MUST NOT use dynamic dimensions (see metadata.md § Shard Section).

Note (non-normative): Dynamic dimensions are intended for streaming and just-in-time dispatch scenarios, where a model producer does not know the batch size or sequence length at graph-compilation time. They are not a substitute for shape polymorphism at the type level.


Element Type

The element type of a tensor is identified by a uint8 type_tag defined in element-types.md. All elements in a tensor share the same type tag.

The element type describes the storage representation of each element in the data buffer. When a tensor is quantized (HAS_QUANTIZATION flag set), the storage type is an integer or float8 type and the quantization descriptor defines the mapping to real-valued elements. The storage type is always orthogonal to the quantization scheme: type_tag never encodes quantization semantics.


Scalar Tensors

A scalar tensor has rank = 0. Its shape is empty. It contains exactly one element. The data buffer size is sizeof(element_type) bytes (or a partial byte for sub-byte types; see element-types.md § Sub-Byte Packing).

A scalar tensor MUST NOT carry a strided, tiled, or sparse layout descriptor; only row-major (0x01) is permitted for scalars.

Note (non-normative): Scalar tensors arise as outputs of reduction operations (e.g., loss values) and as single-element configuration parameters.


Empty Tensors

A tensor is empty if element_count = 0. This occurs when at least one dimension has size 0. An empty tensor is valid; its data buffer has size 0 bytes.

An empty tensor MUST still carry a complete, valid descriptor with a correct element type, layout tag, buffer table, and any applicable quantization descriptor. A reader MUST accept an empty tensor without treating it as an error. A zero-length data buffer MAY be represented with a null pointer; the 64-byte alignment requirement does not apply to a zero-length buffer.

Note (non-normative): The decision to permit empty tensors is recorded in docs/adr/ADR-007-permit-empty-tensors.md. The primary motivation is round-trip fidelity with PyTorch, NumPy, JAX, Apache Arrow, and DLPack, all of which permit zero-size dimensions.


Relationship to Other Sections

  • element-types.md defines the type_tag values, sub-byte packing rules, and the Tier 1 / Tier 2 type classification.
  • memory-layout.md defines how the logical index space described by the shape is mapped to a linear buffer: strides, block shapes, sparse indices.
  • metadata.md defines the binary encoding of rank, shape, and all other descriptor fields on the wire.
  • quantization.md defines how storage elements are mapped to real-valued elements when HAS_QUANTIZATION is set.
  • interchange.md defines how dynamic dimensions are resolved during runtime tensor exchange.

Open Questions

All open questions in this section are resolved. See docs/adr/ADR-007-permit-empty-tensors.md (OQ-2) and docs/adr/ADR-008-normative-rank-cap-64.md (OQ-1).

Element Types

Status: Draft

Scope

This section defines the complete numeric element type system for the Hurray tensor format, including type identifiers, bit widths, encoding rules, and sub-byte packing conventions.

Note (non-normative): Hurray is a strictly numeric tensor format. String types, structured types, and arbitrary user-defined element types are out of scope. The type system is designed to cover the full range of numeric precisions encountered in modern AI/ML inference, from float64 down to 2-bit quantized integers.

Normative Requirements

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

Byte Order

All multi-byte fields in the Hurray format — including element data, descriptor fields, and metadata — MUST be encoded in little-endian byte order (least significant byte at the lowest address). The format does not define a big-endian encoding and contains no endianness indicator field.

Implementations running on big-endian host architectures MUST convert to little-endian on write and from little-endian on read. The wire format is always little-endian regardless of host byte order.

Note (non-normative): This matches the choice made by Apache Arrow, DLPack, and SafeTensors. All hardware platforms targeted by AI/ML inference workloads (x86, ARM, RISC-V, NVIDIA/AMD GPUs, Apple Silicon) are little-endian. A fixed byte order eliminates the need for byte-swap detection and preserves zero-copy semantics between any two conforming implementations.

Type Identifier Encoding

Every element type is identified by a type tag, encoded as a uint8 value in the binary tensor descriptor. The type tag space is partitioned as follows:

RangeAllocation
0x00Reserved (invalid)
0x01 -- 0x3FTier 1 core types
0x40 -- 0x7FTier 2 extended types
0x80 -- 0xEFReserved for future specification versions
0xF0 -- 0xFEReserved for implementation-private extensions
0xFFReserved (invalid)

A conforming reader MUST reject a tensor descriptor containing a type tag of 0x00 or 0xFF.

A conforming reader MUST reject a tensor descriptor containing a type tag it does not recognize, unless the reader is operating in an explicitly configured permissive mode. In permissive mode, the reader MAY accept the descriptor but MUST NOT attempt to interpret the tensor data buffer.

Implementations MUST NOT assign semantics to type tags in the range 0x80 -- 0xEF; these are reserved for future versions of this specification.

Implementations MAY use type tags in the range 0xF0 -- 0xFE for private extensions. Tensors using private extension type tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band.

Tier 1 -- Core Types

All conforming implementations MUST support every Tier 1 type. A conforming implementation MUST be able to read a tensor descriptor for any Tier 1 type and correctly interpret its metadata (shape, strides, buffer layout). Whether the implementation can perform computation on every Tier 1 type is outside the scope of this specification.

Floating-Point Types

TypeTagBit WidthDescription
float160x0116IEEE 754 binary16
bfloat160x0216Brain floating point
float320x0332IEEE 754 binary32
float640x0464IEEE 754 binary64

float16 (0x01)

float16 is the IEEE 754 binary16 format: 1 sign bit, 5 exponent bits, 10 significand (mantissa) bits. Total width is 16 bits (2 bytes). The two bytes MUST be stored in little-endian order (least significant byte first).

All IEEE 754 binary16 bit patterns are valid, including positive and negative zero, infinities, and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange; they MUST NOT canonicalize NaN payloads or flush subnormals to zero.

bfloat16 (0x02)

bfloat16 uses 1 sign bit, 8 exponent bits, and 7 significand (mantissa) bits. Total width is 16 bits (2 bytes). The two bytes MUST be stored in little-endian order.

Note (non-normative): bfloat16 has the same exponent range as float32 but with reduced mantissa precision. It is widely used in machine learning training and inference.

All 16-bit patterns are valid bfloat16 values, including infinities and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.

float32 (0x03)

float32 is the IEEE 754 binary32 format: 1 sign bit, 8 exponent bits, 23 significand bits. Total width is 32 bits (4 bytes). The four bytes MUST be stored in little-endian order.

All IEEE 754 binary32 bit patterns are valid. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.

float64 (0x04)

float64 is the IEEE 754 binary64 format: 1 sign bit, 11 exponent bits, 52 significand bits. Total width is 64 bits (8 bytes). The eight bytes MUST be stored in little-endian order.

All IEEE 754 binary64 bit patterns are valid. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.

Integer Types

TypeTagBit WidthSignedRange
int80x108yes-128 to 127
uint80x118no0 to 255
int160x1216yes-32768 to 32767
uint160x1316no0 to 65535
int320x1432yes-2147483648 to 2147483647
uint320x1532no0 to 4294967295
int640x1664yes-2^63 to 2^63 - 1
uint640x1764no0 to 2^64 - 1

All signed integer types use two's complement representation.

All multi-byte integer types MUST be stored in little-endian byte order.

int8 and uint8 occupy exactly one byte; byte order is not applicable.

All bit patterns within the specified width are valid for both signed and unsigned integer types. There are no trap representations.

Boolean Type

TypeTagBit WidthDescription
bool0x201Boolean, packed 8 per byte

A bool element represents a logical true or false value. Each boolean occupies a single bit. Booleans are packed 8 per byte using LSB-first (least significant bit first) order.

Packing rule: logical element at index i within a group of 8 is stored in bit (i % 8) of byte floor(i / 8). Bit 0 is the least significant bit of the byte.

  • A bit value of 0x1 represents true.
  • A bit value of 0x0 represents false.

When the total number of boolean elements is not a multiple of 8, the remaining high-order bits in the final byte MUST be set to 0x0.

Example: A 1-D boolean tensor with shape [5] and values [true, false, true, true, false] is stored as a single byte: 0x0D (binary 00001101). Bits 5, 6, and 7 are padding and MUST be zero.

Note (non-normative): This packing convention is identical to the one used by Apache Arrow for boolean arrays.

Tier 2 -- Extended Types

Tier 2 types are OPTIONAL. Conforming implementations MAY support any subset of Tier 2 types, including none. Implementations that do not support a given Tier 2 type MUST still reject (or, in permissive mode, skip) descriptors using that type tag according to the rules in the Type Identifier Encoding section.

Float8 Variants

TypeTagBit WidthFormatDescription
float8_e4m30x4081-4-3IEEE-style: 1 sign, 4 exponent, 3 mantissa
float8_e5m20x4181-5-2IEEE-style: 1 sign, 5 exponent, 2 mantissa
float8_e8m00x4280-8-0Exponent-only: 8 exponent bits, no sign, no mantissa

float8_e4m3 and float8_e5m2 follow the OCP (Open Compute Project) 8-bit Floating Point Specification (OFP8). Each occupies exactly 1 byte; byte order is not applicable.

float8_e4m3 (0x40): 1 sign bit, 4 exponent bits, 3 mantissa bits. Exponent bias is 7. NaN is represented by the bit patterns 0x7F and 0xFF (all exponent and mantissa bits set). There are no infinity representations.

float8_e5m2 (0x41): 1 sign bit, 5 exponent bits, 2 mantissa bits. Exponent bias is 15. This format supports infinities (0x7C and 0xFC) and NaN values (exponent all ones, mantissa non-zero).

float8_e8m0 (0x42): 8 exponent bits, no sign bit, no mantissa bits. This is a power-of-two scale factor format. The value is 2^(bits - 127) where bits is the unsigned 8-bit integer in the range [0x01, 0xFE]. The bit patterns 0x00 and 0xFF are reserved (NaN per OCP MX v1.0 § 5.6) and MUST NOT be used as data values. A reader encountering 0x00 or 0xFF in a float8_e8m0 buffer MUST treat the containing descriptor as invalid.

Note (non-normative): float8_e8m0 is primarily used as a scale factor type in microscaling (MX) quantization formats, not as a general-purpose element type.

Implementations MUST preserve all float8 bit patterns exactly during zero-copy interchange.

Sub-Byte Floating-Point Types

TypeTagBit WidthFormatDescription
float4_e2m10x4341-2-1OCP MX: 1 sign, 2 exponent, 1 mantissa
float6_e2m30x4461-2-3OCP MX: 1 sign, 2 exponent, 3 mantissa
float6_e3m20x4561-3-2OCP MX: 1 sign, 3 exponent, 2 mantissa

float4_e2m1 (0x43): 1 sign bit, 2 exponent bits, 1 mantissa bit. Exponent bias is 1. This is the MXFP4 format defined in the OCP Microscaling (MX) Specification. Each element occupies 4 bits.

float4_e2m1 uses the same LSB-first 4-bit packing as int4 (see § Sub-Byte Integer Types § 4-bit packing). When the total element count is odd, the high nibble of the final byte MUST be set to 0x0.

The complete value map, per OCP MX v1.0 § 5.2, is:

Bit patternValue
0x0 (0b0000)+0.0
0x8 (0b1000)-0.0
0x1–0x3positive subnormals: (mantissa / 2) * 2^(1-bias) = 0.5, 1.0
0x9–0xBnegative subnormals
0x4–0x7positive normals: (1 + mantissa / 2) * 2^(exponent - bias)
0xC–0xFnegative normals

Maximum representable value: 1.5 * 2^2 = 6.0. There are no infinity or NaN representations; values outside [-6.0, 6.0] MUST be clamped by hardware.

Implementations MUST preserve all float4_e2m1 bit patterns exactly during zero-copy interchange.

Note (non-normative): float4_e2m1 has native Tensor Core support on NVIDIA Blackwell (B100/B200) and is used in production quantized LLM inference. It is typically paired with float8_e8m0 block scales under the MX quantization scheme (see quantization.md).

float6_e2m3 (0x44) and float6_e3m2 (0x45): Both are OCP MX Specification 6-bit floating-point formats. Each element occupies 6 bits.

float6_e2m3: 1 sign bit, 2 exponent bits, 3 mantissa bits. Exponent bias is 1. Maximum representable value: (1 + 7/8) * 2^(3-1) = 3.75. Zero is represented by all-zero exponent and mantissa (sign-preserving). No infinity or NaN representations; out-of-range values MUST be clamped. Subnormals: exponent all-zero, mantissa non-zero → value = (mantissa/8) * 2^(1-bias). Normative bit-pattern → value mapping per OCP MX v1.0 § 5.3.

float6_e3m2: 1 sign bit, 3 exponent bits, 2 mantissa bits. Exponent bias is 3. Maximum representable value: (1 + 3/4) * 2^(7-3) = 28.0. Zero is represented by all-zero exponent and mantissa (sign-preserving). No infinity or NaN representations; out-of-range values MUST be clamped. Subnormals: exponent all-zero, mantissa non-zero → value = (mantissa/4) * 2^(1-bias). Normative bit-pattern → value mapping per OCP MX v1.0 § 5.4.

6-bit packing

Four 6-bit elements are packed into 3 bytes using LSB-first order across byte boundaries. Given four elements at logical indices 4k, 4k+1, 4k+2, 4k+3 stored in bytes B0, B1, B2:

ElementBits occupied
4k+0bits [5:0] of B0
4k+1bits [7:6] of B0, bits [3:0] of B1
4k+2bits [7:4] of B1, bits [1:0] of B2
4k+3bits [7:2] of B2

When the total number of elements is not a multiple of 4, the unused high-order bits in the final group of 3 bytes MUST be set to 0x0.

Buffer size in bytes for N elements: ceil(N * 6 / 8) = ceil(N / 4) * 3.

Worked example. Four 6-bit elements with bit patterns 0b000001, 0b000010, 0b000100, 0b001000 (logical indices 0, 1, 2, 3) pack into bytes B0, B1, B2 as follows.

SourceBits takenDestinationResulting byte bits
element 0 = 0b000001all 6 bitsB0[5:0]B0[5:0] = 000001
element 1 = 0b000010bits [1:0] = 0b10B0[7:6]B0[7:6] = 10
element 1 = 0b000010bits [5:2] = 0b0000B1[3:0]B1[3:0] = 0000
element 2 = 0b000100bits [3:0] = 0b0100B1[7:4]B1[7:4] = 0100
element 2 = 0b000100bits [5:4] = 0b00B2[1:0]B2[1:0] = 00
element 3 = 0b001000all 6 bitsB2[7:2]B2[7:2] = 001000

Assembling each byte (MSB on the left):

B0 = 10_000001 = 0x81
B1 = 0100_0000 = 0x40
B2 = 001000_00 = 0x20

The 3-byte packed group on the wire is therefore 0x81 0x40 0x20.

Implementations MUST preserve all float6_e2m3 and float6_e3m2 bit patterns exactly during zero-copy interchange.

Note (non-normative): float6_e2m3 and float6_e3m2 are defined in the OCP MX specification and intended for use with MX block quantization (see quantization.md). Hardware adoption is currently limited compared to MXFP4.

Extended Floating-Point Types

TypeTagBit WidthDescription
float1280x46128IEEE 754 binary128 (quad precision)

float128 (0x46): 1 sign bit, 15 exponent bits, 112 significand bits. Exponent bias is 16383. Total width is 128 bits (16 bytes). The sixteen bytes MUST be stored in little-endian order.

All IEEE 754 binary128 bit patterns are valid, including positive and negative zero, infinities, and NaN values. Implementations MUST preserve the bit pattern exactly during zero-copy interchange.

Note (non-normative): float128 is rarely used in ML inference. It is included for high-precision scientific computing workloads (e.g., physics simulations, climate modelling) that may share tensor data with inference pipelines via the array database use case (Core Property 10).

Sub-Byte Integer Types

TypeTagBit WidthSignedRange
int40x484yes-8 to 7
uint40x494no0 to 15
int20x4A2yes-2 to 1
uint20x4B2no0 to 3

Sub-byte integer types are packed into bytes using LSB-first order, analogous to the boolean packing rule.

4-bit packing

Two 4-bit elements are packed per byte. The element at even logical index 2k occupies bits [3:0] (the low nibble) of byte k. The element at odd logical index 2k+1 occupies bits [7:4] (the high nibble) of byte k.

int4 values use two's complement representation within 4 bits. The valid bit patterns are 0x0 through 0xF, representing values -8 (0x8) through 7 (0x7).

uint4 values are unsigned. The valid bit patterns are 0x0 through 0xF, representing values 0 through 15.

When the total number of 4-bit elements is odd, the high nibble of the final byte MUST be set to 0x0.

Example: A 1-D uint4 tensor with shape [3] and values [5, 12, 3] is stored as two bytes. Byte 0: element 0 in low nibble, element 1 in high nibble = 0xC5. Byte 1: element 2 in low nibble, padding in high nibble = 0x03.

2-bit packing

Four 2-bit elements are packed per byte. The element at logical index 4k + j (where 0 <= j < 4) occupies bits [2j+1 : 2j] of byte k.

Position in byteLogical index offsetBits
04k + 0[1:0]
14k + 1[3:2]
24k + 2[5:4]
34k + 3[7:6]

int2 values use two's complement representation within 2 bits. The valid bit patterns are 0b00 (0), 0b01 (1), 0b10 (-2), 0b11 (-1).

uint2 values are unsigned. The valid bit patterns are 0b00 (0), 0b01 (1), 0b10 (2), 0b11 (3).

When the total number of 2-bit elements is not a multiple of 4, the unused high-order bits in the final byte MUST be set to 0x0.

Example: A 1-D uint2 tensor with shape [3] and values [3, 1, 2] is stored as one byte. Element 0 in bits [1:0] = 0b11, element 1 in bits [3:2] = 0b01, element 2 in bits [5:4] = 0b10, padding in bits [7:6] = 0b00. Result: 0x27 (binary 00100111).

Complex Types

TypeTagBit WidthDescription
complex640x5064Two float32 values (real, imaginary)
complex1280x51128Two float64 values (real, imaginary)

A complex64 element consists of two consecutive float32 values: the real part followed by the imaginary part. Total width is 64 bits (8 bytes). Each constituent float32 MUST be stored in little-endian order.

A complex128 element consists of two consecutive float64 values: the real part followed by the imaginary part. Total width is 128 bits (16 bytes). Each constituent float64 MUST be stored in little-endian order.

Note (non-normative): Complex types are included for signal processing and scientific computing workloads that may share tensor data with ML inference pipelines. They are not commonly needed in LLM inference.

Type Properties Summary

The following table summarizes all defined types and their properties.

TypeTagTierBit WidthByte WidthSub-ByteAlignment (bytes)
float160x011162no2
bfloat160x021162no2
float320x031324no4
float640x041648no8
int80x10181no1
uint80x11181no1
int160x121162no2
uint160x131162no2
int320x141324no4
uint320x151324no4
int640x161648no8
uint640x171648no8
bool0x2011n/ayes1
float8_e4m30x40281no1
float8_e5m20x41281no1
float8_e8m00x42281no1
float4_e2m10x4324n/ayes1
float6_e2m30x4426n/ayes1
float6_e3m20x4526n/ayes1
float1280x46212816no16
int40x4824n/ayes1
uint40x4924n/ayes1
int20x4A22n/ayes1
uint20x4B22n/ayes1
complex640x502648no4
complex1280x51212816no8

The Alignment column specifies the minimum alignment requirement of the natural element type. This is the minimum alignment of an individual element within a contiguous buffer; the buffer itself has a separate, stricter alignment requirement (see buffer-protocol.md).

For sub-byte types, the alignment refers to the packed byte granularity: data for sub-byte types MUST start at a byte boundary within the buffer.

Tag 0x47 is reserved for future assignment by this specification. Implementations MUST NOT use tag 0x47. Private extensions MUST NOT assign tag 0x47.

Note (non-normative): Tag 0x47 does not appear in the table because it is intentionally reserved. It is held for a future Tier 2 type whose assignment will be defined in a later revision.

Note (non-normative): The alignment column for complex types reflects the natural alignment of the constituent floating-point element: complex64 lists alignment 4 (per float32 half) and complex128 lists alignment 8 (per float64 half). This matches the natural alignment of the constituent element type. Consumers loading a full complex value as a single 128-bit SIMD register must account for the fact that only the buffer-level alignment (64 bytes minimum, see buffer-protocol.md) guarantees register-width alignment, not the element-level alignment.

Buffer Size Calculation

For whole-byte types with bit width W >= 8, the minimum buffer size in bytes for a contiguous tensor with N total elements is:

buffer_size = N * (W / 8)

For sub-byte types with bit width B < 8, the general minimum buffer size in bytes for a contiguous tensor with N total elements is:

buffer_size = ceil(N * B / 8)

where ceil denotes the ceiling function (rounding up to the next integer).

For sub-byte bit widths B ∈ {1, 2, 4} (i.e. bool, int2/uint2, int4/uint4/float4_e2m1), 8 / B is an integer packing factor P and the formula reduces to the equivalent expression ceil(N / P). For B = 6 (float6_e2m3 and float6_e3m2), the general form ceil(N * 6 / 8) = ceil(N / 4) * 3 MUST be used, because four 6-bit elements pack into three bytes rather than into a whole number of elements per byte; see § Sub-Byte Floating-Point Types § 6-bit packing.

These formulas apply to contiguous (dense) layouts. For strided layouts, the buffer size depends on the strides; see memory-layout.md.

Interaction with Other Sections

  • Quantization (quantization.md): Quantized tensors use an element type from this section as their storage type and attach a quantization descriptor that defines the dequantization mapping. The storage type tag in the tensor descriptor always refers to a type defined here.
  • Memory Layout (memory-layout.md): Stride semantics for sub-byte types require special treatment. Strides are expressed in logical elements; the packing rules in this section define how logical element indices map to bit positions within the buffer.
  • Metadata (metadata.md): The type tag defined here is stored as a uint8 field in the binary tensor descriptor.

Relationship to the standard numeric vocabulary

Note (non-normative): The Tier 1 numeric types — bool, int8, uint8, int16, uint16, int32, uint32, int64, uint64, float16, bfloat16, float32, float64, complex64, complex128 — use the standard numeric dtype vocabulary shared by NumPy and the Python Array API Standard (data-apis.org/array-api). This alignment is intentional and is purely an interop convenience: a Hurray tensor carrying a Tier 1 element type maps to the corresponding NumPy dtype without translation when handed to the ecosystem. It does not imply that a Hurray tensor is an Array API array (see Python Bindings and ADR-029).

Note (non-normative): Tier 2 types (float8_e4m3, float8_e5m2, float8_e8m0, float4_e2m1, float6_e2m3, float6_e3m2, float128, int4, uint4, int2, uint2) and quantized tensor types (see quantization.md) have no standard NumPy dtype and are exposed as hurray-namespaced dtype objects in the Python bindings. Requirements for the Python bindings are defined in Python Bindings.

Open Questions

[OQ-1]: Should float8_e4m3 follow the OCP OFP8 convention (no infinities, two NaN values) or the IEEE 754 draft for binary8 (which may differ)? Resolved: float8_e4m3 normatively follows OCP OFP8 (no infinities, two NaN bit patterns, exponent bias 7), matching production hardware (NVIDIA H100/H200, AMD MI300). If IEEE 754 binary8 diverges when finalized, a separate type tag will be assigned rather than redefining this one.

[OQ-2]: Should the specification define a float128 (IEEE 754 binary128) type? Resolved: float128 is added as a Tier 2 type with tag 0x46. Note: the originally proposed tag 0x05 falls in the Tier 1 range (0x01–0x3F) and was corrected to 0x46 (Tier 2 range 0x40–0x7F). Rationale: high-precision scientific computing workloads sharing tensor data with inference pipelines via the array database use case (Core Property 10).

[OQ-3]: Should the private extension range (0xF0 -- 0xFE) require implementations to include a type descriptor (name, bit width) in the tensor metadata so that readers can at least compute buffer sizes for unknown types? Resolved by ADR-001: Private extension type tags MUST carry an inline descriptor encoding at minimum the bit width, packing, and floating-point parameters (sign/exponent/mantissa widths, exponent bias, NaN/Inf flags). See docs/adr/ADR-001-private-extension-type-descriptors.md. The descriptor binary encoding will be defined in metadata.md.

[OQ-4]: Should float4_e2m1 (MXFP4) be added as a Tier 2 type? Resolved: float4_e2m1 is added as Tier 2 with tag 0x43. Packing follows the LSB-first int4 convention (two elements per byte). Rationale: native Tensor Core support on NVIDIA Blackwell and production use in quantized LLM inference.

[OQ-5]: Should float6_e2m3 and float6_e3m2 be added as Tier 2 types? Resolved: Both are added as Tier 2 with tags 0x44 and 0x45 respectively. Packing: 4 elements per 3 bytes, LSB-first across byte boundaries. Buffer size: ceil(N / 4) * 3 bytes.

Quantization — Hurray Format Specification

Status: Draft

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

Scope

This section defines the binary encoding of the quantization descriptor: the opaque payload that appears inside the Quantization Section of a tensor descriptor (see metadata.md § Quantization Section). It specifies the set of supported quantization schemes, the wire format for each scheme's parameters, and the normative dequantization formula that a reader MUST apply to recover real-valued elements from the quantized storage buffer.

A Hurray quantized tensor has two parts:

  1. A storage type — an integer or float8 type tag defined in element-types.md that describes how each element is encoded in the tensor's data buffer.
  2. A quantization descriptor — the subject of this file, which describes how to map those storage values to real-valued elements.

Note (non-normative): The tensor descriptor's type_tag field always refers to the storage type. Hurray does not allocate separate type tags for quantized formats; a tensor is quantized if and only if the HAS_QUANTIZATION flag (bit 0 of flags) is set in the tensor descriptor. This separation keeps the type system orthogonal to the quantization scheme space.


Relationship to Other Sections

  • metadata.md defines the Quantization Section framing. The section is present if and only if the HAS_QUANTIZATION flag (bit 0) is set in the tensor descriptor. It consists of a uint32 quantization_length prefix followed by quantization_length bytes of payload. This file defines the contents of those payload bytes.
  • element-types.md defines the storage type tags (type_tag values) that may appear in a quantized tensor descriptor. Each scheme below lists the set of storage types it accepts.
  • memory-layout.md defines the buffer table: an ordered list of buffer handles indexed from 0. Several schemes store their per-block scale and zero-point arrays in buffers other than buffer 0; those schemes reference those buffers by index.

Descriptor Header

Every quantization descriptor begins with a fixed 4-byte header.

OffsetFieldTypeDescription
0scheme_taguint8Identifies the quantization scheme. Values are assigned below.
1scheme_versionuint8Version of the scheme-specific encoding. Current value for all schemes defined here: 0x01.
2flagsuint16Scheme-specific flags bitmask. Reserved bits MUST be 0.

All multi-byte fields in the quantization descriptor MUST be encoded in little-endian byte order.

Immediately following the 4-byte header, scheme-specific fields are encoded. The total length of the descriptor (header plus scheme-specific fields, plus any trailing padding chosen by the writer) MUST equal the quantization_length prefix defined in metadata.md.

A reader MUST dispatch on scheme_tag after reading the first byte. A reader that does not recognise scheme_tag MUST reject the tensor descriptor, unless operating in permissive mode. In permissive mode, the reader MAY skip past the descriptor using the quantization_length prefix but MUST NOT dereference the tensor data buffer.

A reader MUST reject a descriptor whose scheme_version exceeds the highest version defined in this specification for the given scheme_tag.

A reader MUST reject a descriptor with any reserved flags bit set.

A reader MUST NOT read beyond quantization_length bytes when parsing the descriptor.


Scheme Tag Space

RangeAllocation
0x00Reserved (invalid)
0x01 – 0x3FTier 1 schemes (MUST be supported by conforming implementations that advertise quantization support)
0x40 – 0x5FTier 2 schemes (OPTIONAL)
0x60 – 0x7FReserved for future nested/composite schemes — implementations MUST NOT assign tags in this range
0x80 – 0xEFReserved for future specification versions
0xF0 – 0xFEImplementation-private extension schemes
0xFFReserved (invalid)

A reader MUST reject a descriptor whose scheme_tag is 0x00 or 0xFF.

Implementations MUST NOT assign semantics to scheme tags in the range 0x60–0x7F; these are reserved for future nested/composite quantization schemes defined by this specification.

Tags in the range 0x80 – 0xEF MUST NOT be used by any implementation; they are reserved for future specification versions.

Tags in the range 0xF0 – 0xFE MAY be used by implementations for private schemes. Tensors using private scheme tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band.

Note (non-normative): The three block-quantization schemes defined in this specification have different block_size lower bounds, reflecting their distinct design constraints:

  • Per-block-affine (Tier 1, 0x03): block_size ≥ 2. Any positive block size with even packing makes algebraic sense; the lower bound exists only to forbid degenerate single-element blocks.
  • NF4 (Tier 2, 0x04): block_size ≥ 8. Below 8 elements per block the 16-point NF4 information content provides no statistical benefit over a plain low-bit linear quantization, so the format is not meaningful at smaller block sizes.
  • MXFP (Tier 2, 0x05): block_size ≥ 16. Tier 2 microscaling formats require hardware Tensor Core support; 16 is the minimum block size for which any production silicon (NVIDIA Blackwell) currently implements MX compute.

Assigned Scheme Tags


Partial Block Policy

The block-quantization schemes defined in this specification differ in whether they permit a partial trailing block — a final block along axis containing fewer than block_size valid logical elements. The active scheme's policy applies; readers implementing multiple schemes MUST apply the partial-block rule of the active scheme.

  • Per-block-affine (0x03): a partial trailing block is permitted. The storage buffer MUST still allocate space for a full block of block_size elements; the unused trailing storage elements MUST be zero bytes and a reader MUST ignore them when dequantizing. The scale (and zero-point) arrays MUST contain one entry per block, including the partial final block. See quantization/per-block-affine.md § Padding for the normative encoding.
  • NF4 (0x04): a partial trailing block is permitted under the same rules as per-block-affine, since NF4 derives its block index computation from per-block-affine. See quantization/per-block-affine.md § Padding (NF4 uses identical padding rules).
  • MXFP (0x05): a partial trailing block is NOT permitted. shape[axis] MUST be a positive multiple of block_size. This restriction is normative under the OCP MX v1.0 specification: microscaling hardware assumes exact block alignment along the quantized axis. A reader MUST reject an MXFP descriptor whose shape[axis] is not a positive multiple of block_size. See quantization/mxfp.md § Validity Constraints.

A reader that supports multiple schemes MUST dispatch on scheme_tag and apply the corresponding partial-block rule. A reader MUST NOT cross-apply the permissive per-block-affine padding rule to MXFP, nor reject a partial final block of a per-block-affine or NF4 tensor on the basis of the MXFP exact- multiple constraint.


Zero-Point Convention

The affine schemes (0x01, 0x02, 0x03) dequantize by subtracting zero_point from the storage value. The stored zero point is the value subtracted: Hurray defines no implicit bias, and the descriptor carries no field selecting an alternative convention.

A writer converting a tensor from an external quantization toolchain MUST normalize that toolchain's zero-point values to this convention before placing them in a Hurray buffer.

Note (non-normative): Some external toolchains store a biased zero point and remove the bias in the loader rather than recording it in the file. GPTQ is the widely deployed case: its packed zero-point plane has conventionally held zero_point - 1. A descriptor built from un-normalized values is structurally valid — it satisfies every validity constraint of its scheme, and a reader has no way to detect the discrepancy — but dequantizes every affected element off by one scale step. Hurray records the zero point, not the convention that produced it, so this normalization is the writer's responsibility and cannot be recovered downstream.


Buffer Table Placement Rules

Every scheme that references a buffer (per-channel, per-block, NF4, MXFP) adds entries to the tensor descriptor's buffer table beyond the buffers used by the layout itself. The following rules apply across all quantization schemes:

  1. The tensor data buffer always occupies a buffer table index determined by the layout (typically 0 for dense layouts). Quantization-parameter buffers MUST occupy distinct indices.
  2. A quantization-parameter buffer MUST NOT be shared with the tensor data buffer. A reader MUST reject a descriptor that violates this rule.
  3. Two quantization-parameter buffers (e.g., scale and zero-point) MAY reside in the same buffer table entry if and only if the entry's byte_size accommodates both arrays laid out end-to-end, and both arrays start at byte offset 0. Since each parameter descriptor field specifies its own *_buffer_index, this case is expressed by writing the same index into both fields; readers MUST then interpret the buffer as the concatenation [scales | zero_points]. Writers SHOULD prefer distinct buffers for clarity; sharing is permitted only as an optimization.
  4. A quantization-parameter buffer's device_tag (see metadata.md buffer handle format) MUST match the tensor data buffer's device_tag. A reader MUST reject a descriptor that violates this rule.

Note (non-normative): The device-colocation rule ensures that quantized tensor kernels can dereference both the data and the quantization parameters without triggering cross-device transfers. A writer that needs to materialize quantization parameters on a different device must emit a separate tensor.


Extension Schemes (0xF0 – 0xFE)

Implementations MAY define private quantization schemes using scheme tags in 0xF0 – 0xFE. The binary encoding of an extension scheme descriptor is unconstrained beyond the fixed 4-byte header; the writer and reader MUST agree on the payload format out of band.

Note (non-normative): Extension schemes are the mechanism for implementation-specific or experimental quantization formats (e.g., GPTQ, AWQ group quantization with per-group permutations, or hardware-vendor- specific packings). Schemes that prove broadly useful are candidates for assignment in the Tier 1 or Tier 2 range through a specification revision.

What sends these formats here is the per-group permutation — GPTQ activation-order grouping reorders the quantized axis, which per-block affine cannot express. The permutation-free subset is a different case: a group-quantized tensor whose groups are contiguous along one axis is representable under per-block affine (0x03) by choosing the appropriate storage type, block size, and flags, and does not need an extension scheme. See § Zero-Point Convention for the normalization such a conversion requires.


Version Compatibility

Adding a new scheme tag MUST be accompanied by a minor version increment of the overall format (version_minor in the fixed header; see metadata.md). A reader at an earlier minor version will encounter an unrecognised scheme_tag and reject the descriptor per the rules above, which is the intended behaviour.

Adding a new field to an existing scheme (or repurposing a reserved byte) MUST be accompanied by a scheme_version increment for that scheme. A reader MUST compare scheme_version against the highest version it supports for the given scheme_tag and reject the descriptor if the version is newer than supported.

Removing a scheme or changing the dequantization formula for an existing (scheme_tag, scheme_version) pair is a backward-incompatible change and MUST be accompanied by a major version increment.


Open Questions

Double / nested quantization (deferred). This version does not define a normative encoding for double quantization (quantizing the scale buffer itself, as in bitsandbytes QLoRA "nested" quantization). The scheme-tag range 0x60–0x7F is reserved for a future nested/composite scheme (see § Scheme Tag Space). A conforming implementation MAY express double quantization today by representing the scale tensor as a separate quantized tensor using existing scheme tags, with the relationship conveyed by application-layer convention.

Note (non-normative): A recursive or two-level scale descriptor is deferred until the base quantization encoding has been validated through implementation; double quantization is primarily a weight-storage optimization rather than a runtime interchange primitive. The intended future occupant of 0x60–0x7F is a nested-scale scheme compatible with bitsandbytes-style double quantization (NF4 data with quantized float8 scales and a float32 super-scale), to be specified in a future revision once implementation experience is available.

[OQ-2]: Should MXFP block_size be fixed at 32 or parameterised? Resolved: block_size is parameterised. The field already exists in the binary encoding; the constraint is: MUST be a power of two in [16, 2048]. The lower bound of 16 excludes values with no hardware Tensor Core support. The OCP MX v1.0 canonical value of 32 is documented as the default. Rationale: avoids scheme tag proliferation for what is effectively one scheme with a size variation; future OCP revisions and hardware variants can use different block sizes under the same scheme tag.

[OQ-3]: Should per-channel affine support lower-precision scale types? Resolved: Per-channel scales remain locked to float32 (scale_type_tag = 0x03). Rationale: storage saving is negligible (~8–16 KB per layer) while accuracy cost is real for precision-critical per-channel weight quantization. A scale_type_tag field (+ 3 reserved bytes) has been added to the per-channel affine binary encoding at offset 16 to allow future relaxation without a wire-format break.

[OQ-4]: Should the descriptor include an explicit num_blocks field? Resolved: num_blocks remains derived: num_blocks = shape[axis] / block_size. Rationale: the value is fully determined by fields already present in the descriptor; an explicit field would be redundant and introduce a new mismatch failure mode.

[OQ-5]: Should the NF4 lookup table be duplicated into a normative reference appendix? Resolved: The table remains inline in quantization/nf4.md. Rationale: duplication would risk the two copies diverging; a single source of truth is safer. Spec audits (spec-checker) are the guard against accidental mutation.

Per-Tensor Affine Quantization — Hurray Format Specification

Scheme tag: 0x01 | Tier: 1

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

Description

A single scale and zero_point pair applies to every element of the tensor. This scheme covers both asymmetric quantization (arbitrary zero_point) and symmetric quantization (zero_point = 0).

The scale and zero-point are stored inline in the quantization descriptor; no additional buffer table entries are required beyond the tensor data buffer.

Binary Encoding

Total descriptor length: 16 bytes.

OffsetFieldTypeDescription
0scheme_taguint8MUST be 0x01.
1scheme_versionuint8MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility.
2flagsuint16MUST be 0x0000. No flags are defined for this scheme.
4scalefloat32Dequantization scale. MUST be a finite, non-zero value.
8zero_pointint32Quantization zero point. For symmetric quantization, MUST be 0x00000000.
12_reserveduint8[4]MUST be 0x00.

All multi-byte fields MUST be encoded in little-endian byte order.

Dequantization Formula

For each storage element q:

x_real = scale * (q - zero_point)

The subtraction q - zero_point is performed in signed 32-bit integer arithmetic. The result is then cast to float32 and multiplied by scale in float32 arithmetic. The real-valued element type produced by dequantization is float32. A consumer MAY further convert to float64 or to a lower-precision float type; such conversion is out of scope for this specification.

zero_point is subtracted exactly as stored. See quantization.md § Zero-Point Convention for the normalization a writer MUST apply when converting from a toolchain that stores a biased zero point.

Validity Constraints

  • scale MUST NOT be zero, NaN, or infinity. A reader MUST reject a descriptor that violates this constraint.
  • zero_point MUST lie within the representable range of the storage type. For example, for a uint8 storage type, zero_point MUST be in [0, 255]. A reader MUST reject a descriptor that violates this constraint.
  • The _reserved bytes MUST be 0x00. A reader MUST reject a descriptor that violates this constraint.

Valid Storage Types

The storage type (type_tag in the tensor descriptor) MUST be one of:

  • int8 (0x10), uint8 (0x11)
  • int16 (0x12), uint16 (0x13)
  • int32 (0x14), uint32 (0x15)
  • int4 (0x48), uint4 (0x49)
  • int2 (0x4A), uint2 (0x4B)

A reader MUST reject a descriptor whose storage type is not in this list.

Per-Channel Affine Quantization — Hurray Format Specification

Scheme tag: 0x02 | Tier: 1

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

Description

One scale and zero_point pair per slice along a specified axis. The scale and zero-point arrays are stored in separate buffers listed in the tensor descriptor's buffer table.

Note (non-normative): Because dense-layout descriptors require buffer_count = 0x01, a per-channel-affine quantized dense tensor requires buffer_count to be at least 2 (symmetric case) or 3 (asymmetric case). This is the mechanism by which quantization schemes extend the buffer table beyond the dense-layout minimum.

Binary Encoding

Total descriptor length: 20 bytes.

OffsetFieldTypeDescription
0scheme_taguint8MUST be 0x02.
1scheme_versionuint8MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility.
2flagsuint16Scheme-specific flags (see below). Reserved bits MUST be 0.
4axisuint32Index of the quantized axis. MUST be strictly less than rank.
8scale_buffer_indexuint32Index in the buffer table of the buffer holding the scale array.
12zero_point_buffer_indexuint32Index in the buffer table of the buffer holding the zero_point array.
16scale_type_taguint8Storage type of the scale values. MUST be 0x03 (float32) in this version of the specification. Reserved for future lower-precision scale types.
17_reserveduint8[3]MUST be 0x00.

All multi-byte fields MUST be encoded in little-endian byte order.

Flags bits:

BitNameMeaning
0SYMMETRICIf set, the zero_point array is implicitly all zeros; zero_point_buffer_index MUST be 0xFFFFFFFF.
1–15(reserved)MUST be 0.

Referenced Buffers

The scale buffer MUST contain exactly shape[axis] consecutive float32 values in little-endian byte order, starting at byte offset 0 within the referenced buffer. Its byte size MUST be exactly shape[axis] * 4.

The zero_point buffer — present only if the SYMMETRIC flag is not set — MUST contain exactly shape[axis] consecutive int32 values in little-endian byte order, starting at byte offset 0 within the referenced buffer. Its byte size MUST be exactly shape[axis] * 4.

Note (non-normative): The zero_point buffer uses int32 regardless of the storage type's bit width (e.g. int4 or int2). This simplifies alignment and avoids sub-byte zero-point packing. The Validity Constraints section enforces that zero-point values lie within the representable range of the storage type, so the wider container does not introduce additional degrees of freedom on the wire.

A reader MUST reject a descriptor whose scale_buffer_index or (when the SYMMETRIC flag is not set) zero_point_buffer_index is greater than or equal to buffer_count in the buffer table.

A reader MUST reject a descriptor whose scale_buffer_index or zero_point_buffer_index equals the buffer index used by the layout for tensor data (typically 0 for dense layouts).

Dequantization Formula

For a storage element q at logical index [i_0, i_1, ..., i_{rank-1}]:

c = i_axis
x_real = scale[c] * (q - zero_point[c])

If the SYMMETRIC flag is set, zero_point[c] is treated as 0 for all c.

zero_point[c] is subtracted exactly as stored. See quantization.md § Zero-Point Convention for the normalization a writer MUST apply when converting from a toolchain that stores a biased zero point.

Validity Constraints

  • axis MUST satisfy axis < rank.
  • shape[axis] MUST NOT equal 0xFFFFFFFFFFFFFFFF (the dynamic dimension sentinel): per-channel quantization requires a statically known channel count.
  • scale_type_tag MUST be 0x03 when scheme_version = 0x01. Future scheme versions MAY define additional values. A reader MUST reject a scheme_version = 0x01 descriptor with any other value.
  • The _reserved bytes MUST be 0x00 when scheme_version = 0x01. A reader MUST reject a scheme_version = 0x01 descriptor with any non-zero reserved byte.
  • Every element of the scale array MUST be a finite, non-zero float32 value.
  • Every element of the zero_point array (when present) MUST lie within the representable range of the storage type.

A reader MAY defer the per-element validity check on the scale and zero-point arrays to the first dequantization attempt, but MUST perform the axis and shape checks before accepting the descriptor.

Valid Storage Types

The storage type (type_tag in the tensor descriptor) MUST be one of:

  • int8 (0x10), uint8 (0x11)
  • int16 (0x12), uint16 (0x13)
  • int32 (0x14), uint32 (0x15)
  • int4 (0x48), uint4 (0x49)
  • int2 (0x4A), uint2 (0x4B)

A reader MUST reject a descriptor whose storage type is not in this list.

Worked Example

A rank-2 int8-stored weight tensor with shape [768, 1024], per-channel affine quantization along axis 0 (asymmetric), no statistics or shard sections. The tensor descriptor's buffer table carries three buffers:

  • Buffer 0 — tensor data, 768 * 1024 = 786432 bytes, int8 storage.
  • Buffer 1 — scale array, 768 * 4 = 3072 bytes, float32.
  • Buffer 2 — zero-point array, 768 * 4 = 3072 bytes, int32.

Quantization descriptor bytes (20 total):

Offset  Value (hex)                   Field
------  ----------------------------  -----
0       02                            scheme_tag = 0x02 (per-channel affine)
1       01                            scheme_version = 1
2       00 00                         flags = 0x0000 (asymmetric)
4       00 00 00 00                   axis = 0
8       01 00 00 00                   scale_buffer_index = 1
12      02 00 00 00                   zero_point_buffer_index = 2
16      03                            scale_type_tag = 0x03 (float32)
17      00 00 00                      _reserved = 0x00

The quantization_length prefix in the tensor descriptor's Quantization Section would be 0x00000014 (20).

Dequantization of element q at logical position [c, k]:

x_real = scale[c] * (q - zero_point[c])

Per-Block Affine Quantization — Hurray Format Specification

Scheme tag: 0x03 | Tier: 1

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

Description

The tensor is divided into fixed-size, contiguous blocks along a specified axis. Each block carries its own scale (and optionally zero_point). This scheme covers the single-level GGUF block-quantized formats — those carrying one scale, and optionally one zero point, per block (e.g., Q8_0, Q4_0, Q4_1) — at the descriptor level; those layouts are representable by choosing the appropriate storage type, block size, and flags.

The GGUF K-quant family (Q2_K–Q6_K) is not expressible under this scheme. Those formats apply a second, super-block scaling level: the per-block scales are themselves quantized and scaled by a shared factor, which this scheme's single scale array cannot represent. See quantization.md § Open Questions, where nested and two-level scale descriptors are deferred to the reserved 0x60–0x7F tag range.

Binary Encoding

Total descriptor length: 24 bytes.

OffsetFieldTypeDescription
0scheme_taguint8MUST be 0x03.
1scheme_versionuint8MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility.
2flagsuint16Scheme-specific flags (see below). Reserved bits MUST be 0.
4axisuint32Index of the axis along which the tensor is divided into blocks. MUST be strictly less than rank.
8block_sizeuint32Number of logical elements per block along axis. MUST be a power of two and MUST be greater than or equal to 2.
12scale_buffer_indexuint32Index in the buffer table of the buffer holding the scale array.
16zero_point_buffer_indexuint32Index in the buffer table of the buffer holding the zero_point array. Ignored when the SYMMETRIC flag is set; writers SHOULD set it to 0xFFFFFFFF in that case.
20scale_type_taguint8Storage type of the scale values. MUST be 0x01 (float16), 0x02 (bfloat16), or 0x03 (float32).
21_reserveduint8[3]MUST be 0x00.

All multi-byte fields MUST be encoded in little-endian byte order.

Flags bits:

BitNameMeaning
0SYMMETRICIf set, the zero_point array is implicitly all zeros; zero_point_buffer_index MUST be 0xFFFFFFFF.
1–15(reserved)MUST be 0.

Block Layout

Let S = shape[axis] (resolved; MUST NOT be the dynamic dimension sentinel) and K = block_size. The number of blocks along axis is:

num_blocks_per_axis = ceil(S / K)

The total number of blocks across the whole tensor is:

num_blocks = num_blocks_per_axis * product(shape[j] for j != axis)

Block index b at a tensor position [i_0, ..., i_{rank-1}] is computed as follows. Let outer be the linear index formed from all dimensions except axis using row-major order over those dimensions. Then:

b = outer * num_blocks_per_axis + floor(i_axis / K)

Note (non-normative): This mapping preserves the tensor's row-major traversal order along non-quantized dimensions, which matches GGUF's linear layout for 2-D weight matrices. Writers targeting column-major traversal SHOULD use a column-major tensor layout rather than altering the block mapping.

Padding

If S is not a multiple of K, the final block along axis for each outer position contains only S mod K valid elements. The storage buffer MUST still allocate space for a full block of K elements; the unused trailing elements within the final block MUST be set to 0x00 bytes by the writer. A reader MUST ignore these padding elements when dequantizing.

The scale (and zero-point) arrays MUST contain one entry per block, including the partial final block.

Referenced Buffers

The scale buffer MUST contain exactly num_blocks consecutive values of scale_type_tag in little-endian byte order, starting at byte offset 0. Its byte size MUST be exactly num_blocks * sizeof(scale_type_tag).

The zero_point buffer — present only if the SYMMETRIC flag is not set — MUST contain exactly num_blocks consecutive int32 values in little-endian byte order, starting at byte offset 0. Its byte size MUST be exactly num_blocks * 4.

A reader MUST reject a descriptor whose scale_buffer_index or (when applicable) zero_point_buffer_index is greater than or equal to buffer_count.

Dequantization Formula

Let b be the block index for a storage element q at logical position [i_0, ..., i_{rank-1}], computed as above. Let s = scale[b] and, if the SYMMETRIC flag is set, z = 0, else z = zero_point[b].

x_real = s * (q - z)

The multiplication is performed in float32 arithmetic. If scale_type_tag is float16 or bfloat16, s MUST be widened to float32 losslessly before the multiplication.

z is subtracted exactly as stored. See quantization.md § Zero-Point Convention for the normalization a writer MUST apply when converting from a toolchain that stores a biased zero point — the case that arises when a group-quantized GPTQ or AWQ tensor without activation-order permutation is mapped onto this scheme.

Validity Constraints

  • axis MUST satisfy axis < rank.
  • block_size MUST be a power of two and MUST be greater than or equal to 2.
  • When shape[axis] is greater than 0, block_size MUST be less than or equal to shape[axis]. A reader MUST reject a descriptor whose block_size exceeds a non-zero shape[axis].
  • When shape[axis] equals 0 (an empty quantization axis, per ADR-007), the upper-bound check is waived. The block_size field MUST still be a power of two greater than or equal to 2, but its value has no effect on buffer sizing: num_blocks evaluates to 0, and the scale and zero-point buffers MUST have byte size 0 (their pointers MAY be null per buffer-protocol.md).
  • shape[axis] MUST NOT equal 0xFFFFFFFFFFFFFFFF.
  • Every scale value MUST be a finite, non-zero value.
  • scale_type_tag MUST be one of 0x01, 0x02, 0x03.

Note (non-normative): Permitting block_size to exceed shape[axis] when the axis is empty preserves the producer's declared quantization granularity across shape changes (for example, a filter that selects zero rows from an otherwise per-block-quantized weight tensor). The descriptor remains structurally valid and round-trippable; no blocks are materialized.

Note (non-normative): The case block_size = shape[axis] — a single block covering the entire quantized axis — is intentionally permitted. It is a degenerate but valid configuration that produces semantics equivalent to per-tensor affine quantization along that axis (one shared scale/zero_point pair per outer position). Readers MUST handle it identically to any other block_size value; there is no separate code path.

Valid Storage Types

The storage type (type_tag in the tensor descriptor) MUST be one of:

  • int8 (0x10), uint8 (0x11)
  • int4 (0x48), uint4 (0x49)
  • int2 (0x4A), uint2 (0x4B)

A reader MUST reject a descriptor whose storage type is not in this list.

Note (non-normative): Wider integer storage types (int16 and above) are not permitted for per-block affine because block quantization is only beneficial at low bit widths. A writer wishing to apply per-block scaling to wider storage types should use per-channel affine (0x02) instead.

NF4 (NormalFloat4) Quantization — Hurray Format Specification

Scheme tag: 0x04 | Tier: 2

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

Description

A non-linear 4-bit quantization scheme introduced by the QLoRA paper. Each storage code in [0, 15] decodes to one of 16 fixed real-valued levels chosen to be information-theoretically optimal for weights drawn from a standard normal distribution. Each block carries a single absmax scale; there is no per-element zero point.

The block index computation follows the same rule as Per-Block Affine (see quantization/per-block-affine.md § Block Layout).

Binary Encoding

Total descriptor length: 16 bytes.

OffsetFieldTypeDescription
0scheme_taguint8MUST be 0x04.
1scheme_versionuint8MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility.
2flagsuint16MUST be 0x0000. No flags are defined for this scheme.
4axisuint32Index of the axis along which the tensor is divided into blocks. MUST be strictly less than rank.
8block_sizeuint32Number of logical elements per block along axis. MUST be a power of two; RECOMMENDED values are 64 (bitsandbytes default) or 128.
12scale_buffer_indexuint32Index in the buffer table of the buffer holding the per-block absmax scales.

All multi-byte fields MUST be encoded in little-endian byte order.

Lookup Table

The 16 NF4 levels are fixed by this specification and MUST NOT be altered by readers or writers. Indexed by the unsigned 4-bit storage code q:

qnf4[q]
0-1.0
1-0.6961928009986877
2-0.5250730514526367
3-0.39491748809814453
4-0.28444138169288635
5-0.18477343022823334
6-0.09105003625154495
70.0
80.07958029955625534
90.16093020141124725
100.24611230194568634
110.33791524171829224
120.44070982933044434
130.5626170039176941
140.7229568362236023
151.0

The table values are given here as float32 decimal expansions of the exact levels from the QLoRA reference implementation. Implementations MUST use these float32 values verbatim; deriving the table from first principles at runtime is NOT RECOMMENDED and MUST match these values bit-for-bit if attempted.

Referenced Buffer

The scale buffer MUST contain exactly num_blocks consecutive float32 absmax values in little-endian byte order, starting at byte offset 0 within the referenced buffer. num_blocks is computed identically to the Per-Block Affine scheme. Its byte size MUST be exactly num_blocks * 4.

Dequantization Formula

Let b be the block index for a storage element q at logical position [i_0, ..., i_{rank-1}] (computed as in Per-Block Affine). Let s = scale[b].

x_real = s * nf4[q]

The multiplication is performed in float32 arithmetic.

Validity Constraints

  • axis MUST satisfy axis < rank.
  • block_size MUST be a power of two and MUST be greater than or equal to 8.
  • When shape[axis] is greater than 0, block_size MUST be less than or equal to shape[axis]. A reader MUST reject a descriptor whose block_size exceeds a non-zero shape[axis].
  • When shape[axis] equals 0 (an empty quantization axis, per ADR-007), the upper-bound check is waived. The block_size field MUST still be a power of two greater than or equal to 8, but its value has no effect on buffer sizing: num_blocks evaluates to 0, and the absmax scale buffer MUST have byte size 0.
  • shape[axis] MUST NOT equal 0xFFFFFFFFFFFFFFFF.
  • Every scale value MUST be a finite, non-negative float32.
  • scale_buffer_index MUST be a valid index into the buffer table and MUST NOT refer to the tensor data buffer.

Note (non-normative): The minimum block size of 8 reflects the information-theoretic minimum for NF4: below 8 elements per block, the 16-point quantization grid provides no statistical benefit over a lower-bit format.

Valid Storage Types

The storage type (type_tag in the tensor descriptor) MUST be uint4 (0x49).

A reader MUST reject an NF4 descriptor whose storage type is not uint4.

Note (non-normative): NF4 storage codes are conceptually unsigned (they index into a signed-valued lookup table); uint4 is the correct storage type. The packing order follows the standard uint4 rule from element-types.md: element 2k in the low nibble of byte k, element 2k+1 in the high nibble.

MXFP (OCP Microscaling) Quantization — Hurray Format Specification

Scheme tag: 0x05 | Tier: 2

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

Description

A block quantization format standardized by the Open Compute Project Microscaling specification (OCP MX v1.0). A contiguous block of elements along a chosen axis shares a single float8_e8m0 exponent-only scale. Each element within the block is stored as one of several supported narrow numeric types. This is the format used by NVIDIA Blackwell Tensor Cores for MXFP8/MXFP6/MXFP4 compute.

The block size is a descriptor field (block_size). The OCP MX v1.0 canonical value is 32; other power-of-two values are permitted by this specification to accommodate future OCP revisions and hardware variants.

The block index computation follows the same rule as Per-Block Affine (see quantization/per-block-affine.md § Block Layout), with the descriptor-specified block_size.

Binary Encoding

Total descriptor length: 16 bytes.

OffsetFieldTypeDescription
0scheme_taguint8MUST be 0x05.
1scheme_versionuint8MUST be 0x01. For the version compatibility policy, see quantization.md § Version Compatibility.
2flagsuint16MUST be 0x0000. No flags are defined for this scheme.
4axisuint32Index of the axis along which the tensor is divided into microscaling blocks. MUST be strictly less than rank.
8block_sizeuint32Number of logical elements per microscaling block along axis. MUST be a power of two in the range [16, 2048]. The OCP MX v1.0 canonical value is 32.
12scale_buffer_indexuint32Index in the buffer table of the buffer holding the per-block float8_e8m0 scales.

All multi-byte fields MUST be encoded in little-endian byte order.

Referenced Buffer

The scale buffer MUST contain exactly num_blocks consecutive float8_e8m0 values, one byte each, starting at byte offset 0 within the referenced buffer. Its byte size MUST be exactly num_blocks bytes. num_blocks is computed identically to the Per-Block Affine scheme using the descriptor-specified block_size, with the additional MXFP constraint that shape[axis] is a positive multiple of block_size (no partial trailing block — see Validity Constraints below). The full count across the whole tensor is:

num_blocks = (shape[axis] / block_size) * product(shape[j] for j ≠ axis)

(exact division — shape[axis] MUST be a positive multiple of block_size; see Validity Constraints). See quantization/per-block-affine.md § Block Layout for the derivation; MXFP differs only in disallowing partial trailing blocks.

The bit patterns 0x00 and 0xFF in any scale byte are reserved (NaN per OCP MX v1.0 § 5.6; see element-types.md § float8_e8m0) and MUST NOT appear in the scale buffer. A reader encountering 0x00 or 0xFF in the scale buffer MUST treat the descriptor as invalid.

Dequantization Formula

Let b be the block index for a storage element at logical position [i_0, ..., i_{rank-1}]. Let e = scale[b] be the float8_e8m0 byte. The shared exponent scale is:

s = 2^(e - 127)

If the storage type is a float type (float8_e4m3, float8_e5m2, float4_e2m1, float6_e2m3, float6_e3m2):

x_real = s * float_value_of(q)

where float_value_of(q) is the real number represented by the storage element q interpreted according to element-types.md and the OCP MX v1.0 value maps for the respective type.

If the storage type is an integer type (int8, int4):

x_real = s * int_value_of(q)

where int_value_of(q) is the signed integer value of q. This MXFP integer form is equivalent to per-block symmetric affine quantization with a power-of-two scale and no zero point.

All arithmetic is performed in float32 (or higher) precision; the shared exponent s is itself exactly representable in float32 for every valid float8_e8m0 bit pattern except 0xFF (which is prohibited above).

Validity Constraints

  • axis MUST satisfy axis < rank.
  • block_size MUST be a power of two in the range [16, 2048]. A reader MUST reject a descriptor whose block_size is not a power of two, is less than 16, or exceeds 2048. Values below 16 have no hardware Tensor Core support and are not valid under any OCP MX revision.
  • shape[axis] MUST NOT equal 0xFFFFFFFFFFFFFFFF.
  • shape[axis] MUST be a positive multiple of block_size. Unlike Per-Block Affine and NF4, MXFP does NOT permit partial trailing blocks; all blocks MUST be full. A reader MUST reject a descriptor whose shape[axis] is not a positive multiple of block_size.
  • scale_buffer_index MUST be a valid index into the buffer table and MUST NOT refer to the tensor data buffer.

Valid Storage Types

The storage type (type_tag in the tensor descriptor) MUST be one of:

  • float8_e4m3 (0x40) — MXFP8
  • float8_e5m2 (0x41) — MXFP8
  • float4_e2m1 (0x43) — MXFP4
  • float6_e2m3 (0x44) — MXFP6
  • float6_e3m2 (0x45) — MXFP6
  • int8 (0x10) — MXINT8
  • int4 (0x48) — MXINT4 / MXFP4 integer-valued surrogate

A reader MUST reject an MXFP descriptor whose storage type is not in this list.

Memory Layout -- Hurray Format Specification

Status: Draft

Scope

This section defines how tensor elements are arranged in memory. It specifies the addressing model that maps a tensor's logical index space to byte positions within a data buffer. Hurray supports a range of memory layouts — from simple contiguous arrangements to tiled, space-filling-curve, sparse, and composite (virtual) layouts — to accommodate the diverse access patterns required by modern AI/ML inference pipelines.

Note (non-normative): The unifying mathematical concept behind all Hurray dense layouts is the subpaving: a finite collection of non-overlapping boxes (rectangular regions) that together tile a tensor's index space. A contiguous row-major tensor is a trivial subpaving (one box). A tiled tensor is a regular subpaving. See Subpaving (Wikipedia).

Normative Requirements

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


Layout Taxonomy

Every tensor descriptor MUST include a layout tag (uint8) that identifies the memory layout of the tensor's data. The tag space is partitioned as follows:

RangeAllocation
0x00Reserved (invalid)
0x01 – 0x3FCore named layouts (Tier 1)
0x40 – 0x7FExtended named layouts (Tier 2)
0x80 – 0xEFReserved for future specification versions
0xF0 – 0xFEImplementation-private extension layouts
0xFFReserved (invalid)

A conforming reader MUST reject a tensor descriptor containing a layout tag of 0x00 or 0xFF.

A conforming reader MUST reject a tensor descriptor containing a layout tag it does not recognise, unless operating in permissive mode. In permissive mode, the reader MAY accept the descriptor but MUST NOT dereference or interpret the tensor data buffer.

Named Layout Tags

The Type column classifies each layout's addressing model:

  • Dense — every logical element exists and maps to a physical position by an affine stride formula.
  • Sparse — only stored (non-zero) elements are materialised; unstored coordinates are implicitly zero.
  • Indirect — every logical element exists (no implicit zeros), but the mapping from a logical index to a physical position is non-affine and resolved through an index structure (e.g. a block table) rather than an affine stride formula.
  • Virtual — the descriptor owns no data buffers; it presents a logical view assembled from a set of member tensors (the head of a composite; see layouts/composite.md).

Note (non-normative): Rows marked "(reserved — planned)" name a tag that is earmarked for a layout whose spec section does not yet exist. A reader treats such a tag exactly as it treats any unrecognised tag: it MUST reject a descriptor bearing that tag unless operating in permissive mode, in which case it MUST NOT dereference the tensor data buffer (see the unrecognised-tag rule above). The reservation only records intent so the tag is not reassigned before the layout is specified.

Writers choose the layout. Hurray imposes no requirement on which layout a writer selects; any layout from the table above (or from the extension range, by prior agreement) is valid.


Common Fields

All layouts share the following fields in the tensor descriptor. These fields are defined once here; individual layout files specify additional layout-specific fields.

Rank and Shape

  • rank (uint32): the number of dimensions. 0 denotes a scalar tensor.
  • shape (uint64[rank]): the size of each dimension. Each value MUST be greater than or equal to 0. A size of 0 indicates an empty tensor.

Note (non-normative): Zero-size dimensions are valid for placeholder tensors or empty batches. A tensor with shape [3, 0, 5] has zero total elements.

The value 0xFFFFFFFFFFFFFFFF (UINT64_MAX) is the dynamic dimension sentinel: the dimension's size is not statically known and MUST be resolved before use.

byte_offset

  • byte_offset (uint64): offset in bytes from the start of buffer 0 to the element at logical index [0, 0, ..., 0]. MUST be ≤ the buffer's byte size.

For sub-byte types (bool, int4, uint4, int2, uint2), byte_offset MUST point to a byte boundary.

For sparse layouts (COO, CSR, CSC, CSF, and future sparse tags) and indirect layouts (block-paged, and future indirect tags), the concept of a "first element at a fixed offset" does not apply: the first logical element is located through an index structure, not at a fixed offset. For these tensors, byte_offset MUST be set to 0x0000000000000000.

For the virtual layout (composite head, tag 0x0B), there is no data buffer at all; byte_offset MUST be set to 0x0000000000000000. See layouts/composite.md.


Element Address Computation

Whole-Byte Types

For element types with bit width ≥ 8, the byte address of the element at linear offset offset (as computed by the layout-specific addressing formula) is:

byte_address = byte_offset + offset * (bit_width / 8)

Sub-Byte Types

For sub-byte types (bool, int4, uint4, int2, uint2), strides are expressed in logical elements. Given a linear element offset offset:

  • Packing factor P: elements per byte (8 for bool, 2 for 4-bit, 4 for 2-bit).
  • Bit width B: bits per element (1, 4, or 2).
  • Byte index: byte_offset + floor(offset / P).
  • Bit position within byte: (offset mod P) * B (counting from LSB).

For strided layouts with sub-byte types, strides are in logical elements; the implementation MUST compute the linear element offset using the strides, then apply the packing formula above.

Note (non-normative): Sub-byte types are almost always used with contiguous layouts. Strided sub-byte tensors are supported for completeness but require bit-level manipulation per element. Writers SHOULD prefer contiguous layouts for sub-byte data.

Note (non-normative): The 6-bit types (float6_e2m3, float6_e3m2) pack 4 elements per 3 bytes (see element-types.md § Buffer Size Calculation). However, because their bit width (6) does not divide evenly into 8 bits, the standard sub-byte bit-addressing model (where bit position within a byte is well-defined) does not apply cleanly. Elements are always addressed at the group level (3 bytes per group of 4 elements); individual element extraction within a group is defined by the bit layout in element-types.md § Encoding but is not generalised here.

Accordingly, 6-bit types (float6_e2m3, float6_e3m2) MUST be accessed at the group level — one group is 4 elements packed into 3 bytes — and an implementation MUST NOT apply the single-element byte/bit-addressing model of § Sub-Byte Types to them. Single-element extraction within a group is defined normatively by element-types.md § Encoding and is intentionally not generalised into a layout addressing formula here; that encoding is sufficient for implementors.


Alignment

  • The data buffer MUST be aligned to at least 64 bytes regardless of layout or element type.
  • For tiled layouts, each tile's data SHOULD start at a naturally aligned boundary. Writers SHOULD insert inter-tile padding when needed; tile stride values MUST account for any padding.
  • For sub-byte types, byte_offset MUST be byte-aligned.
  • Page-aligned buffers (typically 4096 bytes) SHOULD be used when the tensor is shared across processes or with GPU devices. See buffer-protocol.md.

Buffer Table

Every tensor descriptor contains a buffer table: an ordered list of buffer handles. The buffer table is encoded as a uint8 count followed by that many buffer handle entries, as defined in metadata.md.

For dense layouts (tags 0x01–0x05, 0x40), the buffer table MUST contain at least one entry. Non-quantized dense tensors MUST have exactly buffer_count = 0x01. Quantized dense tensors MUST have buffer_count = 0x01 plus the number of quantization-parameter buffers required by the active scheme (see quantization.md § Buffer Table Placement Rules).

For sparse layouts (tags 0x06, 0x07, 0x08, 0x09, and future sparse tags), the buffer table MUST contain the number of entries specified by that layout's individual spec file. Each buffer holds a distinct component array (values, indices, pointers). Most sparse layouts have a fixed buffer count; CSF (0x09) is the exception, with a rank-dependent count of 2 × rank + 1 (see layouts/csf.md).

For indirect layouts (tag 0x0A, and future indirect tags), the buffer table MUST contain at least three entries (buffer_count >= 3): a values buffer plus the index/pointer buffers that resolve the logical-to-physical mapping. For block-paged (0x0A) these are buffer 0 = page_pool, buffer 1 = block_table, and buffer 2 = seq_ptr. When the tensor is quantized, the quantization-parameter buffers follow at indices 3 and up, per quantization.md § Buffer Table Placement Rules. See that layout's individual spec file for the exact buffer table.

For the virtual layout (composite head, tag 0x0B), the buffer table MUST be empty (buffer_count = 0x00): the head owns no data and supplies its logical view through its member tensors. See layouts/composite.md.


Splittability and Sharding

A tensor MAY be described as a shard: a rectangular sub-region of a larger logical tensor. A shard carries a shard descriptor in the tensor descriptor (see metadata.md § Shard Section) indicating its position within the parent index space.

Shard descriptor fields:

  • parent_shape (uint64[rank]): shape of the logical parent tensor.
  • shard_offset (uint64[rank]): starting index of this shard within the parent along each dimension.

The constraint shard_offset[k] + shape[k] <= parent_shape[k] MUST hold for every dimension k.

Note (non-normative): Sharding always produces rectangular sub-regions (hyperrectangles), which covers all practical partitioning patterns in ML inference (batch splitting, tensor parallelism, pipeline stages). Protocol-level splitting of tensor data across stream frames is a separate concern covered in interchange.md.


Extension Layouts

Layout tags in the range 0xF0–0xFE are reserved for implementation-private extension layouts. Tensors using these tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the layout semantics out of band.

An extension layout descriptor MUST include:

  • extension_layout_id (uint64): implementation-defined unique identifier.
  • extension_data (byte sequence): opaque layout-specific metadata, preceded by a uint32 byte-length field.

Note (non-normative): Extension layouts are the mechanism for hardware-specific panel/pack formats used between BLAS pipeline stages. A client advertises an extension layout tag with opaque hardware metadata during capability negotiation (see interchange.md); the server transcodes and packs accordingly.


Custom Layouts

Any layout expressible as a composition of the named primitives — strides, tiling, space-filling curves — using a composite partition (layouts/composite.md) or recursive tiling is representable without the extension mechanism. Truly opaque custom layouts MUST use extension tags (0xF0–0xFE) with an out-of-band semantic agreement.


Interaction with Other Sections

  • Element Types (element-types.md): defines bit widths, packing rules, and natural alignment for each element type.
  • Quantization (quantization.md): block-quantized layouts interact with the memory layout tiling structure.
  • Buffer Protocol (buffer-protocol.md): defines buffer ownership, alignment requirements, and device memory semantics.
  • Metadata (metadata.md): defines the binary encoding of all layout-specific fields in the tensor descriptor.
  • Interchange (interchange.md): defines how tensor descriptors and data buffers are transmitted. Protocol-level data splitting is distinct from logical sharding.

Row-Major (C Order) Layout — Hurray Format Specification

Layout tag: 0x01 | Tier: 1

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

Description

In row-major layout, elements are stored with the last dimension varying fastest. Strides are implicit and MUST NOT be present in the descriptor for this layout tag.

Implicit Strides

strides[rank - 1] = 1
strides[i] = shape[i + 1] * strides[i + 1]    for i = rank - 2, ..., 0

All strides are in logical elements.

Element Address

The linear element offset of element [i_0, i_1, ..., i_{r-1}] in a row-major tensor of rank r is:

offset = sum(i_k * strides[k] for k = 0, ..., r - 1)

The byte address is computed from the element offset using the rules in memory-layout.md § Element Address Computation.

Buffer Size

For a contiguous row-major tensor, the minimum buffer size is num_elements * element_byte_width for whole-byte types, or ceil(num_elements / packing_factor) for sub-byte types, where num_elements is the product of all dimension sizes.

Additional Descriptor Fields

None. This layout has no layout-specific fields in the tensor descriptor.

Example

A rank-2 tensor with shape [3, 4] has implicit strides [4, 1]. Element [1, 2] is at linear offset 1 * 4 + 2 * 1 = 6.

Column-Major (Fortran Order) Layout — Hurray Format Specification

Layout tag: 0x02 | Tier: 1

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

Description

In column-major layout, elements are stored with the first dimension varying fastest. Strides are implicit and MUST NOT be present in the descriptor for this layout tag.

Implicit Strides

strides[0] = 1
strides[i] = shape[i - 1] * strides[i - 1]    for i = 1, ..., rank - 1

All strides are in logical elements.

Element Address

The linear element offset of element [i_0, i_1, ..., i_{r-1}] is computed identically to row-major using the column-major strides above.

The byte address is computed from the element offset using the rules in memory-layout.md § Element Address Computation.

Buffer Size

Identical to row-major: num_elements * element_byte_width for whole-byte types, or ceil(num_elements / packing_factor) for sub-byte types.

Additional Descriptor Fields

None. This layout has no layout-specific fields in the tensor descriptor.

Example

A rank-2 tensor with shape [3, 4] has implicit strides [1, 3]. Element [1, 2] is at linear offset 1 * 1 + 2 * 3 = 7.

Strided Layout — Hurray Format Specification

Layout tag: 0x03 | Tier: 1

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

Description

The strided layout generalises both row-major and column-major by allowing an arbitrary stride value per dimension. It is the most general of the simple (non-tiled, non-curve) layouts.

Additional Descriptor Fields

FieldTypeDescription
stridesint64[rank]Stride of each dimension in logical elements.

Stride Semantics

  • A positive stride advances forward through the buffer.
  • A negative stride advances backward. A negative stride on dimension k reverses that dimension: logical index 0 maps to the highest physical offset along that axis.
  • A zero stride on dimension k means all indices along k map to the same physical element — a broadcast (virtual) dimension. Data is not physically replicated.

Negative and zero strides are valid. A conforming implementation MUST support them.

Element Address

The linear element offset of element [i_0, i_1, ..., i_{r-1}] is:

offset = sum(i_k * strides[k] for k = 0, ..., r - 1)

When negative strides are present, the offset may be negative relative to the base address at byte_offset. The byte_offset field MUST be set such that element [0, 0, ..., 0] is addressable within the buffer. The physical address of every valid element MUST lie within the buffer's bounds.

Buffer Size

The minimum buffer size must cover every addressable element:

max_offset = sum(max(0, strides[k] * (shape[k] - 1)) for all k)
min_offset = sum(min(0, strides[k] * (shape[k] - 1)) for all k)
range_elements = max_offset - min_offset + 1

Buffer size in bytes depends on the element type (see memory-layout.md § Element Address Computation).

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar) tensors. See data-model.md § Scalar Tensors.

Contiguity

A strided tensor is dense (contiguous with no gaps) if and only if the absolute values of its strides form a permutation of the row-major strides for the same shape. Implementations SHOULD NOT assume density without verifying this condition.

Example

A rank-2 tensor with shape [3, 4] and strides [-4, 1] represents a row-major matrix with the row order reversed. byte_offset points to what would be element [2, 0] in a non-reversed matrix. Element [2, 3] is at offset 2 * (-4) + 3 * 1 = -5. The buffer MUST be large enough to cover the full range from offset -8 to 0 (9 elements).

Tiled / Blocked Layout — Hurray Format Specification

Layout tag: 0x04 | Tier: 1

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

Description

A tiled layout partitions the tensor's index space into uniform rectangular tiles (blocks). This is a regular subpaving: all tiles have the same shape and tile the index space without overlap or gap (with possible padding at the boundaries).

Additional Descriptor Fields

FieldTypeDescription
tile_shapeuint64[rank]Tile size along each dimension. Every value MUST be greater than 0.
outer_layoutuint8Layout tag for tile-grid ordering. MUST be 0x01, 0x02, or 0x03.
inner_layoutuint8Layout tag for element ordering within each tile. MUST be 0x01, 0x02, 0x03, or 0x04 (recursive tiling).
_reserveduint8[2]MUST be 0x00.

If outer_layout is 0x03 (strided):

FieldTypeDescription
outer_stridesint64[rank]Outer strides in units of tiles (not elements).

If inner_layout is 0x03 (strided):

FieldTypeDescription
inner_stridesint64[rank]Inner strides in logical elements within a tile.

If inner_layout is 0x04 (recursive tiling), the tiled layout-specific fields are encoded recursively beginning with tile_shape. A reader MUST enforce a maximum recursion depth (RECOMMENDED: 8 levels) and MUST reject descriptors that exceed it.

Element Address

To locate element [i_0, i_1, ..., i_{r-1}]:

  1. Compute the tile index: t_k = floor(i_k / tile_shape[k]) for each dimension.
  2. Compute the intra-tile offset: e_k = i_k mod tile_shape[k] for each dimension.
  3. Compute the linear tile number using the outer layout applied to [t_0, ..., t_{r-1}]. Multiply by total_tile_elements to get the byte offset to the tile start.
  4. Compute the intra-tile linear offset using the inner layout applied to [e_0, ..., e_{r-1}].
  5. Final linear element offset = (3) + (4).

Boundary Padding

When a dimension size is not evenly divisible by the tile size, partial tiles exist at the boundary. The buffer MUST contain storage for full tiles, including padding. Padding element values are undefined; readers MUST NOT access elements whose logical index exceeds shape.

Number of tiles per dimension:

num_tiles[k] = ceil(shape[k] / tile_shape[k])

Total buffer elements:

total_tile_elements = product(tile_shape[k] for all k)
total_tiles        = product(num_tiles[k] for all k)
buffer_elements    = total_tiles * total_tile_elements

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar) tensors. See data-model.md § Scalar Tensors.

Recursive Tiling

Note (non-normative): Recursive tiling is useful for hierarchical blocking in GEMM kernels (e.g., 128×128 L2 tiles subdivided into 32×32 L1 tiles). It is also the natural expression of cache-oblivious recursive blocking.

Example

Rank-2 tensor with shape [6, 8], tile_shape = [2, 4], outer_layout = 0x01 (row-major), inner_layout = 0x01 (row-major).

  • Tile grid shape: [3, 2]. Outer strides (implicit): [2, 1] in tiles.
  • Each tile: 8 elements. Inner strides (implicit): [4, 1] in elements.
  • Element [3, 5]: tile [1, 1], intra-tile [1, 1]. Tile linear index = 1 * 2 + 1 = 3. Tile start = 3 * 8 = 24. Intra-tile offset = 1 * 4 + 1 = 5. Final offset = 29.

Morton (Z-Order Curve) Layout — Hurray Format Specification

Layout tag: 0x05 | Tier: 1

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

Description

The Morton layout stores elements by interleaving the bits of their dimension indices, producing a linear order with good spatial locality for multi-dimensional access patterns.

Additional Descriptor Fields

FieldTypeDescription
morton_bitsuint32[rank]Number of bits used per dimension in the Morton encoding. Each value MUST be greater than 0.

Dimension Size Constraints

For each dimension k, shape[k] MUST satisfy shape[k] <= 2^morton_bits[k].

Morton Index Computation

For element [i_0, i_1, ..., i_{r-1}], the Morton code is computed by interleaving bits in round-robin order, starting from the least significant bit of dimension 0:

morton_code = 0
for bit_position b = 0, 1, 2, ...:
    for dimension d = 0, 1, ..., rank - 1:
        if b < morton_bits[d]:
            morton_code |= ((i_d >> b) & 1) << (b * rank + d)

The element at Morton code m is stored at linear offset m in the buffer.

Buffer Size

The buffer MUST hold 2^(sum(morton_bits[k] for all k)) elements.

Note (non-normative): For non-power-of-two dimension sizes, morton_bits[k] SHOULD be set to ceil(log2(shape[k])) to minimise padding. The worst-case padding factor per dimension is strictly less than 2×. Writers for whom padding waste is unacceptable SHOULD prefer a tiled or row-major layout.

Example

Rank-2 tensor with shape [4, 4], morton_bits = [2, 2].

Element [2, 3]: i_0 = 2 = 0b10, i_1 = 3 = 0b11. Interleaved (LSB first, dim 0 then dim 1): bit 0 of i_0=0, bit 0 of i_1=1, bit 1 of i_0=1, bit 1 of i_1=1. Morton code = 0b1110 = 14. Element [2, 3] is stored at linear offset 14.

Hilbert Curve Layout — Hurray Format Specification

Layout tag: 0x40 | Tier: 2

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

Description

The Hilbert curve layout stores elements according to a Hilbert space-filling curve, which provides better locality than the Morton curve: consecutive Hilbert indices always correspond to physically adjacent elements (L∞ distance = 1 in index space), with no large jumps. This benefits access patterns that traverse multi-dimensional regions with spatial coherence (e.g., image convolution, volumetric sampling, point-cloud processing), at the cost of a more expensive index computation than Morton.

Note (non-normative): The Hilbert curve is particularly effective for 2D and 3D spatial tensors where Morton's large jumps at quadrant boundaries cause cache misses. For 1D access patterns or cases where index-computation cost dominates, row-major or Morton layouts are preferable.

Additional Descriptor Fields

FieldTypeDescription
hilbert_orderuint32Order of the Hilbert curve. MUST be greater than 0. Each tensor dimension MUST equal 2^hilbert_order.
hilbert_rankuint32Number of curve dimensions. MUST equal the tensor's rank. MUST be greater than or equal to 2.

Validity Constraints

A conforming reader MUST reject a Hilbert-curve descriptor that violates any of the following constraints:

  1. shape[k] = 2^hilbert_order for every k in [0, hilbert_rank). All tensor dimensions MUST be a power of two equal to 2^hilbert_order.
  2. hilbert_rank MUST equal the tensor's rank.
  3. hilbert_rank MUST be greater than or equal to 2.
  4. hilbert_order MUST be greater than 0.

This layout MUST NOT be used for rank-0 (scalar) tensors (constraints 2 and 3 together exclude rank 0). See data-model.md § Scalar Tensors.

Buffer Size

The buffer MUST hold exactly 2^(hilbert_rank * hilbert_order) elements.

Normative Index Mapping

The normative index mapping is the algorithm defined by Skilling (2004) (see references.md). Conforming implementations MUST use this algorithm. All arithmetic is integer arithmetic; ^ denotes bitwise XOR; array indexing is zero-based.

Let r = hilbert_rank, p = hilbert_order. Coordinates X[0..r-1] are each in [0, 2^p). Hilbert index h is in [0, 2^(r*p)).

Bit packing: bit (b * r + (r - 1 - d)) of h holds bit b of X[d], for b = 0, ..., p-1 and d = 0, ..., r-1.

CoordsToHilbert(X[0..r-1], r, p) → h:

M = 1 << (p - 1)
Q = M
while Q > 1:
    P = Q - 1
    for i = 0 to r-1:
        if X[i] & Q:
            X[0] ^= P
        else:
            t = (X[0] ^ X[i]) & P
            X[0] ^= t; X[i] ^= t
    Q >>= 1
for i = 1 to r-1:
    X[i] ^= X[i-1]
t = 0; Q = M
while Q > 1:
    if X[r-1] & Q: t ^= Q - 1
    Q >>= 1
for i = 0 to r-1:
    X[i] ^= t
h = 0
for b = 0 to p-1:
    for d = 0 to r-1:
        h |= ((X[d] >> b) & 1) << (b * r + (r - 1 - d))
return h

HilbertToCoords(h, r, p) → X[0..r-1]:

X = [0] * r
for b = 0 to p-1:
    for d = 0 to r-1:
        X[d] |= ((h >> (b * r + (r - 1 - d))) & 1) << b
t = X[r-1] >> 1
for i = r-1 downto 1:
    X[i] ^= X[i-1]
X[0] ^= t
Q = 2
while Q != (1 << p):
    P = Q - 1
    for i = r-1 downto 0:
        if X[i] & Q:
            X[0] ^= P
        else:
            t = (X[0] ^ X[i]) & P
            X[0] ^= t; X[i] ^= t
    Q <<= 1
return X

Conformance Check

Selected index mappings for r = 2, p = 2 (shape [4, 4]):

hX[0]X[1]hX[0]X[1]
000822
110923
2111033
3011132
4021231
5031321
6131420
7121530

Consecutive entries differ by exactly 1 in exactly one coordinate. Implementations SHOULD validate against this table as a conformance check.

Note (non-normative): The MUST-level normative reference for the index mapping is the CoordsToHilbert / HilbertToCoords algorithm defined above. This table is provided as a SHOULD-level conformance aid; if a discrepancy is ever observed between table and algorithm, the algorithm output prevails.

COO (Coordinate) Sparse Layout — Hurray Format Specification

Layout tag: 0x06 | Tier: 1

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

Description

The COO (Coordinate) format stores a sparse tensor as a list of explicitly enumerated non-zero elements. Each non-zero is described by its logical index tuple and its value. COO is the most general sparse format: it imposes no ordering requirement on the non-zeros and supports arbitrary rank.

Note (non-normative): COO is simple to construct (append-only) and easy to convert to other formats. It is less efficient for row-wise random access than CSR, but is well-suited for assembly (e.g., accumulating contributions from finite-element computations) and as an interchange format between sparse kernels.

Buffer Table

A COO tensor descriptor MUST have buffer_count = 2 in the buffer table.

Buffer indexNameElement typeLengthDescription
0valuestensor element typennz elementsNon-zero element values, in storage order.
1indicesuint64nnz × rank elementsLogical index tuples of the non-zeros, stored in row-major order: indices[i * rank + d] is the coordinate of the i-th non-zero along dimension d.

Additional Descriptor Fields

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor.
is_sorteduint80x01 if the non-zeros are sorted in lexicographic index order (dimension 0 major); 0x00 otherwise.
_reserveduint8[7]MUST be 0x00.

Storage Order

Non-zeros are stored at positions 0 through nnz - 1 in values and indices. The i-th non-zero has:

  • Value: values[i]
  • Logical index: [indices[i * rank + 0], indices[i * rank + 1], ..., indices[i * rank + (rank-1)]]

If is_sorted = 0x01, the non-zeros MUST appear in lexicographic index order (i.e., sorted by dimension 0 first, then dimension 1, etc.). A conforming reader MAY use binary search for element lookup when is_sorted = 0x01.

If is_sorted = 0x00, no ordering guarantee is made. Readers MUST perform a linear scan to locate a specific element.

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar) tensors. See data-model.md § Scalar Tensors.

A conforming writer MUST ensure:

  1. Every stored index tuple is within bounds: 0 <= indices[i * rank + d] < shape[d] for all i in [0, nnz) and all d in [0, rank).
  2. No two stored entries share the same index tuple (no duplicate coordinates).
  3. If is_sorted = 0x01, entries are in strictly increasing lexicographic order (no ties, since duplicate coordinates are forbidden).

A conforming reader SHOULD validate constraints (1) and (2) and MUST reject descriptors that violate them, unless operating in permissive mode.

Buffer Size

  • values buffer: nnz * element_byte_width bytes (or ceil(nnz / packing_factor) for sub-byte types).
  • indices buffer: nnz * rank * 8 bytes (uint64 elements).

Both buffers MUST satisfy the alignment requirements in buffer-protocol.md.

Note (non-normative): The byte_offset field in the common descriptor header is not meaningful for sparse layouts — there is no single "first element" at a fixed offset. For COO tensors, byte_offset MUST be set to 0x0000000000000000.

Element Lookup

To retrieve the value at logical index idx[0..rank-1]:

  1. If is_sorted = 0x01, perform a binary search on the indices buffer (comparing full rank-tuples in lexicographic order) to find a matching entry.
  2. If is_sorted = 0x00, perform a linear scan over all nnz entries.
  3. If a match is found at position i, return values[i].
  4. If no match is found, the element is implicitly zero (or the zero value of the element type).

Interaction with Statistics Section

When the HAS_STATISTICS flag is set, the nnz field in the statistics section MUST match the nnz field in the COO descriptor, which is authoritative. The sparsity_ratio SHOULD be computed as 1.0 - (nnz / total_elements) where total_elements is the product of all shape values.

Example

Rank-2 sparse tensor with shape [4, 4], element type float32, 3 non-zeros:

Non-zeros: (0, 1) = 1.5,  (2, 0) = -0.5,  (3, 3) = 2.0
nnz = 3, is_sorted = 0x01

values  (buffer 0, 12 bytes):  [1.5, -0.5, 2.0]  as float32 LE

indices (buffer 1, 3×2×8 = 48 bytes, uint64 LE):
  entry 0: [0, 1]
  entry 1: [2, 0]
  entry 2: [3, 3]

CSR (Compressed Sparse Row) Layout — Hurray Format Specification

Layout tag: 0x07 | Tier: 1

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

Description

The CSR (Compressed Sparse Row) format stores a sparse matrix (rank-2 tensor) by compressing the row structure: each row is represented by a contiguous slice of a flat non-zero array, with a separate pointer array indicating where each row begins. CSR is the most widely-used sparse matrix format in scientific computing and machine learning (e.g., sparse attention, graph neural networks, sparse weight matrices).

Note (non-normative): CSR is defined only for rank-2 tensors in this version of the specification. Generalisation to arbitrary rank (Compressed Sparse Fiber, CSF) is specified in CSF (Compressed Sparse Fiber).

A conforming implementation MUST reject a CSR descriptor whose rank is not 2.

Buffer Table

A CSR tensor descriptor MUST have buffer_count = 3 in the buffer table.

Buffer indexNameElement typeLengthDescription
0valuestensor element typennz elementsNon-zero values in row-major order (all non-zeros of row 0 first, then row 1, etc.).
1col_indicesuint64nnz elementsColumn index of each non-zero. col_indices[i] is the column of values[i].
2row_ptruint64nrows + 1 elementsRow pointer array. row_ptr[i] is the index into values / col_indices of the first non-zero in row i. row_ptr[nrows] = nnz.

where nrows = shape[0].

Additional Descriptor Fields

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix.
_reserveduint8[8]MUST be 0x00.

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar) tensors. See data-model.md § Scalar Tensors. CSR is further restricted to rank 2 (see § Description).

Storage Invariants

A conforming writer MUST ensure:

  1. row_ptr[0] = 0 and row_ptr[nrows] = nnz.
  2. row_ptr is non-decreasing: row_ptr[i] <= row_ptr[i+1] for all i.
  3. Within each row i, the non-zeros in col_indices[row_ptr[i]..row_ptr[i+1]) MUST be sorted in strictly increasing order (no duplicate column indices per row).
  4. All column indices are within bounds: 0 <= col_indices[j] < shape[1] for all j.

A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.

Buffer Size

  • values buffer: nnz * element_byte_width bytes (or ceil(nnz / packing_factor) for sub-byte types).
  • col_indices buffer: nnz * 8 bytes (uint64 elements).
  • row_ptr buffer: (nrows + 1) * 8 bytes (uint64 elements), where nrows = shape[0].

All buffers MUST satisfy the alignment requirements in buffer-protocol.md.

For CSR tensors, byte_offset MUST be set to 0x0000000000000000.

Note (non-normative): The byte_offset field in the common descriptor header is not meaningful for CSR — there is no single "first element" at a fixed offset.

Element Lookup

To retrieve the value at row r, column c:

  1. The non-zeros of row r occupy positions row_ptr[r] through row_ptr[r+1] - 1 in values and col_indices.
  2. Perform a binary search on col_indices[row_ptr[r]..row_ptr[r+1]) for value c.
  3. If found at position j, return values[j].
  4. If not found, the element is implicitly zero.

Binary search is valid because column indices within each row are sorted (invariant 3).

Row Iteration

To iterate over all non-zeros in row r:

for j = row_ptr[r] to row_ptr[r+1] - 1:
    col = col_indices[j]
    val = values[j]

Interaction with Statistics Section

When the HAS_STATISTICS flag is set, the nnz field in the statistics section MUST match the nnz field in the CSR descriptor, which is authoritative. The sparsity_ratio SHOULD be 1.0 - (nnz / (shape[0] * shape[1])).

Example

Rank-2 sparse matrix with shape [4, 5], element type float32:

Dense representation:
  row 0: [1.0,  0,   0,  2.0,  0 ]
  row 1: [ 0,   0,  3.0,  0,   0 ]
  row 2: [ 0,  4.0,  0,   0,  5.0]
  row 3: [ 0,   0,   0,   0,   0 ]

nnz = 5

values      (buffer 0): [1.0, 2.0, 3.0, 4.0, 5.0]
col_indices (buffer 1): [0,   3,   2,   1,   4  ]
row_ptr     (buffer 2): [0,   2,   3,   5,   5  ]

row_ptr[3] = row_ptr[4] = 5 because row 3 has no non-zeros.

CSC (Compressed Sparse Column) Layout — Hurray Format Specification

Layout tag: 0x08 | Tier: 1

Also known as: CCS (Compressed Column Storage)

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

Description

The CSC (Compressed Sparse Column) format is the column analog of CSR. It stores a sparse matrix (rank-2 tensor) by compressing the column structure: each column is represented by a contiguous slice of a flat non-zero array, with a separate pointer array indicating where each column begins.

CSC is preferred when the workload requires efficient column access or column iteration — e.g., sparse matrix-vector products with column-major dense vectors, column pivoting in sparse direct solvers, and graph algorithms that traverse out-edges (adjacency stored column-wise).

Note (non-normative): CSC is defined only for rank-2 tensors in this version of the specification, consistent with CSR; generalisation to arbitrary rank is provided by the Compressed Sparse Fiber (CSF) layout (see CSF (Compressed Sparse Fiber)). The format is also known as Compressed Column Storage (CCS) in some communities (e.g., MATLAB, some LAPACK interfaces). The two names refer to the same format.

A conforming implementation MUST reject a CSC descriptor whose rank is not 2.

Buffer Table

A CSC tensor descriptor MUST have buffer_count = 3 in the buffer table.

Buffer indexNameElement typeLengthDescription
0valuestensor element typennz elementsNon-zero values in column-major order (all non-zeros of column 0 first, then column 1, etc.).
1row_indicesuint64nnz elementsRow index of each non-zero. row_indices[i] is the row of values[i].
2col_ptruint64ncols + 1 elementsColumn pointer array. col_ptr[j] is the index into values / row_indices of the first non-zero in column j. col_ptr[ncols] = nnz.

where ncols = shape[1].

Additional Descriptor Fields

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix.
_reserveduint8[8]MUST be 0x00.

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar) tensors. See data-model.md § Scalar Tensors. CSC is further restricted to rank 2 (see § Description).

Storage Invariants

A conforming writer MUST ensure:

  1. col_ptr[0] = 0 and col_ptr[ncols] = nnz.
  2. col_ptr is non-decreasing: col_ptr[j] <= col_ptr[j+1] for all j.
  3. Within each column j, the non-zeros in row_indices[col_ptr[j]..col_ptr[j+1]) MUST be sorted in strictly increasing order (no duplicate row indices per column).
  4. All row indices are within bounds: 0 <= row_indices[i] < shape[0] for all i.

A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.

Buffer Size

  • values buffer: nnz * element_byte_width bytes (or ceil(nnz / packing_factor) for sub-byte types).
  • row_indices buffer: nnz * 8 bytes (uint64 elements).
  • col_ptr buffer: (ncols + 1) * 8 bytes (uint64 elements), where ncols = shape[1].

All buffers MUST satisfy the alignment requirements in buffer-protocol.md.

Note (non-normative): As with CSR and COO, the byte_offset field in the common descriptor header is not meaningful for CSC. For CSC tensors, byte_offset MUST be set to 0x0000000000000000.

Element Lookup

To retrieve the value at row r, column c:

  1. The non-zeros of column c occupy positions col_ptr[c] through col_ptr[c+1] - 1 in values and row_indices.
  2. Perform a binary search on row_indices[col_ptr[c]..col_ptr[c+1]) for value r.
  3. If found at position i, return values[i].
  4. If not found, the element is implicitly zero.

Binary search is valid because row indices within each column are sorted (invariant 3).

Column Iteration

To iterate over all non-zeros in column c:

for i = col_ptr[c] to col_ptr[c+1] - 1:
    row = row_indices[i]
    val = values[i]

Relationship to CSR

CSC is the transpose of CSR: the CSC representation of matrix A is equivalent to the CSR representation of A^T with shape swapped. Implementations that support both formats MAY convert between them by transposing the pointer and index arrays.

Interaction with Statistics Section

When the HAS_STATISTICS flag is set, the nnz field in the statistics section MUST match the nnz field in the CSC descriptor, which is authoritative. The sparsity_ratio SHOULD be 1.0 - (nnz / (shape[0] * shape[1])).

Example

The same matrix as in the CSR example (shape [4, 5], element type float32):

Dense representation:
  row 0: [1.0,  0,   0,  2.0,  0 ]
  row 1: [ 0,   0,  3.0,  0,   0 ]
  row 2: [ 0,  4.0,  0,   0,  5.0]
  row 3: [ 0,   0,   0,   0,   0 ]

nnz = 5

values      (buffer 0): [1.0, 4.0, 3.0, 2.0, 5.0]
row_indices (buffer 1): [0,   2,   1,   0,   2  ]
col_ptr     (buffer 2): [0,   1,   2,   3,   4,   5]

col_ptr[j+1] - col_ptr[j] = number of non-zeros in column j: col 0: 1 (value 1.0 at row 0), col 1: 1 (value 4.0 at row 2), col 2: 1 (value 3.0 at row 1), col 3: 1 (value 2.0 at row 0), col 4: 1 (value 5.0 at row 2).

CSF (Compressed Sparse Fiber) Layout — Hurray Format Specification

Layout tag: 0x09 | Tier: 1

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

Description

The CSF (Compressed Sparse Fiber) format is the rank-N generalisation of CSR/CSC. It stores a sparse tensor as a tree of rank levels, where each level compresses one mode (logical dimension) with a (pos, crd) pair: a pos pointer array delimiting each parent's children, and a crd coordinate array naming those children. A single values array holds the non-zero values at the leaves. CSF is the natural higher-rank complement to COO for structured sparsity — sparse attention masks, sparse activations, and higher-order tensor factorisations.

Every level of a CSF tree is compressed: there is no per-mode dense/compressed distinction in this version of the specification. Dense-outer rank-2 cases are covered by CSR/CSC.

Note (non-normative): CSF is defined only for rank ≥ 3 tensors in this version. Rank-2 sparse matrices are served by CSR (CSR (Compressed Sparse Row)) and CSC (CSC (Compressed Sparse Column)), whose dense outer pointer array is more compact and more interop-canonical. COO (COO (Coordinate)) (which supports any rank) and CSR/CSC are preferable for rank ≤ 2. A future revision could admit dense levels via a mode_format field; a reader of this version rejecting such a descriptor is intended versioning behaviour.

A conforming implementation MUST reject a CSF descriptor whose rank is less than 3.

Mode Ordering

A CSF descriptor carries a mode_order: uint32[rank] field, a permutation of the integers 0..rank-1. mode_order[L] is the logical dimension stored at tree level L, where level 0 is the outermost level and level rank-1 is the leaf level directly above values. The bounding size for level L — the upper bound on every coordinate stored at that level — is shape[mode_order[L]].

mode_order affects storage traversal only; it does not change the tensor's logical shape.

A conforming reader MUST honour any valid mode_order permutation for lookup and iteration. A conforming reader MUST NOT reject a CSF descriptor merely because mode_order is non-identity. A conforming reader MUST reject a descriptor whose mode_order is not a valid permutation of 0..rank-1 (e.g. contains a duplicate or an out-of-range value), unless operating in permissive mode.

When a writer has no access-pattern preference, the identity ordering [0, 1, ..., rank-1] (row-major, outer-to-inner) SHOULD be used as the default.

Note (non-normative): mode_order is a performance knob: it lets a writer match the tree's nesting to its access pattern. The identity ordering is preferred as the default because it is reproducible across writers and keeps the outermost (slowest- varying) logical dimension at the top of the tree, which is cache-friendly for row-major access.

Buffer Table

A CSF tensor descriptor MUST have buffer_count = 2 * rank + 1 in the buffer table (plus any quantization-parameter buffers, which follow at indices 2 * rank + 1 and up).

Buffer indexNameElement typeLengthDescription
0valuestensor element typennz elementsNon-zero values, ordered by tree traversal (leaf order). values[p] is the value reached by descending to leaf position p.
2L + 1pos_Luint64n_{L-1} + 1 elements (2 for level 0)Level-L pointer array. pos_L[k] and pos_L[k+1] delimit the children of parent k in crd_L.
2L + 2crd_Luint64n_L elements (nnz for the leaf level)Level-L coordinate array. crd_L[i] is a coordinate along logical dimension mode_order[L].

where n_L is the number of nodes stored at level L, n_{-1} = 1 (a single virtual root), and n_{rank-1} = nnz. The top-level pointer array pos_0 always has length 2 and equals [0, n_0].

Additional Descriptor Fields

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor.
mode_orderuint32[rank]Permutation of 0..rank-1; mode_order[L] is the logical dimension stored at level L. See § Mode Ordering.
_reserveduint8[8]MUST be 0x00.

Validity Constraints

This layout MUST NOT be used for rank-0 (scalar), rank-1, or rank-2 tensors. CSF requires rank >= 3 (see § Description); rank-1 and rank-2 cases are served by COO, CSR, and CSC. Rank remains capped at 64 by data-model.md.

Storage Invariants

A conforming writer MUST ensure, for every level L in 0..rank-1:

  1. pos_L[0] = 0.
  2. pos_L is non-decreasing: pos_L[k] <= pos_L[k+1] for all k.
  3. The terminal pos entry equals the level's stored count: pos_0[1] = n_0, pos_L[n_{L-1}] = n_L, and n_{rank-1} = nnz.
  4. Within each parent slice crd_L[pos_L[k]..pos_L[k+1]), the coordinates MUST be sorted in strictly increasing order (no duplicate siblings).
  5. All coordinates are within bounds: 0 <= crd_L[i] < shape[mode_order[L]] for all i.

In addition, mode_order MUST be a valid permutation of 0..rank-1.

For an empty tensor (nnz = 0), pos_0 MUST be [0, 0], values and every crd_L MUST have length 0, and for each level L >= 1 pos_L MUST be [0] (length 1, since n_{L-1} = 0).

A conforming reader SHOULD validate these invariants and MUST reject descriptors that violate them, unless operating in permissive mode.

Buffer Size

  • values buffer: nnz * element_byte_width bytes (or ceil(nnz / packing_factor) for sub-byte types).
  • pos_L buffer: (n_{L-1} + 1) * 8 bytes (uint64 elements); pos_0 is always 2 * 8 = 16 bytes.
  • crd_L buffer: n_L * 8 bytes (uint64 elements); the leaf crd_{rank-1} is nnz * 8 bytes.

All buffers MUST satisfy the alignment requirements in buffer-protocol.md.

For CSF tensors, byte_offset MUST be set to 0x0000000000000000.

Note (non-normative): As with COO, CSR, and CSC, the byte_offset field in the common descriptor header is not meaningful for CSF — there is no single "first element" at a fixed offset; the first stored element is located by descending the tree.

Element Lookup

To retrieve the value at logical index idx = [idx[0], ..., idx[rank-1]]:

  1. Permute the query into storage order: the coordinate sought at level L is q_L = idx[mode_order[L]].
  2. Initialise the parent position p = 0 (the virtual root).
  3. For each level L from 0 to rank-1:
    • The children of the current parent occupy the slice crd_L[pos_L[p] .. pos_L[p+1]).
    • Perform a binary search on that slice for q_L.
    • If the binary search finds q_L at relative offset k within the slice (so that crd_L[pos_L[p] + k] == q_L), set p = pos_L[p] + k — the absolute index into crd_L — and continue to the next level.
    • If not found, the element is implicitly zero; stop.
  4. After the leaf level (L = rank-1), p is the leaf position; return values[p].

Binary search is valid at each level because sibling coordinates within a parent slice are sorted (invariant 4).

Iteration

To iterate over all non-zeros, descend the tree in depth-first, leaf order. The following sketch accumulates the storage-order coordinate tuple c[0..rank-1]; the logical index is recovered by placing c[L] at dimension mode_order[L]:

def visit(L, p):
    for i = pos_L[p] to pos_L[p+1] - 1:
        c[L] = crd_L[i]
        if L == rank - 1:
            emit(c, values[i])      # i is the leaf position
        else:
            visit(L + 1, i)

visit(0, 0)

Relationship to CSR / CSC / COO

CSF generalises CSR/CSC to arbitrary rank but does not replace them. For rank-2 tensors, writers SHOULD prefer CSR (CSR (Compressed Sparse Row)) or CSC (CSC (Compressed Sparse Column)), whose dense outer pointer array is more compact and more interop-canonical; for rank-1 and rank-2 coordinate lists, writers SHOULD prefer COO (COO (Coordinate)). CSF MUST NOT be used below rank 3.

A rank-2 CSF tree with mode_order = [0, 1] is structurally analogous to CSR (pos_0 the trivial root pointer, pos_1 the row pointer, crd_1 the column indices), and with mode_order = [1, 0] to CSC — but CSF stores an explicit top-level pos_0/crd_0 pair that CSR/CSC fold into their dense outer array, so the layouts are not byte-identical.

Quantization Compatibility

The values buffer (buffer index 0) holds the quantized leaf values, exactly as for COO and CSR. Quantization schemes decorate the leaves: per-tensor, per-channel, per-block, NF4, and MXFP compose as for COO/CSR, following the placement rules in quantization.md. Quantization-parameter buffers occupy buffer indices 2 * rank + 1 and up. All quantization-parameter buffers MUST carry the same device_tag as the values buffer.

Sharding

A CSF tensor MUST NOT carry a shard descriptor in this version of the specification. Sharding a CSF tree (the interaction between a shard's shard_offset and the per-level pos/crd structure) is deferred to a future revision.

Interaction with Statistics Section

When the HAS_STATISTICS flag is set, the nnz field in the statistics section MUST match the nnz field in the CSF descriptor, which is authoritative. The sparsity_ratio SHOULD be 1.0 - (nnz / total_elements), where total_elements is the product of all shape entries.

Example

Rank-3 sparse tensor with shape [2, 3, 4], element type float32, mode_order = [0, 1, 2] (identity), 4 non-zeros:

Non-zeros (logical index -> value):
  (0, 0, 1) -> 1.0
  (0, 2, 3) -> 2.0
  (1, 1, 0) -> 3.0
  (1, 1, 2) -> 4.0

nnz = 4
buffer_count = 2 * 3 + 1 = 7

Level 0 (dimension 0, bound 2):
  pos_0   (buffer 1): [0, 2]
  crd_0   (buffer 2): [0, 1]              # two non-empty slices: i=0, i=1

Level 1 (dimension 1, bound 3):
  pos_1   (buffer 3): [0, 2, 3]           # parent 0 has 2 children, parent 1 has 1
  crd_1   (buffer 4): [0, 2, 1]           # i=0: j=0,2 ; i=1: j=1

Level 2 (dimension 2, bound 4):
  pos_2   (buffer 5): [0, 1, 2, 4]        # leaf delimiters
  crd_2   (buffer 6): [1, 3, 0, 2]        # the k coordinates

values    (buffer 0): [1.0, 2.0, 3.0, 4.0]

Lookup of (1, 1, 2):

q = [1, 1, 2]                 # mode_order is identity

Level 0: parent p = 0, slice crd_0[pos_0[0]..pos_0[1]) = crd_0[0..2) = [0, 1]
         search 1 -> relative offset k = 1; p = pos_0[0] + 1 = 0 + 1 = 1
Level 1: slice crd_1[pos_1[1]..pos_1[2]) = crd_1[2..3) = [1]
         search 1 -> relative offset k = 0; p = pos_1[1] + 0 = 2 + 0 = 2
Level 2: slice crd_2[pos_2[2]..pos_2[3]) = crd_2[2..4) = [0, 2]
         search 2 -> relative offset k = 1; p = pos_2[2] + 1 = 2 + 1 = 3

leaf position p = 3 -> values[3] = 4.0

Block-Paged Layout — Hurray Format Specification

Status: Draft

Layout tag: 0x0A | Tier: 1 | Type: Indirect

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

Description

The block-paged format stores a tensor whose paged axis is divided into fixed-size pages drawn from a shared page pool, with a block table mapping each logical page position to a physical page ID. It is the interchange form of a PagedAttention KV cache: one page pool plus a ragged set of per-sequence block tables, addressed CSR-style by a seq_ptr offset array.

Block-paged is an indirect-dense layout: every logical element along the paged axis exists (there are no implicit zeros, unlike sparse layouts), but the mapping from a logical index to a physical buffer position is resolved through the block table rather than by an affine stride formula. A block-paged descriptor describes a static snapshot of one whole batch for one {kv_role, layer_index} pair. It carries no live allocator state.

Prefix sharing across sequences is expressed as static structure: two block-table entries MAY name the same physical page ID. The aliasing is internal to the block table; it creates no shared buffer ownership (see § Prefix Sharing).

Note (non-normative): Block-paged is defined only for rank-3 tensors in this version of the specification. Generalisation to other ranks is left to a future revision. The whole transformer KV cache ([layers, 2, ...]) is transmitted as a stream of descriptors, one per key/value role per layer (see ADR-024).

A conforming implementation MUST reject a block-paged descriptor whose rank is not 3.

Note (non-normative): "Page" and "block" refer to the same fixed-size physical unit. "Page" names the storage slot in the page_pool; "block" names the entry in the index structure (block_table) that maps a logical page position to a physical page ID. This dual naming matches the vLLM convention (page pool, block table).

Logical Shape

The logical shape is [total_tokens, num_heads, head_dim], where total_tokens is the sum of the per-sequence token counts across the batch. The ragged per-sequence structure is carried by seq_ptr (see § Buffer Table), not by the shape, which remains a hyperrectangle. The paged axis is the axis subdivided into pages; in this version it MUST be axis 0 (paged_axis = 0).

Buffer Table

A block-paged tensor descriptor MUST have buffer_count >= 3 in the buffer table. Buffers 0–2 are always present; buffers 3 and up are present only when the HAS_QUANTIZATION flag is set, per the placement rules in quantization.md.

Buffer indexNameElement typeLengthDescription
0page_pooltensor storage typenum_pages * page_size * num_heads * head_dim elementsFlat pool of fixed-size pages. Page p occupies the contiguous slice [p * page_size * num_heads * head_dim, (p + 1) * page_size * num_heads * head_dim).
1block_tableuint32 or uint64 (per block_table_index_type)total_logical_pages elementsConcatenation of every sequence's page-ID list, in sequence order. block_table[k] is the physical page ID of the k-th logical page across the batch.
2seq_ptruint32 or uint64 (per block_table_index_type)num_seqs + 1 elementsOffset array into block_table. Sequence s owns block_table[seq_ptr[s]..seq_ptr[s+1]). seq_ptr[0] = 0 and seq_ptr[num_seqs] = total_logical_pages.
3scalesquantization scale typenum_pages * page_size elements (per-token)Present only when HAS_QUANTIZATION is set. Paged through the same block_table as the values.
4zero_pointsquantization zero-point typesame length as scalesPresent only for an asymmetric scheme that uses a separate zero-point buffer (see quantization.md § Buffer Table Placement Rules).

where total_logical_pages = seq_ptr[num_seqs].

Note (non-normative): Buffers 1 (block_table) and 2 (seq_ptr) share the same element type, selected by block_table_index_type.

Note (non-normative): Empty batch (num_seqs = 0): block_table (buffer 1) is empty (byte_size = 0, and MAY be a null pointer per buffer-protocol.md), and seq_ptr (buffer 2) contains exactly one element, seq_ptr[0] = 0.

Additional Descriptor Fields

FieldTypeDescription
page_sizeuint32Tokens per page. MUST be >= 1. Common values are 16 and 32.
num_pagesuint64Number of physical pages in page_pool.
paged_axisuint32The axis subdivided into pages. MUST be 0 in this version.
num_seqsuint32Number of sequences in the batch. MAY be 0 for an empty batch.
kv_roleuint80x00 = key, 0x01 = value, 0x02 = fused / non-KV generic.
layer_indexuint32Transformer layer index. 0xFFFFFFFF indicates the tensor is not layer-scoped.
block_table_index_typeuint80x00 = uint32 (default), 0x01 = uint64. Governs the element type of buffers 1 and 2.
_reserveduint8[6]MUST be 0x00. Readers MUST reject a descriptor with any non-zero reserved byte.

All multi-byte fields in the block-paged descriptor MUST be little-endian.

Note (non-normative): A uint32 block table addresses up to 0xFFFFFFFF (4294967295) pages. Larger pools MUST use uint64 (block_table_index_type = 0x01).

byte_offset

For block-paged tensors, byte_offset MUST be set to 0x0000000000000000.

Note (non-normative): The byte_offset field in the common descriptor header is not meaningful for block-paged — logical position 0 is located via the block table, not at a fixed offset, exactly as for sparse layouts.

Storage Invariants

A conforming writer MUST ensure:

  1. seq_ptr[0] = 0 and seq_ptr[num_seqs] = total_logical_pages.
  2. seq_ptr is non-decreasing: seq_ptr[s] <= seq_ptr[s+1] for all s.
  3. All physical page IDs are within bounds: 0 <= block_table[k] < num_pages for all k.
  4. The page_pool buffer size is num_pages * page_size * num_heads * head_dim * element_byte_width bytes (or the sub-byte equivalent, ceil(num_pages * page_size * num_heads * head_dim / packing_factor), where packing_factor is as defined in memory-layout.md § Sub-Byte Types).
  5. When HAS_QUANTIZATION is set, scales (and zero_points, when present) are indexed by the same block_table, with one entry per page slot (num_pages * page_size entries).
  6. paged_axis = 0.

A conforming reader SHOULD validate invariants (1)–(3) and (6) and MUST reject descriptors that violate them, unless operating in permissive mode. A reader is NOT required to validate that aliased page IDs carry identical content: aliasing is expressible, not validated (see § Prefix Sharing).

Partial trailing page. A sequence's token count need not be a multiple of page_size; the slots in its final page beyond the valid token count are undefined. A reader MUST NOT read past a sequence's valid token count (bounded by seq_ptr and the sequence's token count). A writer SHOULD zero those unused slots when transferring across a trust or tenant boundary.

Element Lookup

To retrieve the value at token t of sequence s, head h, dimension d:

page_in_seq    = t / page_size            (integer division)
offset_in_page = t mod page_size
phys_page      = block_table[seq_ptr[s] + page_in_seq]
flat           = ((phys_page * page_size + offset_in_page) * num_heads + h) * head_dim + d
value          = page_pool[flat]

When the tensor is quantized, dequantize value using scales[phys_page * page_size + offset_in_page] (and the corresponding zero_points entry for an asymmetric scheme), per the active scheme in quantization.md.

Prefix Sharing

Two sequences share a prefix when their block-table slices name the same physical page IDs for the shared leading positions. For example, with

seq 0 block-table slice: [12, 5, 7, 9]
seq 1 block-table slice: [12, 5, 7, 3]

sequences 0 and 1 share physical pages 12, 5, and 7. The aliasing is internal to the block_table buffer: it creates no shared buffer ownership and no wire-level reference count (see ADR-024 and ADR-009). The engine-internal copy-on-write reference counts that govern live page lifetime are out of scope; a block-paged descriptor is a static snapshot.

Quantization Compatibility

Block-paged tensors MAY be quantized using the schemes defined in quantization.md. The quantization-parameter buffers occupy buffer indices 3 and up, per quantization.md § Buffer Table Placement Rules. The following rules govern how those schemes compose with the paged structure.

Per-page-slot parameters

For a quantized block-paged tensor, scales (and zero-points, when present) are stored per page slot: the scale array has exactly num_pages * page_size entries, and it is paged through the same block_table as the values. The scale for the element at physical page p, slot i lives at scales[p * page_size + i]. This is the indexing already given in § Element Lookup: a reader dequantizes using scales[phys_page * page_size + offset_in_page] (and the corresponding zero_points entry for an asymmetric scheme). Zero-point parameters, when carried in a separate buffer, follow the same per-page-slot layout.

Note (non-normative): Per-page co-location is required so that a shared (aliased) page carries its own scales. Because two sequences may name the same physical page ID in their block_table slices (see § Prefix Sharing), storing scales per logical token would force a shared page's leading tokens to be dequantized with different scales depending on the aliasing sequence, breaking numerical coherence of the prefix. Paging the scales through the same block_table as the values keeps every aliased reference to a page numerically identical.

Scale-buffer size

The standard per-block-affine scale-buffer-size formula in quantization.md (which yields shape[axis] / block_size scale entries) does NOT apply to block-paged. A reader MUST compute the scale-buffer size as num_pages * page_size entries from the page structure, because scales are stored per physical page slot rather than per logical token. This is what allows an aliased/shared page to carry its own scales and keeps prefix sharing numerically coherent.

Per-scheme composition

  • Per-tensor schemes (scheme_tag = 0x01, see quantization.md) compose normally: one scale (and zero-point) applies to the whole tensor.
  • Per-channel schemes (scheme_tag = 0x02) compose normally when the quantization axis is num_heads (axis 1) or head_dim (axis 2). The per-channel parameters are indexed by channel along that axis and are independent of the paged structure.
  • Per-block-affine (scheme_tag = 0x03) composes only under the constraint that the quantization descriptor's axis field MUST be 0 (the paged / token axis) and block_size MUST equal page_size. Under this constraint the per-block parameters coincide exactly with the per-page-slot parameters described above. A reader MUST reject a block-paged descriptor using scheme_tag = 0x03 whose axis field is not 0, or whose block_size is not equal to page_size.

A writer MUST NOT quantize the paged / token axis (axis 0) with the per-channel scheme (scheme_tag = 0x02); paged-axis quantization MUST use per-block-affine under the block_size == page_size constraint above, so that scales remain per-page-slot and aliasing stays coherent.

Sharding

A shard descriptor (see memory-layout.md § Splittability and Sharding) MUST NOT be applied to a block-paged tensor in this version of the specification. A reader MUST reject a block-paged descriptor that also carries a shard descriptor.

Note (non-normative): Tensor-parallel / multi-GPU sharding of a block-paged KV cache (for example, splitting along the num_heads axis) is deferred to a future revision. Before sharding can be permitted, the interaction between a shard descriptor's shard_offset and the absolute seq_ptr offsets into the block_table must be resolved, since seq_ptr indexes the whole-batch block table rather than a shard-local slice. The normative rule for this version is stated above: a reader MUST reject a block-paged descriptor that also carries a shard descriptor.

Alignment

All buffers MUST satisfy the alignment requirements in buffer-protocol.md (at least 64 bytes; page-aligned for GPU, IPC, or RDMA). All buffers in a block-paged descriptor MUST share the same device_tag and memory_class.

Framework Compliance

Note (non-normative): There is no formal PagedAttention specification. This layout is designed to be faithful to the dominant inference frameworks, principally vLLM.

In vLLM's FlashAttention / FlashInfer backend, the per-layer KV cache is stored as one tensor per key and per value with shape [num_blocks, block_size, num_kv_heads, head_dim], and the default block_size is 16. The block table is a per-sequence list of physical block numbers, and sequences may share physical blocks (copy-on-write prefix sharing). These map onto Hurray as follows:

vLLM conceptHurray block-paged
num_blocksnum_pages
block_size (default 16)page_size
num_kv_headsnum_heads
head_dimhead_dim
per-K / per-V tensorone descriptor per kv_role per layer_index
per-sequence block tableblock_table slice delimited by seq_ptr
shared physical blockaliased page ID in block_table

Hurray encodes the portable interchange layout ([num_blocks, block_size, num_kv_heads, head_dim] per role), not a kernel-internal reshaped layout. In particular, the older paged_attention_v1 key-cache reshape [num_blocks, num_kv_heads, head_dim/x, block_size, x] is an engine implementation detail and is out of scope for this layout. A producer that stores its cache in a kernel-internal layout MUST transpose to the interchange layout before emitting a block-paged descriptor.

Example

A KV cache for layer 3, key role, with 2 sequences, page_size = 4, num_pages = 5, num_heads = 2, head_dim = 8, element type float16. Sequence 0 has 6 tokens, sequence 1 has 3 tokens, and sequence 1 reuses page 0 (a shared prefix):

num_seqs    = 2
seq_ptr     (buffer 2): [0, 2, 3]
block_table (buffer 1): [0, 1, 0]
page_pool   (buffer 0): 5 * 4 * 2 * 8 = 320 float16 values

kv_role                = 0x00   (key)
layer_index            = 3
page_size              = 4
num_pages              = 5
paged_axis             = 0
block_table_index_type = 0x00   (uint32)

Sequence 0 occupies block_table[0..2) = [0, 1] (6 tokens span 2 pages: page 0 holds tokens 0–3, page 1 holds tokens 4–5, with slots 6–7 of page 1 undefined). Sequence 1 occupies block_table[2..3) = [0] (3 tokens in page 0, slot 3 undefined), aliasing page 0 — the shared prefix.

Composite / Virtual Tensor — Hurray Format Specification

Layout tag: 0x0B | Tier: 1 | Type: Virtual

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

Description

A composite tensor is a head descriptor plus an ordered set of member tensors, combined under a declared composition rule. The head presents a single logical view — one shape and one type_tag — while the members supply the actual data, each as a complete, ordinary tensor descriptor with its own layout, buffers, quantization, statistics, and device placement.

The head is a virtual (data-less) descriptor: it owns no buffers. This is a new addressing category, Virtual, alongside Dense, Sparse, and Indirect (see memory-layout.md § Layout Taxonomy). A composite unifies three previously distinct capabilities under one primitive:

  • Partition — a logical tensor whose index space is an exact, non-overlapping tiling of heterogeneous regions, each a full member tensor (see § Composition Semantics › Partition for the coverage and non-overlap constraints).
  • Overlay — a base tensor spanning the whole index space plus scattered corrections at shared indices (SpQR / KVQuant outlier quantization).
  • Group — several independent tensors delivered together under one logical identity (multi-output inference; weight collections).

Note (non-normative): A member is an ordinary TensorDescriptor. Everything a region needs — per-member layout, buffers, quantization, statistics, device tags — comes from the existing descriptor machinery, so the composite adds no per-member wire format of its own beyond the small Composite Member section (§ Composite Member Section). The crux of the unification is that a region is a shard is a member.

Note (non-normative): This version specifies only the v1.0-accepted scope: partition, group, and sealed overlay. Versioned / open overlay — an appendable, time-travel-capable overlay identified by an open-composite member_count sentinel and a per-member version field — is deferred to a future ADR (see ADR-027 § Status). Wire space is reserved for it (the member_count sentinel 0xFFFFFFFF in § Head Layout-Specific Fields, and the reserved padding in § Composite Member Section) so the future feature is an additive change, not a re-layout. Those reserved encodings are not usable in v1.0; the normative rejection rules for them appear in § Head Layout-Specific Fields and § Composite Member Section below.

Head Descriptor

The head is an ordinary tensor descriptor (see metadata.md) with layout_tag = 0x0B. It carries the composite's logical shape (shape) and logical element type (type_tag) — the view the composite presents to a consumer.

The head owns no data:

  • buffer_count MUST be 0x00.
  • byte_offset MUST be 0x0000000000000000.

A reader MUST reject a head descriptor whose buffer_count is not 0x00, or whose byte_offset is not 0x0000000000000000.

The head MUST NOT set the HAS_COMPOSITE_MEMBER flag (bit 4); that flag applies to members only (§ Composite Member Section). Because the head owns no data buffer, it MUST NOT set the HAS_QUANTIZATION flag (there is no stored data to dequantize). When a head is itself a member of an enclosing partition or overlay composite (a nested composite; see § Binding), it MUST carry a shard section exactly as any other member does.

A strict reader MUST reject an unrecognised layout_tag = 0x0B. A permissive reader MAY read the head's shape and type_tag but MUST NOT dereference tensor data through the head (there is none).

Head Layout-Specific Fields

Immediately following the head's byte_offset (per metadata.md § Layout-Specific Fields), the composite head encodes the following fields. All multi-byte fields are little-endian.

FieldTypeDescription
composition_ruleuint80x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF are reserved; 0xF0–0xFE are implementation-private; 0xFF is invalid.
combine_opuint8Overlay only: 0x01 replace (last-wins), 0x02 add. MUST be 0x00 for partition (0x01) and group (0x03).
_reserveduint8[2]MUST be 0x00.
member_countuint32Number of member tensors that immediately follow the head. MUST be a definite count for every composition rule.

A reader MUST reject a head whose composition_rule is 0x00, 0xFF, or in the reserved range 0x04–0xEF. A reader that does not recognise an implementation-private composition_rule in 0xF0–0xFE MUST reject the head unless operating in permissive mode with an out-of-band agreement.

A reader MUST reject a head whose combine_op is not 0x00 when composition_rule is 0x01 (partition) or 0x03 (group). When composition_rule is 0x02 (overlay), a reader MUST reject a head whose combine_op is not 0x01 or 0x02.

A reader MUST reject a head whose _reserved bytes are not all 0x00.

member_count sentinel — RESERVED. The value 0xFFFFFFFF denotes an open composite and is RESERVED for a future ADR (versioned / open overlay). A strict v1.0 reader MUST reject a head whose member_count is 0xFFFFFFFF. All v1.0 composites — partition, group, and overlay — use a definite member_count.

Note (non-normative): member_count = 0x00000000 (a head with no members) is a definite count of zero. It is syntactically valid but carries no data view; writers are not expected to emit it. Validation for each rule at "the Nth member" is trivially satisfied at N = 0 except where a rule requires a base member (overlay), which such a head cannot supply — see § Validation.

Members

A member is a complete, ordinary TensorDescriptor with its own layout_tag (dense, sparse, indirect, or a nested composite), its own buffer table, and its own optional quantization, statistics, and device tags.

Shard section

For partition (0x01) and overlay (0x02) composites, every member MUST carry a shard section (HAS_SHARD flag set; see metadata.md § Shard Section, and ADR-004). Its parent_shape MUST equal the head's logical shape, and its shard_offset together with the member's own shape define the member's box in the head's index space:

member covers, along dimension k, the half-open range
    [ shard_offset[k], shard_offset[k] + shape[k] )

For group (0x03) composites, members MAY omit the shard section (group members have no spatial relationship to the head; see § Composition Semantics).

Element type across members

For partition and overlay, each member MAY declare its own stored element type and its own quantization scheme, but each member's decoded value type MUST equal the head's type_tag. Dequantization already yields a canonical real-valued view, so no new machinery is required.

Note (non-normative): SpQR example — head type_tag = float16; the base member is int4 with per-block-affine quantization decoding to float16; an outlier correction member is a float16 COO sparse tensor. The overlay combine is evaluated in float16 (the head's type). Composite versioning would change values over a fixed index space; shape evolution is out of scope.

For group composites, the head's shape and type_tag are advisory and members MAY differ arbitrarily (see § Composition Semantics).

Composite Member Section

Overlay members carry a Composite Member section, a new optional descriptor section gated by descriptor flag bit 4, HAS_COMPOSITE_MEMBER, defined in metadata.md § Composite Member Section and appended after the Extension Type section. Its single v1.0 field is member_role:

member_roleMeaning
0x00correction
0x01base
0x02–0xFFRESERVED (see § Deferred below)

Partition and group members MUST NOT carry a Composite Member section (they MUST NOT set HAS_COMPOSITE_MEMBER). An overlay member MUST carry one.

Note (non-normative): v1.0 carries member_role only. A sealed overlay's precedence is plain stream / emission order (§ Composition Semantics), so no explicit version field is needed. The section's reserved padding leaves room for a future ADR to add a member_version field additively without reallocating the section.

Binding

A head with a definite member_count = N binds the next N self-delimiting tensors in stream / file write order as its members. This is a forward promise — the head precedes its members, which precede their data — not a back-reference. It introduces no name namespace and is streamable for both readers and writers.

  • In-process: the head handle plus an array of N member handles (no wire concern).
  • IPC / network streaming: the framing and "close" rules are defined in interchange.md § Composite Tensor Streaming.
  • File: the head + members occupy consecutive index entries in the tensor region; the recovery rule is defined in file-format.md § Composite Tensors.

Nested composites are permitted: a member MAY itself be a head with layout_tag = 0x0B, parsed pre-order. A reader MUST enforce a maximum composite nesting depth of 8 levels and MUST reject a descriptor that exceeds it (the same recursion-depth discipline used for nested Tiled layouts, metadata.md § Layout-Specific Fields › Tiled / Blocked).

Note (non-normative): Plain sharding (members without a head; see interchange.md § Parallel Transfers) is the status quo. The head upgrades an ephemeral shard set into a persistent, composition-typed collection. Explicit member identifiers for out-of-order random access are not defined in this version (see § Deferred).

Composition Semantics

Partition (0x01)

Members' shard boxes MUST exactly cover the head's index space with no overlap. Each logical index belongs to exactly one member, so the composite view is zero-copy: value lookup at logical index idx is (1) select the member whose box contains idx, (2) compute the local index idx - shard_offset, (3) apply that member's own addressing.

Coverage constraint. The union of all member boxes MUST exactly cover every element in the head's index space: for every valid index [i_0, i_1, ..., i_{r-1}] (where 0 <= i_k < shape[k] for all k, shape being the head's shape), there MUST be exactly one member whose box — [shard_offset[k], shard_offset[k] + shape[k]) along each dimension k (§ Members › Shard section) — contains that index.

Non-overlap constraint. Two members' boxes A and B overlap if, for every dimension k:

A.shard_offset[k] < B.shard_offset[k] + B.shape[k]
AND
B.shard_offset[k] < A.shard_offset[k] + A.shape[k]

Member boxes MUST NOT overlap. A conforming writer MUST produce a partition composite whose members satisfy both constraints. A conforming reader SHOULD validate them and MUST reject a violating composite unless in permissive mode (§ Validation).

Overlay (0x02, v1.0: sealed only)

One member is the base (member_role = 0x01): it MUST be the first member and its box MUST span the whole index space (its shard shard_offset is all-zero and its shape equals the head's shape). The remaining members are corrections (member_role = 0x00) whose boxes MAY overlap one another and the base.

Precedence is emission / stream order — later wins. The writer emits the base first, then corrections in the order they take effect. A single-pass reader applies members as they arrive; no version field or reordering is required.

Reads apply the base, then all corrections in emission order, under combine_op, evaluated in the head's type_tag:

  • combine_op = 0x01 (replace): within a correction's box, the topmost (latest-emitted) member covering an index wins; outside every correction's box, the base shows through.
  • combine_op = 0x02 (add): the value at an index is the base value plus the sum of all covering corrections' values at that index.

A correction's box replaces or adds within its box only; outside it, lower-precedence members (down to the base) show through. Per-member storage is zero-copy, but the merged logical view is computed by the consumer — a sealed overlay is not zero-copy at the composite level.

Note (non-normative): Replace serves region overwrites; add serves residual / outlier overlays (SpQR / KVQuant). A v1.0 overlay is a complete snapshot: definite member_count, not appendable without rewriting the head. Logical delete (reverting a region to base, or masking it) is out of scope for v1.0; a region is reverted by writing a new correction that carries the desired values. A data-less tombstone member kind is reserved (see § Deferred).

Group (0x03)

Members are independent tensors under one head identity, with no spatial semantics and no ordering semantics. The head's shape and type_tag are advisory; members MAY differ arbitrarily in rank, shape, element type, layout, and device. This occupies the grouping gap (see ADR-010) using forward adjacency, not naming.

Validation

Validation is cross-member and stateful but bounded: because every v1.0 composition rule uses a definite member_count = N, a reader accumulates state only up to N, reaches one verdict at the Nth member, and is done.

Per-member checks (immediate)

On each member, a reader MUST verify:

  1. For partition and overlay: the member carries a shard section whose parent_shape equals the head's shape, and whose box is in bounds (shard_offset[k] + shape[k] <= the head's shape[k] for every dimension k).
  2. The member's decoded value type equals the head's type_tag (§ Members).
  3. combine_op is legal for the composition rule (§ Head Layout-Specific Fields).
  4. For overlay: the member carries a Composite Member section with a valid member_role (0x00 or 0x01). The first overlay member MUST be the base (member_role = 0x01) and MUST span the index space (all-zero shard_offset, shape equal to the head's shape). Every subsequent overlay member MUST have member_role = 0x00.
  5. For partition and group: the member MUST NOT set HAS_COMPOSITE_MEMBER.

A per-member violation MUST cause rejection of the whole composite.

Close-time checks (at the Nth member)

  • Partition: on receiving the Nth member, run the exact-cover and non-overlap checks (§ Composition Semantics › Partition) over the N boxes. A gap or an overlap MUST cause rejection.
  • Sealed overlay: close at the Nth member. The base-span is already checked at the first member (per-member check 4); overlap between corrections is legal; there is no exact-cover requirement. A reader MUST reject an overlay head (composition_rule = 0x02) with member_count = 0x00000000: overlay requires a base member (§ Composition Semantics), which a zero-member composite cannot supply.
  • Group: close at the Nth member. There is no coverage or overlap check (members MAY differ arbitrarily).

Torn composite

A torn composite — the stream or file ends before all N members have arrived, for any composition rule — is incomplete. A strict reader MUST reject it. A permissive reader MAY expose the arrived members as independent shard tensors but MUST NOT present the composite as complete.

Deferred

Note (non-normative): The following are reserved for a future ADR (versioned / open overlay and related work) and are not part of v1.0: the 0xFFFFFFFF open-composite member_count sentinel and its append-oriented membership rules; the member_version field (the Composite Member section's reserved padding holds room for it); time-travel reads; file append with footer regeneration; a tombstone member kind (member_role = 0x02, data-less, revert-to-base-within-box); explicit member identifiers for out-of-order random access; wall-clock timestamp versioning; an optional inline single-frame compaction of a partition composite (a future revival of this idea would need a fresh layout tag allocated from the reserved range 0x0C–0x3F; the old subpaving tag 0x06 is permanently reassigned to COO and is not available for it); and heterogeneous per-member device placement. See ADR-027 § Status and § Consequences.

Example

A sealed overlay (SpQR-style) with a float16 logical view of shape [4096, 4096], combine_op = 0x01 (replace), one base and one correction:

Head (layout_tag = 0x0B):
  shape            = [4096, 4096]
  type_tag         = float16
  buffer_count     = 0x00
  byte_offset      = 0x0000000000000000
  composition_rule = 0x02   (overlay)
  combine_op       = 0x01   (replace)
  member_count     = 2

Member 0 (base):
  shape            = [4096, 4096]
  type_tag         = int4    (stored), decodes to float16
  layout_tag       = 0x01    (row-major), per-block-affine quantization
  HAS_SHARD:  parent_shape = [4096, 4096], shard_offset = [0, 0]
  HAS_COMPOSITE_MEMBER:  member_role = 0x01  (base)

Member 1 (correction):
  shape            = [4096, 4096]
  type_tag         = float16
  layout_tag       = 0x06    (COO sparse: scattered outliers)
  HAS_SHARD:  parent_shape = [4096, 4096], shard_offset = [0, 0]
  HAS_COMPOSITE_MEMBER:  member_role = 0x00  (correction)

The merged logical view is: for each index, the correction's outlier value if present, otherwise the dequantized base value. The reader computes this merge; it is not zero-copy at the composite level.

A partition composite with a float32 logical view of shape [8, 8], split into two [8, 4] members:

Head: shape = [8, 8], type_tag = float32, buffer_count = 0,
      composition_rule = 0x01 (partition), combine_op = 0x00, member_count = 2

Member 0: shape = [8, 4], HAS_SHARD parent_shape = [8, 8], shard_offset = [0, 0]
Member 1: shape = [8, 4], HAS_SHARD parent_shape = [8, 8], shard_offset = [0, 4]

The two boxes exactly cover [8, 8] with no overlap; element [3, 6] resolves to member 1 at local index [3, 2]. The view is zero-copy.

Buffer Protocol — Hurray Format Specification

Status: Draft

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

Scope

This section defines the buffer protocol: the rules governing how tensor data buffers and quantization-parameter buffers are represented, aligned, located on a device, owned, and released. It is the normative reference for all other sections that reference buffer handles, device tags, or alignment requirements.

The buffer protocol is independent of the interchange transport (in-process, IPC, or cross-machine); interchange.md defines the transport-level framing.


Buffer Handle

A buffer handle is the unit by which a tensor descriptor references a contiguous region of memory. Each handle appears as a 16-byte entry in the buffer table of the tensor descriptor (see metadata.md § Buffer Table).

OffsetFieldTypeDescription
0byte_sizeuint64Size of the buffer in bytes (little-endian). 0 denotes an empty buffer.
8alignmentuint32Minimum alignment of the buffer's base address in bytes (little-endian). MUST be a power of two; MUST be at least 64 for non-empty buffers (byte_size > 0); any power-of-two value (including 1) is valid for empty buffers (byte_size == 0). See § Empty Buffers.
12device_taguint8Device where this buffer resides. See § Device Tags.
13sync_modeuint8Producer-side synchronisation mechanism in effect. See § Stream and Event Synchronisation.
14memory_classuint8Memory access class. See § Memory Class.
15_reserveduint8MUST be 0x00. Readers MUST reject a descriptor with non-zero reserved bytes.

All multi-byte fields MUST be encoded in little-endian byte order.

Note (non-normative): The buffer handle in the descriptor is a declaration of a buffer's properties, not a pointer. The actual pointer (or shared-memory handle, or RDMA registration) is communicated out-of-band via the interchange protocol or, for in-process use, via the C ABI defined in docs/impl/c-ffi.md.


Alignment

Minimum Alignment

The base address of every non-empty buffer MUST be aligned to at least 64 bytes. This ensures compatibility with all current SIMD instruction sets (AVX-512, NEON, SVE) without requiring per-operation alignment negotiation.

A writer MUST set alignment to the actual alignment it guarantees, which MUST be a power of two and MUST be at least 64. A writer MAY set alignment to a larger value (e.g., a page boundary of 4096 or 65536) to communicate a stronger guarantee.

A reader MAY rely on the declared alignment for SIMD loads. A reader MUST NOT rely on alignment stronger than what is declared in the alignment field.

Page Alignment for GPU and IPC

Buffers shared across process boundaries (IPC) or placed in device memory (GPU) SHOULD be aligned to the host page size, which is typically 4096 bytes. Writers targeting GPU or IPC transport MUST set alignment to at least 4096.

Buffers intended for RDMA transfer SHOULD be aligned to the RDMA provider's minimum pinnable unit, which is typically 4096 bytes. Writers targeting RDMA MUST set alignment to at least 4096.

Empty Buffers

A buffer with byte_size = 0 is an empty buffer. The 64-byte minimum alignment requirement does not apply to an empty buffer: there are no addressable bytes to align. A writer MAY set alignment to any power-of-two value (including 0x00000001) for an empty buffer. A reader MUST NOT dereference the pointer of an empty buffer.

In C ABI contexts, an empty buffer MAY be represented by a null pointer. A non-null pointer for an empty buffer is also valid; readers MUST handle both.


Device Tags

The device_tag field identifies the memory space in which the buffer resides.

ValueDevice
0x00CPU host memory
0x01CUDA device memory
0x02ROCm device memory
0x03Metal device memory (Apple Silicon unified memory)
0x04Vulkan device memory
0x05WebGPU device memory
0x06Qualcomm Hexagon (HVX/HMX) memory
0x07Intel Level Zero / oneAPI device memory
0x08OpenCL device memory
0x09–0xEFReserved for future specification versions
0xF0–0xFEImplementation-private device types
0xFFReserved (invalid)

A reader MUST reject a buffer handle whose device_tag is 0xFF.

Tags in the range 0x09–0xEF MUST NOT be used by any implementation; they are reserved for future specification versions.

Tags in the range 0xF0–0xFE MAY be used by implementations for private or experimental device types. Descriptors carrying private device tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band.

Per-Device Memory Model

This subsection specifies, for each named device tag, the allocation context the buffer's base address refers to and any alignment requirement that applies in addition to the global 64-byte SIMD minimum defined in § Alignment. Where no device-specific alignment is required, only the 64-byte minimum (and the page-alignment SHOULD for GPU/IPC) applies.

0x00 CPU host memory

Buffers reside in the producer's process-addressable host memory, allocated by any standard host allocator (e.g., malloc, mmap, jemalloc, the system page allocator). No alignment requirement above the 64-byte SIMD minimum applies. Buffers shared via IPC SHOULD be page-aligned per § Page Alignment for GPU and IPC.

0x01 CUDA device memory

Buffers reside in CUDA device memory, typically allocated by cudaMalloc, cuMemAlloc, or cuMemCreate / cuMemMap on the device identified by the producer's CUDA context. The base address is a CUDA device pointer and MUST NOT be dereferenced from the host. Buffers intended for cross-process sharing or GPUDirect RDMA SHOULD be page-aligned (typically 4096 bytes); the CUDA driver returns allocations aligned to at least 256 bytes, which already satisfies the 64-byte SIMD minimum.

0x02 ROCm device memory

Buffers reside in AMD GPU device memory allocated through the HIP / ROCr runtime (e.g., hipMalloc, hsa_amd_memory_pool_allocate). The base address is a device pointer and MUST NOT be dereferenced from the host. The 64-byte minimum applies; page alignment SHOULD be used for IPC and RDMA per the global rules.

0x03 Metal device memory

Buffers reside in a Metal MTLBuffer whose storage mode is shared (Apple Silicon unified memory) or private. On Apple Silicon, unified memory permits host access to shared-mode buffers; private-mode buffers MUST NOT be dereferenced from the host. The base address conveyed via this tag is the buffer's contents pointer (for shared/managed storage) or the GPU resource handle (for private storage); the consumer MUST agree out of band on which storage mode the producer used. The 64-byte minimum applies.

0x04 Vulkan device memory

Buffers reside in a VkDeviceMemory allocation on the producer's Vulkan logical device, typically backing a VkBuffer. The base address is the result of vkMapMemory for host-visible memory, or an opaque device handle that MUST NOT be dereferenced from the host for device-local memory. Cross-process sharing requires an external memory handle (VkExternalMemoryHandleTypeFlagBits) exchanged out of band. The 64-byte minimum applies; producers SHOULD honour the device's nonCoherentAtomSize and minMemoryMapAlignment properties when applicable.

0x05 WebGPU device memory

Buffers reside in a WebGPU GPUBuffer allocated against the producer's GPUDevice. The base address is a host pointer only when the buffer was mapped via mapAsync with MAP_READ or MAP_WRITE; otherwise it is an opaque GPU resource handle that MUST NOT be dereferenced from the host. WebGPU imposes a 4-byte minimum for buffer offsets and copy sizes; the 64-byte SIMD minimum subsumes this. Cross-process sharing of WebGPU buffers is not defined by the W3C specification; implementations MUST exchange GPU resource handles out of band.

0x06 Qualcomm Hexagon (HVX/HMX) memory

Buffers reside in memory allocated via the Qualcomm AI Engine Direct (QNN) SDK or the Hexagon SDK's rpcmem / FastRPC allocator, accessible to the Hexagon DSP / NPU via the QNN HTP backend. The base address is a host pointer into the rpcmem region that the DSP can also address through its IOMMU. HVX operations are most efficient on 128-byte-aligned addresses; producers SHOULD align buffers to at least 128 bytes when targeting Hexagon, which exceeds the 64-byte SIMD minimum.

0x07 Intel Level Zero / oneAPI device memory

Buffers reside in memory allocated via the Intel Level Zero API (zeMemAllocDevice, zeMemAllocHost, zeMemAllocShared) on a Level Zero device, or equivalently via SYCL Unified Shared Memory (sycl::malloc_device, sycl::malloc_shared). The base address may or may not be host-dereferenceable depending on the allocation kind; consumers MUST agree on the kind out of band or query it via zeMemGetAllocProperties. The 64-byte minimum applies; the Level Zero spec returns allocations aligned to at least 64 bytes by default.

0x08 OpenCL device memory

Buffers reside in an OpenCL cl_mem allocation on the producer's OpenCL context and device. The base address is a host pointer only when the buffer was created with CL_MEM_USE_HOST_PTR / CL_MEM_ALLOC_HOST_PTR and is currently mapped via clEnqueueMapBuffer; otherwise it is an opaque device handle and MUST NOT be dereferenced from the host. The 64-byte minimum applies; producers SHOULD honour the device's CL_DEVICE_MEM_BASE_ADDR_ALIGN property when allocating sub-buffers.

0xF0–0xFE Implementation-private device types

Buffers carrying a private device tag MUST be exchanged only between peers that have agreed on the allocation context, alignment requirements, host-versus-device addressability, and synchronisation rules out of band. The format specification makes no statement about the memory model of private tags beyond the global buffer protocol invariants in this section.

Note (non-normative): The mapping between Hurray device tags and DLPack DLDeviceType constants is normative for Python bindings only and lives in docs/impl/python-bindings.md § Device Tag Mapping (Hurray ↔ DLPack). It is intentionally not duplicated here, since translation is the binding layer's responsibility, not a property of the buffer protocol.

Device Colocation

All buffers referenced by a single tensor descriptor (data buffer + all quantization-parameter buffers) MUST share the same device_tag AND the same memory_class. A reader MUST reject a descriptor whose buffers carry different device_tag or memory_class values.

For TENSOR_PUT transfers (see interchange.md), the client unilaterally declares the destination device_tag in the descriptor; the server MAY reject the transfer with DEVICE_UNAVAILABLE but MUST NOT silently place buffers on a different device.

Note (non-normative): Device colocation ensures that quantized tensor kernels can dereference both the data and the quantization parameters without triggering cross-device transfers. A writer that needs quantization parameters on a different device must emit a separate tensor descriptor.

When buffer handles are exchanged across machines, the device selection rules in interchange.md § Device Negotiation govern which device tag is valid for a given transfer.


Memory Class

The memory_class field identifies how a buffer is accessible — specifically, whether it can be read without copying by more than one compute unit simultaneously. memory_class is orthogonal to device_tag: the device tag names the allocator or hardware domain; the memory class names the access semantics within that domain.

Memory Class Values

ValueNameSemantics
0x00STANDARDDevice-exclusive memory. Only the primary compute unit of the tagged device can access this buffer without a copy. This is the default for all device types and is the correct value for pre-ADR-020 descriptors whose _reserved[0] byte is 0x00.
0x01HOST_PINNEDCPU-accessible, device-mapped. The CPU can read and write at native cache speed. The device can access the buffer over its interconnect (PCIe, NVLink) without an explicit copy, but at reduced bandwidth compared to device-local memory. No hardware-managed coherency between CPU and device caches.
0x02UNIFIEDHardware-managed unified or coherent memory. Both CPU and device can access this buffer at any time; the hardware (driver or MMU) ensures coherency. Physical pages may migrate.
0x03PEERPeer-to-peer device memory. Directly accessible by a specific set of peer accelerators agreed out of band (NVLink, xGMI, PCIe BAR mapping). Not CPU-accessible without a copy. The set of peers is communicated via the interchange protocol, not this field.
0x04–0xEF(reserved)Reserved for future specification versions. Readers MUST reject a buffer handle with a memory_class in this range.
0xF0–0xFE(private)Implementation-private memory classes. Valid only when paired with a private device_tag (0xF0–0xFE). Semantics are agreed out of band. A reader that does not recognise the private class MUST reject the handle unless the semantics have been agreed out of band.
0xFF(invalid)Reserved. Readers MUST reject a buffer handle whose memory_class is 0xFF.

A reader MUST reject a buffer handle whose memory_class value is in the range 0x04–0xEF or equals 0xFF.

Per-Device Validity

Not every (device_tag, memory_class) combination is meaningful. The following table defines the valid combinations. A reader MUST reject a buffer handle whose (device_tag, memory_class) pair is not listed as valid for the declared device. Private device tags (0xF0–0xFE) MAY be paired with any private memory class (0xF0–0xFE) or STANDARD (0x00); semantics are out of band.

DeviceSTANDARDHOST_PINNEDUNIFIEDPEER
CPU (0x00)✓ heap / malloc✓ page-locked for GPU DMA✓ CPU side of a unified address space✗
CUDA (0x01)✓ cudaMalloc✓ cudaMallocHost✓ cudaMallocManaged✓ NVLink / PCIe P2P
ROCm (0x02)✓ hipMalloc✓ hipHostMalloc✓ hipMallocManaged (hw-dependent)✓ xGMI / PCIe
Metal (0x03)✓ MTLStorageModePrivate✓ MTLStorageModeManaged (discrete GPU only)✓ MTLStorageModeShared (Apple Silicon)✗
Vulkan (0x04)✓ DEVICE_LOCAL✓ HOST_VISIBLE✓ DEVICE_LOCAL|HOST_VISIBLE (integrated GPU)✓ via external memory extension
WebGPU (0x05)✓✗✗✗
Hexagon (0x06)✓ VTCM / DDR✓ FastRPC shared✓ FastRPC coherent✗
Level Zero (0x07)✓ zeMemAllocDevice✓ zeMemAllocHost✓ zeMemAllocShared✓
OpenCL (0x08)✓ device cl_mem✓ CL_MEM_ALLOC_HOST_PTR✓ SVM (clSVMAlloc, OpenCL 2.0+)✗

Note (non-normative): Metal HOST_PINNED (MTLStorageModeManaged) is deprecated and unavailable on Apple Silicon. Producers targeting Apple Silicon MUST use UNIFIED (MTLStorageModeShared) instead. The HOST_PINNED value remains defined for discrete Metal GPU configurations.

Note (non-normative): ROCm UNIFIED requires hardware support for Heterogeneous Memory Management (HMM). Producers MUST verify hardware support before tagging a buffer UNIFIED; consumers MAY fall back to a copy-based path if UNIFIED is declared but the consumer's runtime does not support HMM on the current device.

Backward Compatibility

Existing descriptors that encode 0x00 in what was previously the first byte of _reserved[2] are implicitly STANDARD (memory_class = 0x00) — the most conservative and correct semantics for any pre-ADR-020 allocation. No existing producer or consumer is broken by this reassignment.

Readers compiled before this amendment will encounter a non-zero memory_class byte and reject it at the _reserved byte check. This is the intended fail-safe: a consumer that does not understand the memory class MUST NOT silently treat a UNIFIED buffer as STANDARD, as doing so would yield incorrect synchronisation.


Buffer Ownership and Lifetime

Ownership Model

At any instant, exactly one entity — the owner — is responsible for the buffer's memory. Ownership may be transferred between a producer and a consumer as part of the interchange protocol, but it is never shared: concurrent read/write access to the same buffer by multiple owners is a protocol error.

In-Process

In in-process exchange, the producer creates the buffer and holds ownership until the consumer signals that it has retained a reference (via the release callback mechanism described below). The consumer then owns the buffer for the duration of its use and MUST release it exactly once when done.

IPC

In IPC exchange via shared memory, the producer creates and owns the shared memory segment. The consumer maps the segment into its own address space. The producer MUST NOT unmap or destroy the segment until all consumers have unmapped it. The IPC channel MUST convey a release signal so that the producer knows when it may reclaim the segment.

Cross-Machine

In cross-machine exchange, the sender owns the source buffer and the receiver owns the destination buffer. There is no shared buffer; data is copied (or RDMA-written) from sender to receiver. See interchange.md for framing details.

Release Callback

For in-process and IPC exchange, the buffer handle is augmented at the ABI level with a release callback: a function pointer that the consumer calls exactly once when it has finished using the buffer. The release callback is not encoded in the binary descriptor; it is supplied by the producer at handoff time via the C ABI (see docs/impl/c-ffi.md).

A consumer MUST call the release callback exactly once. A consumer MUST NOT access the buffer after calling the release callback. A producer's release callback MUST be safe to call from any thread.

Reference Counting

Reference counting is an implementation detail — not a normative contract (see docs/adr/ADR-009-release-callback-not-normative-refcount.md). A producer that wishes to support multiple simultaneous consumers of the same buffer MUST implement reference counting internally. Each consumer receives a separate buffer handle whose release callback decrements the internal count; the actual deallocation occurs only when the count reaches zero. Consumers are unaware of this; they call their release callback exactly once as the normative contract requires.

Note (non-normative): This is the same model used by DLPack's DLManagedTensor.deleter. It keeps the ABI surface minimal and allows each language binding to use its own lifetime management idiom (Python GC, Rust Arc, etc.) without bridging to a C reference count.


Stream and Event Synchronisation

The sync_mode field at offset 13 of the buffer handle declares how the producer has ordered its device-side writes with respect to the moment of handoff. The release callback (see § Release Callback) governs the end of consumer access; sync_mode governs the start. The two contracts are independent and apply symmetrically.

sync_mode Values

ValueNameMeaning
0x00SYNC_PRODUCER_SYNCEDThe producer has issued a host-side wait on the device stream(s) that wrote the buffer, ensuring all preceding device-side writes have completed before handoff. The consumer MAY access the buffer immediately on any stream.
0x01SYNC_EVENTThe producer has recorded a device event on the stream(s) that wrote the buffer. The consumer MUST retrieve the producer's event handle via the C ABI (see docs/impl/c-ffi.md) and MUST issue a device-stream-wait on it on every stream that will access the buffer before enqueuing any work that touches the buffer. The consumer MUST release the event handle exactly once via the event-release callback defined in docs/impl/c-ffi.md.
0x02SYNC_CONSUMER_STREAMThe consumer declared its target stream at handoff time via the C ABI; the producer has issued a device-side ordering dependency from its writing stream(s) onto the consumer's declared stream(s). The consumer MAY access the buffer on the stream(s) it declared, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait.
0x03–0xFE(reserved)Reserved for future specification versions. Readers MUST reject a buffer handle whose sync_mode is in this range.
0xFF(invalid)Reserved. Readers MUST reject a buffer handle whose sync_mode is 0xFF.

A reader MUST reject a buffer handle whose sync_mode value is not one of the values defined for the format version it implements.

Producer Requirement

A producer of a non-CPU buffer (device_tag != 0x00) MUST ensure that, at the instant ownership of the buffer is transferred to the consumer, all device-side writes enqueued by the producer that affect the buffer's bytes have reached a point at which a properly-synchronised consumer access on the same device will observe them. The producer MUST satisfy this requirement by exactly one of the three mechanisms enumerated above, and the chosen mechanism MUST be declared in the buffer handle's sync_mode field.

For a CPU buffer (device_tag == 0x00), sync_mode MUST be SYNC_PRODUCER_SYNCED (0x00). When concurrent host-side writes exist, the producer MUST additionally issue a host memory fence (a release-store or equivalent) before handoff so that all preceding host writes are visible to the consumer's subsequent loads.

Consumer Requirement

A consumer that has received a buffer handle MUST inspect the sync_mode field and apply the matching rule before accessing the buffer's bytes:

  • If sync_mode == SYNC_PRODUCER_SYNCED, the consumer MAY access the buffer immediately on any stream.
  • If sync_mode == SYNC_EVENT, the consumer MUST retrieve the producer's event handle from the C ABI handoff structure and MUST issue a device-stream-wait on it on every stream that will access the buffer before enqueuing any work that touches the buffer. The consumer MUST release the event handle exactly once via the event-release callback.
  • If sync_mode == SYNC_CONSUMER_STREAM, the consumer MAY access the buffer on the stream(s) it declared at handoff time, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait.

A consumer that does not recognise the declared sync_mode value MUST reject the descriptor.

Per-Transport Constraints

The set of sync_mode values that are valid depends on the interchange transport (see interchange.md):

  • In-process. All three sync_mode values are valid. Event and stream handles are exchanged out of band via the C ABI; both parties share the same driver context.
  • IPC (same machine, different processes). SYNC_PRODUCER_SYNCED is always valid. SYNC_EVENT is valid only if the device supports IPC-exportable events (e.g., CUDA cudaIpcEventHandle_t, ROCm hipIpcEventHandle_t). SYNC_CONSUMER_STREAM is valid only if the device supports IPC-exportable streams. When neither SYNC_EVENT nor SYNC_CONSUMER_STREAM is available on the underlying device, the producer MUST use SYNC_PRODUCER_SYNCED or fall back to a host-staged copy.
  • Cross-machine (network transport). sync_mode MUST be SYNC_PRODUCER_SYNCED for every buffer handle transmitted over a network transport. SYNC_EVENT and SYNC_CONSUMER_STREAM are FORBIDDEN across machines because device event and stream handles are not valid in a different driver context on a different host. A receiver MUST reject a cross-machine TENSOR_DESCRIPTOR whose buffer handle declares any other mode. See interchange.md § RDMA Data Plane for how TENSOR_DATA_END serves as the cross-machine equivalent of SYNC_PRODUCER_SYNCED.

Relationship to the Release Callback

The sync_mode contract governs the start of consumer access; the release callback (see § Release Callback) governs the end of consumer access. The two are independent normative contracts that occupy symmetric positions at the bookends of the consumer's hold.

The event-release callback used in SYNC_EVENT mode is separate from the buffer-release callback. A consumer in SYNC_EVENT mode therefore makes two release calls per buffer: one for the event handle, called after the consumer has issued its stream-wait (typically immediately after handoff), and one for the buffer, called after all device work on the buffer is complete. Conflating the two would force the producer to keep the event alive for the buffer's entire lifetime, defeating the purpose of using events instead of full stream synchronisation.

A consumer that has issued device work using the buffer MUST NOT call the buffer-release callback until that device work has completed on the device. The consumer is free to satisfy this by host-side waiting, by recording its own completion event and waiting on it, or by deferring the release callback to a completion callback registered on its stream.

C ABI Note

The opaque event handle (for SYNC_EVENT) and the consumer stream handle (for SYNC_CONSUMER_STREAM) are NOT carried in the binary descriptor. They are exchanged out of band via the C ABI. See docs/impl/c-ffi.md for the per-mode handoff payload definitions and the ABI-side cross-check that the payload provided at handoff time matches the sync_mode declared in the descriptor.

Note (non-normative): sync_mode is a declaration of a buffer's synchronisation properties; the synchronisation handle is a transport detail, not a buffer property. Putting the discriminant in the binary descriptor lets a static inspector (hurray-inspect) surface the synchronisation contract for any buffer without reaching into the C ABI layer.


Zero-Copy Invariants

The buffer protocol is designed to preserve zero-copy access across language and runtime boundaries. The following invariants MUST hold at all times:

  1. No implicit copies. Neither the producer nor the consumer MAY copy the buffer contents as part of the handoff. Copies are only permitted when explicitly requested by the interchange protocol (e.g., layout transcoding in response to a TENSOR_REQUEST that specifies a different layout tag).
  2. No in-place mutation after handoff. Once a producer has handed off a buffer to a consumer, the producer MUST NOT modify the buffer's contents. Mutating a buffer that is held by a consumer is a protocol error.
  3. Quantization parameter buffers are immutable. Scale and zero-point buffers MUST NOT be modified after the tensor descriptor is emitted. They are part of the tensor's logical value and MUST be treated as read-only by all consumers.
  4. Pointer stability. The base address of a buffer MUST NOT change for the duration of the consumer's hold. Buffer defragmentation or garbage collection that moves the buffer is the producer's responsibility to prevent while any consumer holds a reference.

Relationship to Other Sections

  • metadata.md defines the binary encoding of buffer handles in the buffer table. The alignment and device_tag fields are declared there; this file defines the normative rules they must satisfy.
  • data-model.md defines empty tensors (zero-size dimensions). This file specifies the corresponding empty-buffer rules (null pointer allowed, alignment waived, no dereference).
  • quantization.md defines quantization-parameter buffers. This file's device-colocation and immutability rules apply to those buffers.
  • interchange.md defines in-process, IPC, and cross-machine transport. This file's alignment and ownership rules are prerequisites for all three transport modes.
  • docs/impl/c-ffi.md defines the C ABI for buffer handle handoff, including the release callback signature.

Open Questions

All open questions in this section are resolved. See docs/adr/ADR-009-release-callback-not-normative-refcount.md (OQ-1) and docs/adr/ADR-020-memory-class-field.md (memory class field, device colocation extension, and supported_memory_classes interchange advertisement).

Metadata — Hurray Format Specification

Status: Draft

Scope

This section defines the binary encoding of the tensor descriptor: the self-describing header that precedes every tensor data buffer in the Hurray format. The tensor descriptor encodes all information required to interpret a tensor: its element type, rank, shape, memory layout, buffer table, and optional quantization and shard annotations.

Note (non-normative): The tensor descriptor is designed to be self-delimiting: a receiver can determine its total byte length from the first 10 bytes (the descriptor_length field occupies bytes 6–9) and MAY skip the descriptor entirely without parsing any layout-specific fields. Skipping past the whole descriptor is what only requires the first 10 bytes; locating or skipping a particular section within the descriptor (e.g. the buffer table or the quantization section) requires reading through the preceding fields. This property is essential for streaming readers.

Normative Requirements

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


Overall Descriptor Structure

A tensor descriptor consists of a fixed header, followed by variable-length core fields, followed by layout-specific fields, followed by a buffer table, followed by zero or more optional sections selected by the flags field.

[Fixed Header]         20 bytes
[shape]                8 × rank bytes
[byte_offset]          8 bytes
[Layout-specific]      variable (layout-dependent)
[Buffer Table]         variable
[Quantization]         variable, present if HAS_QUANTIZATION flag is set
[Shard]                variable, present if HAS_SHARD flag is set
[Statistics]           72 bytes, present if HAS_STATISTICS flag is set
[Extension Type]       20 bytes, present if HAS_EXTENSION_TYPE flag is set
[Composite Member]     16 bytes, present if HAS_COMPOSITE_MEMBER flag is set

All multi-byte fields MUST be encoded in little-endian byte order (least significant byte at the lowest address).


Fixed Header

The fixed header occupies the first 20 bytes of every tensor descriptor.

OffsetFieldTypeDescription
0magicbytes[4]Magic bytes: 0x48 0x52 0x52 0x59 (ASCII "HRRY").
4version_majoruint8Major format version. Current value: 0x01.
5version_minoruint8Minor format version. Current value: 0x00.
6descriptor_lengthuint32Total length of the tensor descriptor in bytes, including the fixed header. A reader MUST use this field to advance past the descriptor without parsing all fields.
10flagsuint32Descriptor flags bitmask (see Flags).
14type_taguint8Element type tag (see element-types.md).
15layout_taguint8Memory layout tag (see memory-layout.md). See data-model.md § Scalar Tensors for layout restrictions that apply when rank = 0.
16rankuint32Number of dimensions. A rank of 0 denotes a scalar tensor.

A reader MUST reject a descriptor whose magic field is not 0x48 0x52 0x52 0x59.

A reader MUST reject a descriptor whose version_major exceeds the reader's supported major version.

A reader MUST reject a descriptor whose descriptor_length is less than 20 (the minimum valid descriptor size).

A reader MUST NOT read beyond descriptor_length bytes when parsing a descriptor.

Flags

BitNameMeaning
0HAS_QUANTIZATIONA quantization descriptor section is present (see Quantization Section).
1HAS_SHARDA shard descriptor section is present (see Shard Section).
2HAS_EXTENSION_TYPEAn extension type descriptor section is present. MUST be set if and only if type_tag is in the range 0xF0–0xFE (see Extension Type Section).
3HAS_STATISTICSA statistics section is present (see Statistics Section).
4HAS_COMPOSITE_MEMBERA composite member section is present (see Composite Member Section). Set on the members of an overlay composite; see layouts/composite.md.
5–31(reserved)MUST be 0. A reader MUST reject a descriptor with any reserved flag bit set.

Core Variable Fields

Immediately following the fixed header:

shape

uint64[rank] — the size of each dimension, in ascending dimension order (dimension 0 first). Each value MUST be greater than or equal to 0. A dimension size of 0 indicates an empty tensor.

The value 0xFFFFFFFFFFFFFFFF (UINT64_MAX) is the dynamic dimension sentinel: it indicates that the dimension's size is not statically known. A reader MUST NOT compute buffer sizes, strides, or element counts for a dimension carrying this sentinel without first resolving it to a concrete value.

For a scalar tensor (rank = 0), this field is absent (zero bytes).

byte_offset

uint64 — the byte offset from the start of buffer 0 in the buffer table to the element at logical index [0, 0, ..., 0]. MUST be less than or equal to the byte size of buffer 0.

For sub-byte types (bool, int4, uint4, int2, uint2), byte_offset MUST point to a byte boundary. The first element begins at bit 0 of that byte.


Layout-Specific Fields

Immediately following byte_offset, the layout-specific fields for the layout identified by layout_tag are encoded. If the layout has no additional fields, this section is absent.

Row-Major (0x01) and Column-Major (0x02)

No additional fields. Strides are implicit and computed as defined in memory-layout.md.

Strided (0x03)

FieldTypeDescription
stridesint64[rank]Stride of each dimension in logical elements.

Tiled / Blocked (0x04)

FieldTypeDescription
tile_shapeuint64[rank]Tile size along each dimension. Every value MUST be greater than 0.
outer_layoutuint8Layout tag for tile-grid ordering. MUST be 0x01, 0x02, or 0x03.
inner_layoutuint8Layout tag for element ordering within each tile. MUST be 0x01, 0x02, 0x03, or 0x04 (recursive tiling).
_reserveduint8[2]MUST be 0x00.

If outer_layout is 0x03 (strided):

FieldTypeDescription
outer_stridesint64[rank]Outer strides in units of tiles (not elements).

If inner_layout is 0x03 (strided):

FieldTypeDescription
inner_stridesint64[rank]Inner strides in logical elements within a tile.

If inner_layout is 0x04 (recursive tiling), the tiled layout-specific fields are encoded recursively at this point, beginning with tile_shape.

A reader MUST enforce a maximum recursion depth for nested tiled descriptors. The RECOMMENDED limit is 8 levels. A reader MUST reject a descriptor that exceeds its configured recursion limit.

Morton (Z-Order Curve) (0x05)

FieldTypeDescription
morton_bitsuint32[rank]Number of bits used per dimension in the Morton encoding. Each value MUST be greater than 0.

COO (0x06)

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse tensor.
is_sorteduint80x01 if the non-zeros are sorted in lexicographic index order (dimension 0 major); 0x00 otherwise.
_reserveduint8[7]MUST be 0x00.

See layouts/coo.md for buffer table composition, storage order, and validity constraints.

CSR (0x07)

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix.
_reserveduint8[8]MUST be 0x00.

See layouts/csr.md for buffer table composition, storage invariants, and rank-2 restriction.

CSC (0x08)

FieldTypeDescription
nnzuint64Number of stored (non-zero) elements. MAY be 0 for an empty sparse matrix.
_reserveduint8[8]MUST be 0x00.

See layouts/csc.md for buffer table composition, storage invariants, and rank-2 restriction.

Hilbert Curve (0x40)

FieldTypeDescription
hilbert_orderuint32Order of the Hilbert curve. MUST be greater than 0.
hilbert_rankuint32Number of curve dimensions. MUST equal rank. MUST be greater than or equal to 2.

Composite / Virtual (0x0B)

The composite head is a virtual (data-less) descriptor. Its layout-specific fields encode the composition rule. All multi-byte fields are little-endian.

FieldTypeDescription
composition_ruleuint80x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF reserved; 0xF0–0xFE private; 0xFF invalid.
combine_opuint8Overlay only: 0x01 replace, 0x02 add. MUST be 0x00 for partition and group.
_reserveduint8[2]MUST be 0x00.
member_countuint32Number of member tensors that immediately follow the head. MUST be a definite count. The value 0xFFFFFFFF (open composite) is RESERVED; a strict reader MUST reject it.

A composite head MUST have buffer_count = 0x00 and byte_offset = 0x0000000000000000. See layouts/composite.md for members, binding, composition semantics, and validation.

Extension Layouts (0xF0–0xFE)

FieldTypeDescription
extension_layout_iduint64Implementation-defined layout identifier.
extension_data_lengthuint32Byte length of the opaque metadata that follows.
extension_databytes[extension_data_length]Opaque layout-specific metadata.

A reader that does not recognise extension_layout_id MUST reject the descriptor, unless operating in permissive mode.


Buffer Table

Immediately following the layout-specific fields, the buffer table is encoded.

FieldTypeDescription
buffer_countuint8Number of buffer handles. MUST be at least 1 for every layout except the composite head (layout_tag = 0x0B), for which it MUST be exactly 0x00 (a composite head owns no data; see layouts/composite.md). For dense layout tags (0x01–0x05, 0x40) without quantization, MUST be exactly 0x01. For quantized dense tensors, MUST equal 0x01 plus the number of quantization-parameter buffers required by the active scheme (see quantization.md § Buffer Table Placement Rules).

The maximum value is 255, imposed by the uint8 wire type. This limit applies to the sum of data and quantization-parameter buffers. Implementations that require more than 255 buffers MUST use multiple tensor descriptors.

Followed by buffer_count buffer handles, each encoded as 16 bytes:

OffsetFieldTypeDescription
0byte_sizeuint64Size of the buffer in bytes.
8alignmentuint32Minimum buffer alignment in bytes. MUST be a power of two and MUST be at least 64.
12device_taguint8Device where this buffer resides (see buffer-protocol.md and Device Tags in interchange.md).
13_reserveduint8[3]MUST be 0x00.

The _reserved bytes MUST be 0x00. A conforming reader in strict mode MUST reject a descriptor containing any buffer handle whose _reserved bytes are not all 0x00.

Note (non-normative): For sparse layout tags (COO 0x06, CSR 0x07, CSC 0x08, CSF 0x09), buffer_count exceeds 1 — each entry holds a distinct component array (values, indices, pointers). For CSF the count is rank-dependent, 2·rank + 1 (one values buffer plus a pos/crd pair per level). For quantized dense tensors, quantization-parameter buffers (scales, zero-points) extend the buffer table beyond the layout baseline. The layout-defined minimum is always 0x01 for dense layouts; quantization schemes append their parameter buffers on top.


Quantization Section

Present if and only if the HAS_QUANTIZATION flag (bit 0) is set.

FieldTypeDescription
quantization_lengthuint32Byte length of the quantization descriptor that follows.
quantization_descriptorbytes[quantization_length]Binary encoding of the quantization descriptor, as defined in quantization.md.

A reader that encounters HAS_QUANTIZATION but does not support quantized types MUST reject the descriptor unless operating in permissive mode.


Shard Section

Present if and only if the HAS_SHARD flag (bit 1) is set.

FieldTypeDescription
parent_shapeuint64[rank]Shape of the logical parent tensor. MUST have the same rank as the tensor.
shard_offsetuint64[rank]Starting index of this shard within the parent tensor along each dimension.

The constraint shard_offset[k] + shape[k] <= parent_shape[k] MUST hold for every dimension k. A reader MUST reject a shard descriptor that violates this constraint.


Statistics Section

Present if and only if the HAS_STATISTICS flag (bit 3) is set.

The statistics section is a fixed-size 72-byte block. All statistics are advisory: they reflect the tensor data at write time. A reader MUST NOT rely on any statistic for correctness; statistics MAY be used only as optimization hints (algorithm selection, memory pre-allocation, routing decisions).

Note (non-normative): A streaming writer that has not processed the entire tensor buffer before emitting the descriptor (e.g., a pipeline stage forwarding data on the fly) MUST omit the statistics section (HAS_STATISTICS not set) rather than emitting invalid statistics. A writer that knows only a subset of statistics (e.g., nnz is known from a sparse format but value_mean was not computed) MUST mark the unknown fields as not valid in computed_mask.

computed_mask

The computed_mask field (first 4 bytes of the section) is a bitmask indicating which statistics fields contain valid data. A reader MUST check the relevant bit before using any field. Fields whose bit is not set MUST be treated as unknown, regardless of their encoded value.

BitNameCovers
0NNZ_VALIDnnz
1SPARSITY_VALIDsparsity_ratio
2VALUE_RANGE_VALIDvalue_min, value_max, value_abs_max
3VALUE_STATS_VALIDvalue_mean, value_stddev
4NM_SPARSITY_VALIDnm_n, nm_m
5NAN_INF_VALIDhas_nan, has_inf
6–31(reserved)MUST be 0.

A conforming reader MUST reject a descriptor whose computed_mask has any reserved bit set (any bit greater than or equal to 6, given the six defined statistics fields above).

Field Encoding

The 72-byte statistics block is encoded as follows. All multi-byte fields are little-endian.

OffsetFieldTypeDescription
0computed_maskuint32Validity bitmask (see above).
4_reserveduint32MUST be 0x00000000.
8nnzuint64Number of non-zero elements. Valid when NNZ_VALID is set.
16sparsity_ratiofloat64Fraction of zero elements: (total_elements - nnz) / total_elements. Range [0.0, 1.0]. Valid when SPARSITY_VALID is set.
24value_minfloat64Minimum element value, dequantized to float64. Valid when VALUE_RANGE_VALID is set.
32value_maxfloat64Maximum element value, dequantized to float64. Valid when VALUE_RANGE_VALID is set.
40value_abs_maxfloat64Maximum absolute element value (max(abs(value_min), abs(value_max))). Key input for symmetric quantization range calibration. Valid when VALUE_RANGE_VALID is set.
48value_meanfloat64Arithmetic mean of all elements, dequantized to float64. Valid when VALUE_STATS_VALID is set.
56value_stddevfloat64Population standard deviation of all elements, dequantized to float64. MUST be greater than or equal to 0.0. Valid when VALUE_STATS_VALID is set.
64nm_nuint8N in the N:M structured sparsity pattern (e.g., 2 for 2:4 sparsity). 0x00 if not applicable. Valid when NM_SPARSITY_VALID is set.
65nm_muint8M in the N:M structured sparsity pattern (e.g., 4 for 2:4 sparsity). MUST satisfy nm_n <= nm_m. 0x00 if not applicable. Valid when NM_SPARSITY_VALID is set.
66has_nanuint80x01 if at least one NaN element is present; 0x00 if no NaN was found. Valid when NAN_INF_VALID is set.
67has_infuint80x01 if at least one positive or negative infinity is present; 0x00 otherwise. Valid when NAN_INF_VALID is set.
68_reserved2uint8[4]MUST be 0x00.

Note (non-normative): value_min, value_max, value_abs_max, value_mean, and value_stddev are always expressed in float64 regardless of the tensor's element type. For quantized tensors, these values reflect dequantized (real-valued) statistics, not the raw quantized storage values. For bool types, the statistics are defined over the integer domain {0, 1}.

Note (non-normative): N:M structured sparsity is particularly relevant for NVIDIA Ampere/Ada/Hopper Tensor Cores, which provide hardware-accelerated 2:4 sparsity (2 non-zeros in every group of 4 consecutive elements). Declaring the N:M pattern in the descriptor lets a receiver select the sparse kernel path without scanning the buffer.


Extension Type Section

Present if and only if the HAS_EXTENSION_TYPE flag (bit 2) is set. This flag MUST be set whenever type_tag is in the range 0xF0–0xFE, and MUST NOT be set otherwise.

Per ADR-001, extension type tags MUST carry an inline descriptor providing at minimum the bit width and packing parameters required to compute buffer sizes.

The extension type descriptor is 20 bytes:

OffsetFieldTypeDescription
0bit_widthuint32Bit width of one element. MUST be greater than 0.
4packing_factoruint8Number of elements packed per byte. MUST be exactly 1 when bit_width is greater than or equal to 8. When bit_width is less than 8, bit_width MUST be one of 1, 2, or 4, and packing_factor MUST equal 8 / bit_width (that is, 8, 4, or 2 respectively). All other sub-byte widths — including but not limited to 3, 5, 6, and 7 bits — MUST NOT be encoded as extension types. A reader MUST reject an extension type descriptor that violates these constraints.
5is_floatuint80x01 if floating-point, 0x00 if integer.
6is_signeduint80x01 if signed integer. MUST be 0x00 for float types.
7sign_bitsuint8Number of sign bits (for float types). MUST be 0 or 1. MUST be 0x00 for integer types.
8exponent_bitsuint8Number of exponent bits (for float types). MUST be 0x00 for integer types.
9mantissa_bitsuint8Number of mantissa bits (for float types). MUST be 0x00 for integer types.
10_reserveduint8[2]MUST be 0x00.
12exponent_biasuint32Exponent bias (for float types). MUST be 0x00000000 for integer types.
16has_nanuint80x01 if NaN is representable (float types only).
17has_infuint80x01 if infinity is representable (float types only).
18_reserved2uint8[2]MUST be 0x00.

Note (non-normative): The sign of a float extension type is carried by sign_bits; is_signed applies to integer types only. Requiring is_signed to be 0x00 for float types does not mean float extension types are unsigned — it means a reader has exactly one field to consult. A signed float sets is_float = 0x01 and sign_bits = 0x01; an unsigned float, such as a private analogue of the exponent-only float8_e8m0 (0x42), sets is_float = 0x01 and sign_bits = 0x00.

Sub-byte element widths that are not a power of two (notably 6-bit) are reserved to the built-in type tag space. Implementors requiring an interchange-portable non-power-of-two sub-byte type MUST request a built-in tag assignment through the specification governance process rather than encoding the type in the private extension range. The extension descriptor's whole-byte and power-of-two sub-byte width restriction ensures that buffer-size computation remains a single integer formula (ceil(N / packing_factor) for sub-byte, N * (bit_width / 8) for whole-byte) without rational arithmetic.

Note (non-normative): The 6-bit float6_e2m3 (0x44) and float6_e3m2 (0x45) types are built-in (Tier 2) and use a dedicated 4-elements-per-3-bytes packing defined in element-types.md. Their packing rule is not expressible as 8 / bit_width and is therefore not delegable to the generic extension descriptor. The extension descriptor is designed for private, implementation-defined types whose layout fits the simple "elements per byte" model; richer packings remain the prerogative of the standardized type system.

A reader MUST use bit_width and packing_factor to compute buffer sizes for tensors with extension type tags, even if it does not interpret the numeric semantics of the type.


Composite Member Section

Present if and only if the HAS_COMPOSITE_MEMBER flag (bit 4) is set. It appears after the Extension Type section. The section is a fixed-size 16-byte block. All multi-byte fields are little-endian.

OffsetFieldTypeDescription
0member_roleuint80x00 correction, 0x01 base. 0x02–0xFF RESERVED (a future tombstone kind; see layouts/composite.md § Deferred).
1_reserveduint8[15]MUST be 0x00.

A reader MUST reject a Composite Member section whose member_role is not 0x00 or 0x01, or whose _reserved bytes are not all 0x00.

This section is carried by the members of an overlay composite (composition_rule = 0x02). Partition and group members MUST NOT set HAS_COMPOSITE_MEMBER. The full semantics — base-vs-correction roles, precedence, and the combine operation — are defined in layouts/composite.md.

Note (non-normative): v1.0 uses member_role only. The 15 reserved bytes hold room for a future member_version field (versioned overlay), added additively without reallocating the section. See layouts/composite.md § Deferred.


Version Compatibility

A reader MUST reject a descriptor whose version_major exceeds the reader's supported major version.

A reader encountering a version_minor greater than its supported minor version SHOULD accept the descriptor but MUST NOT interpret fields beyond what its supported minor version defines. The descriptor_length field allows the reader to skip the descriptor entirely if desired.

Note (non-normative): Minor version increments add optional fields or new flag bits. A reader built against version 1.0 will correctly skip a 1.1 descriptor by consuming exactly descriptor_length bytes, because all reserved flag bits were required to be 0 in 1.0. Major version increments signal backward-incompatible changes; a reader MUST NOT attempt to parse a descriptor with an unsupported major version.


Worked Example

A rank-2 float32 tensor with shape [3, 4] in row-major layout, one buffer of 192 bytes aligned to 64 bytes on CPU, no optional sections:

Offset  Value (hex)                   Field
------  ----------------------------  -----
0       48 52 52 59                   magic = "HRRY"
4       01                            version_major = 1
5       00                            version_minor = 0
6       3D 00 00 00                   descriptor_length = 61
10      00 00 00 00                   flags = 0x00000000 (no optional sections)
14      03                            type_tag = 0x03 (float32)
15      01                            layout_tag = 0x01 (row-major)
16      02 00 00 00                   rank = 2
20      03 00 00 00 00 00 00 00       shape[0] = 3
28      04 00 00 00 00 00 00 00       shape[1] = 4
36      00 00 00 00 00 00 00 00       byte_offset = 0
                                      (no layout-specific fields for row-major)
44      01                            buffer_count = 1
45      C0 00 00 00 00 00 00 00       buffer[0].byte_size = 192
53      40 00 00 00                   buffer[0].alignment = 64
57      00                            buffer[0].device_tag = 0x00 (CPU)
58      00 00 00                      buffer[0]._reserved

Total: 61 bytes. Fixed header (20) + shape (16) + byte_offset (8) + buffer table (1 + 16) = 61.


Interaction with Other Sections

  • Element Types (element-types.md): defines type_tag values and the bit-width, packing, and alignment properties used during buffer size computation.
  • Memory Layout (memory-layout.md): defines layout_tag values and the layout-specific fields encoded in this descriptor.
  • Quantization (quantization.md): defines the binary format of the quantization_descriptor payload in the quantization section.
  • Buffer Protocol (buffer-protocol.md): defines buffer ownership, device memory semantics, and release callback conventions referenced by the buffer table entries.
  • Interchange (interchange.md): the tensor descriptor defined here is transmitted verbatim in TENSOR_DESCRIPTOR and TENSOR_PUT message payloads, followed by transport-specific fields (total_data_bytes, shard_index, total_shards).

Open Questions

[OQ-1]: Should the descriptor include a CRC-32 checksum field? Resolved: No checksum in the descriptor. Integrity is delegated to the transport/storage layer (TCP, TLS, ECC, ZFS). Adding 4 bytes and a full-pass CRC on every descriptor would penalise in-process and IPC interchange where corruption is not a realistic threat. If file-level integrity is needed, it belongs in the file format footer (see file-format.md OQ-3).

[OQ-2]: The binary encoding of the quantization descriptor is deferred to quantization.md. Resolved: The encoding is fully defined in quantization.md: a fixed 4-byte header (scheme_tag, scheme_version, flags) followed by a per-scheme payload. Complete byte-level layouts are specified in quantization/per-tensor-affine.md, quantization/per-channel-affine.md, quantization/per-block-affine.md, quantization/nf4.md, and quantization/mxfp.md. The quantization_length prefix in metadata.md allows readers to skip unrecognised schemes safely.

Interchange — Hurray Format Specification

Status: Draft

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

Scope

This section defines how Hurray tensors are exchanged between producers and consumers. Three interchange modes are in scope:

ModeDescription
In-processShared memory within a single address space; zero-copy by pointer passing
IPCCross-process on a single machine; shared memory segments or Unix domain sockets
Network transportCross-machine over a network interface; client-server streaming protocol

This section focuses primarily on the network transport mode, as it is the most complex and the most relevant to distributed inference pipelines. In-process and IPC modes are covered in In-Process and IPC.

A Hurray stream MAY contain zero or more tensors. Back-to-back concatenation of self-delimiting descriptor+data pairs is the canonical multi-tensor encoding: a reader processes one tensor at a time, advancing to the next descriptor after the current tensor's data is consumed. No container header, index, or tensor names are defined at this level (see docs/adr/ADR-010-multi-tensor-collections-deferred.md).

Note (non-normative): The design of the Hurray network transport protocol draws inspiration from Apache Arrow Flight, but differs in several key respects: it is tensor-focused (not columnar), it supports layout and device negotiation, it defines on-the-fly transcoding, and its data plane is designed for large buffer transfers where gRPC framing overhead is impractical.


In-Process and IPC

In-Process

Within a single address space, tensor interchange is accomplished by passing a tensor descriptor (see metadata.md) and a buffer handle (see buffer-protocol.md) by value. No serialization is required. The buffer handle carries a release callback (see buffer-protocol.md § Buffer Ownership and Lifetime and ADR-009); the receiver MUST retain the handle for the duration of its use and call the release callback exactly once when done.

IPC

For cross-process interchange on a single machine, two mechanisms are supported:

  1. Shared memory: the producer maps a shared memory segment and places the data buffer there. The tensor descriptor is transmitted over any IPC channel (pipe, Unix domain socket, etc.). The byte_offset field in the descriptor identifies the buffer's position within the shared segment.
  2. Unix domain socket streaming: the producer serializes the tensor using the network transport framing defined below, transmitted over a Unix domain socket. This is slower than shared memory but requires no shared-memory setup.

In both cases, buffer alignment requirements from buffer-protocol.md apply.


Network Transport Protocol

Overview

The Hurray network transport protocol is a client-server streaming protocol for transferring tensor data over a reliable, ordered byte stream (e.g., TCP). It consists of:

  • A control plane: session establishment, capability negotiation, tensor requests, and error signalling. Uses message framing defined in this section.
  • A data plane: tensor descriptor and buffer transmission. Uses the same message framing, but data frame payloads MAY be transferred over a separate high-throughput channel (e.g., RDMA) by prior agreement during session establishment.

The protocol is half-duplex per stream: within a single stream, the client sends a request and the server responds with a sequence of messages. Multiple streams MAY be multiplexed over a single connection.

Note (non-normative): The stream_id field is an opaque per-stream identifier. Implementations MAY use one TCP connection per stream, or MAY multiplex multiple streams over a single connection by demultiplexing on stream_id. Messages are self-framing (fixed-size header + payload_length bytes), so a receiver can always advance to the next message regardless of stream_id. Normative multiplexing rules are not defined in this version of the spec.

Message Framing

Every message on the wire consists of a message header followed by a payload. All multi-byte fields are little-endian.

Message header (12 bytes):

OffsetFieldTypeDescription
0message_typeuint32Message type tag (see Message Types)
4stream_iduint32Stream identifier. 0x00000000 is reserved.
8payload_lengthuint32Length of the payload in bytes, not including the header.

The payload follows immediately after the header. A receiver MUST read exactly payload_length bytes as the payload. A receiver MUST reject messages whose payload_length exceeds the receiver's configured maximum message size, and MUST send an ERROR message in response.

Message Types

TagNameDirectionDescription
0x00000001CLIENT_HELLOClient → ServerSession initiation and capability advertisement
0x00000002SERVER_HELLOServer → ClientSession acceptance and capability advertisement
0x00000003TENSOR_REQUESTClient → ServerRequest a tensor by key, with layout and device preferences
0x00000004TENSOR_DESCRIPTORServer → ClientTensor descriptor, precedes data frames
0x00000005TENSOR_DATAServer → ClientTensor data frame (partial or complete)
0x00000006TENSOR_DATA_ENDServer → ClientSignals that all data frames for a tensor have been sent
0x00000007TENSOR_PUTClient → ServerPush a tensor to the server (descriptor + data)
0x00000008TENSOR_PUT_ACKServer → ClientAcknowledges receipt of a pushed tensor
0x00000009ERROREitherError response; terminates the stream
0x0000000APINGEitherKeepalive request
0x0000000BPONGEitherKeepalive response
0x0000000CRDMA_REGISTEREitherRDMA memory region registration: shares rkey and remote address
0x0000000DRDMA_READYEitherAcknowledges RDMA_REGISTER; signals readiness for the RDMA transfer
0x000000F0–0x000000FE(private extension)—Reserved for implementation-private extensions
0x000000FF(invalid)—Reserved; MUST NOT be used

A receiver that encounters an unrecognised message_type MUST send an ERROR message and close the stream.

Message type values in the range 0x000000F0–0x000000FE are reserved for implementation-private extensions. Implementations MAY use these values for private extension message types; messages using private extension types MUST NOT be sent to a peer that has not agreed to the extension out of band. The value 0x000000FF is reserved and MUST NOT be used.


Session Establishment

CLIENT_HELLO Payload

FieldTypeDescription
protocol_versionuint32Protocol version. Current version: 0x00000001.
max_message_sizeuint32Maximum payload size (bytes) the client will accept.
capability_flagsuint64Bitmask of client capabilities (see Capability Flags).
supported_layoutslayout_entry[layout_count]Layout entries the client can consume, in preference order (most preferred first). Preceded by a uint16 count field. Each entry is encoded as defined in Layout Entry Encoding.
supported_devicesuint8[device_count]Device tags the client can accept (see Device Tags). Preceded by a uint16 count field.
supported_memory_classesuint8[class_count]Memory class values the client can accept (see buffer-protocol.md § Memory Class). Preceded by a uint16 count field. If absent or empty, the client is assumed to support STANDARD (0x00) only.

SERVER_HELLO Payload

FieldTypeDescription
protocol_versionuint32Protocol version the server will use. MUST be <= the client's version.
max_message_sizeuint32Maximum payload size (bytes) the server will accept.
capability_flagsuint64Bitmask of server capabilities.
supported_layoutslayout_entry[layout_count]Layout entries the server can produce (possibly via transcoding). Preceded by a uint16 count field. Each entry is encoded as defined in Layout Entry Encoding.
supported_devicesuint8[device_count]Device tags the server can target. Preceded by a uint16 count field.
supported_memory_classesuint8[class_count]Memory class values the server can produce. Preceded by a uint16 count field. If absent or empty, the server is assumed to support STANDARD (0x00) only.

Note (non-normative): supported_memory_classes advertises which memory access classes a peer can produce or consume across all devices. A server that supports CUDA UNIFIED (cudaMallocManaged) and CPU STANDARD would list [0x00, 0x02]. A client that cannot handle UNIFIED buffers lists only [0x00]; the server MUST NOT send a UNIFIED buffer to such a client. Memory class negotiation follows the same fallback logic as device negotiation: the server SHOULD prefer the client's advertised classes and MUST report the actual memory_class in the TENSOR_DESCRIPTOR buffer handle.

A client MUST send a CLIENT_HELLO as the first message on every new connection. The server MUST respond with a SERVER_HELLO before any other message. If the server cannot satisfy the minimum requirements (e.g., protocol version mismatch), it MUST respond with an ERROR message instead and close the connection.

Capability Flags

BitNameMeaning
0TRANSCODINGSender can transcode tensors to a requested layout on the fly
1PARALLEL_STREAMSSender supports multi-stream parallel shard transfer
2RDMA_DATA_PLANESender supports RDMA for the data plane
3RDMA_GPUDIRECTSender supports GPUDirect-style writes into a receiver-registered destination memory region. MUST imply RDMA_DATA_PLANE (bit 2).
4–63(reserved)MUST be 0

Device Tags

The canonical definition of device tags lives in buffer-protocol.md § Device Tags. The table below is reproduced for transport-protocol convenience and MUST stay consistent with buffer-protocol.md.

TagDevice
0x00CPU host memory
0x01CUDA device memory
0x02ROCm device memory
0x03Metal device memory (Apple Silicon unified memory)
0x04Vulkan device memory
0x05WebGPU device memory
0x06Qualcomm Hexagon (HVX/HMX) memory
0x07Intel Level Zero / oneAPI device memory
0x08OpenCL device memory
0x09–0xEFReserved for future specification versions
0xF0–0xFEImplementation-private device types
0xFFReserved (invalid)

Layout Entry Encoding

Layout lists appear in CLIENT_HELLO, SERVER_HELLO, and TENSOR_REQUEST. Each list is preceded by a uint16 count, followed by that many layout entries. A layout entry is variable-length and encoded as follows:

  1. layout_tag (uint8): the layout tag as defined in memory-layout.md.
  2. If layout_tag is in the extension range (0xF0–0xFE):
    • ext_metadata_length (uint16): byte length of the opaque metadata that follows. MAY be 0.
    • ext_metadata (byte sequence): opaque hardware- or implementation-specific metadata of ext_metadata_length bytes.
  3. If layout_tag is not in the extension range, the entry consists of the single layout_tag byte only. No length or metadata fields follow.

A reader MUST skip any extension entry whose ext_metadata it does not understand, using ext_metadata_length to advance past it. A reader MUST NOT reject a layout list solely because it contains unrecognised extension entries.

Note (non-normative): The primary use case for extension layout entries in negotiation is hardware-specific panel/pack formats for BLAS kernels. A client advertising such a format would include an extension tag with opaque metadata encoding its hardware parameters (e.g. panel width, register block dimensions, SIMD width, cache line size). The server either recognises the profile and transcodes accordingly, or skips the entry and falls back to the next preference. The packed buffer travels to the client and is handed directly to the BLAS kernel — it is never forwarded or reinterpreted by generic tensor code.


Layout Negotiation

Request

When sending a TENSOR_REQUEST, the client specifies its layout preferences. The server MUST honor the negotiation rules below.

TENSOR_REQUEST Payload

FieldTypeDescription
tensor_keyutf8 stringIdentifier of the requested tensor. Encoded as a uint32 byte length followed by UTF-8 bytes.
preferred_layoutslayout_entry[layout_count]Layout entries in preference order (most preferred first). Preceded by a uint16 count field. 0 count means no preference. Each entry is encoded as defined in Layout Entry Encoding.
preferred_deviceuint8Preferred device tag for the response buffer.
min_alignmentuint32Minimum buffer alignment (bytes) the client requires. MUST be a power of two.
request_flagsuint32Bitmask of request flags (see below).

Request flags:

BitNameMeaning
0ALLOW_TRANSCODEClient permits the server to transcode to a preferred layout
1PARALLEL_OKClient supports receiving the tensor as multiple parallel shards
2–31(reserved)MUST be 0

Server Layout Selection

Upon receiving a TENSOR_REQUEST, the server MUST select a layout for the response according to the following rules, in order:

  1. If the client supplied a non-empty preferred_layouts list, the server MUST iterate through the list in order and select the first layout tag that satisfies one of: a. The tensor is already stored in that layout (no transcoding needed), or b. ALLOW_TRANSCODE is set and the server has TRANSCODING capability for that layout tag.
  2. If no preferred layout can be satisfied (list exhausted or empty), the server MUST serve the tensor in its native stored layout.

The server MUST indicate the chosen layout in the TENSOR_DESCRIPTOR message. The client MUST be prepared to receive any layout that appeared in the server's supported_layouts advertisement, even if it was not among the client's preferences.

On-the-Fly Transcoding

When the server transcodes a tensor to satisfy a layout preference, the transcoding MUST be element-preserving: the logical tensor (same rank, same shape, same element values at every index) MUST be identical before and after transcoding. Only the memory layout differs.

A server MUST NOT transcode if doing so would require materialising a buffer larger than the server's configured transcoding memory limit. In that case the server MUST fall through to the next preferred layout or the native layout.

Note (non-normative): Transcoding is inherently a memory and compute cost on the server side. Servers SHOULD document their transcoding capabilities and limits. Clients SHOULD list lightweight layouts (e.g. row-major 0x01) later in their preference list as a fallback, rather than requiring the server to transcode into a complex layout first.


Device Negotiation

The preferred_device field of TENSOR_REQUEST drives device selection for the response buffer. Device selection is distinct from layout negotiation: device selection has no ordered preference list on the wire (a single tag is supplied), and the rules below define the server's response when the preferred device cannot be served.

Server Device Selection

Upon receiving a TENSOR_REQUEST, the server MUST select a placement device for the response buffer according to the following rules, in order:

  1. If preferred_device appears in the server's supported_devices list (from SERVER_HELLO) and the server has resources to satisfy it at request time, the server MUST place the response buffer on preferred_device.
  2. If preferred_device == 0x00 (CPU) and the server cannot satisfy CPU, the server MUST send ERROR with error_code = DEVICE_UNAVAILABLE and close the stream. CPU is the universal fallback; if a server cannot satisfy CPU, no silent fallback is meaningful.
  3. If the preferred device is a non-CPU device the server cannot serve, the server MUST send ERROR with error_code = DEVICE_UNAVAILABLE and close the stream. The server MUST NOT silently fall back to a different device.
  4. Exception to rule 3: if preferred_device was advertised by the server in SERVER_HELLO but is transiently unavailable (e.g., out of device memory), the server MAY fall back to CPU (0x00) if and only if the client also advertised CPU in its CLIENT_HELLO supported_devices list. This is the only permitted silent fallback.
  5. The server MUST report the actual placement device in the device_tag field of every buffer handle in TENSOR_DESCRIPTOR. Per buffer-protocol.md § Device Colocation, all buffers of a single tensor MUST share the same device_tag.
  6. A client that receives a TENSOR_DESCRIPTOR whose buffer device_tag differs from its preferred_device MUST be prepared to either accept the placement (if the tag is in its supported_devices list) or close the stream with ERROR.

Note (non-normative): The "no silent fallback" design is motivated by the performance characteristics of inference workloads: a model running on CUDA that silently lands on CPU produces correct output but performance collapses catastrophically (often by orders of magnitude). The narrow CPU-fallback exception in rule 4 is provided only as a graceful degradation path for clients that explicitly opt in by advertising CPU in their supported_devices list.

Note (non-normative): Clients that want graceful CPU fallback should advertise CPU (0x00) in CLIENT_HELLO supported_devices. The DEVICE_UNAVAILABLE error gives the client enough information to retry with a different preferred_device if it has alternatives available.


Streaming: Tensor Descriptor and Data Frames

Ordering Invariant

For every tensor transferred, the server MUST send messages in the following order:

TENSOR_DESCRIPTOR
TENSOR_DATA  (zero or more frames)
TENSOR_DATA_END

A tensor whose total_data_bytes is greater than 0 MUST send one or more TENSOR_DATA frames. A tensor whose total_data_bytes is 0 — an empty tensor (ADR-007) or a composite head, which owns no data — MUST send zero TENSOR_DATA frames: the server sends TENSOR_DESCRIPTOR immediately followed by TENSOR_DATA_END. A receiver MUST accept this frame-free sequence when total_data_bytes = 0.

A receiver MUST NOT attempt to interpret data frames before receiving the TENSOR_DESCRIPTOR. This invariant holds for each shard in a parallel transfer.

TENSOR_DESCRIPTOR Payload

The payload is a serialized tensor descriptor as defined in metadata.md, followed by the following transport-specific fields:

FieldTypeDescription
total_data_bytesuint64Total number of bytes that will follow in TENSOR_DATA frames for this tensor (or shard).
shard_indexuint32Index of this shard in a parallel transfer. 0 for non-parallel transfers.
total_shardsuint32Total number of shards in a parallel transfer. 1 for non-parallel transfers.

The sync_mode field of each buffer handle inside TENSOR_DESCRIPTOR declares the producer's synchronisation guarantee for that buffer. Consumers MUST observe the sync_mode value and apply the matching rule per buffer-protocol.md § Stream and Event Synchronisation before accessing the buffer's bytes.

TENSOR_DATA Payload

FieldTypeDescription
byte_offset_in_bufferuint64Byte offset within the tensor's data buffer where this frame's bytes begin.
databyte sequenceRaw tensor data bytes. Length is payload_length minus 8 (the byte_offset_in_buffer field).

Data frames for a single tensor MUST be sent in ascending byte_offset_in_buffer order with no gaps and no overlaps. The sum of all data frame lengths MUST equal the total_data_bytes declared in the TENSOR_DESCRIPTOR.

TENSOR_DATA_END Payload

The TENSOR_DATA_END message has an empty payload (payload_length = 0). It signals that all data frames for the current tensor (or shard) have been sent on this stream.


Composite Tensor Streaming

A composite tensor (head + members; see layouts/composite.md) is streamed as a forward-adjacency sequence with no back-reference. The head is an ordinary tensor descriptor with layout_tag = 0x0B and buffer_count = 0, so it is sent as a TENSOR_DESCRIPTOR message immediately followed by a TENSOR_DATA_END (an empty data plane — the head owns no buffers). The head's member_count = N binds the next N self-delimiting tensors on the stream, in order, as its members:

TENSOR_DESCRIPTOR        (head, layout_tag = 0x0B, member_count = N)
TENSOR_DATA_END          (head has no data buffers)
  TENSOR_DESCRIPTOR      (member 0)
  TENSOR_DATA  (zero or more frames)
  TENSOR_DATA_END
  ...
  TENSOR_DESCRIPTOR      (member N-1)
  TENSOR_DATA  (zero or more frames)
  TENSOR_DATA_END        (composite "close": the Nth member's TENSOR_DATA_END)

The head's TENSOR_DESCRIPTOR MUST precede its members' descriptors, which MUST precede their data — a forward promise, never a back-reference. The composite closes on the Nth member's TENSOR_DATA_END; at that point a receiver runs the close-time validation for the composition rule (layouts/composite.md § Validation).

A member that is itself a composite head (a nested composite) recursively binds its own members before the enclosing composite's member count advances; the sequence is parsed pre-order, subject to the depth limit in layouts/composite.md § Binding.

A receiver MUST reject a composite whose per-member checks fail (send ERROR and close the stream). A torn composite — the stream ends before all N members' TENSOR_DATA_END messages arrive — is incomplete: a strict receiver MUST reject it; a permissive receiver MAY expose the arrived members as independent shard tensors but MUST NOT present the composite as complete (layouts/composite.md § Validation).

Note (non-normative): The head carries no data, so its total_data_bytes in the TENSOR_DESCRIPTOR transport fields is 0 and no TENSOR_DATA frame is sent for it. Binding is purely positional (member count + stream order); no tensor names or member identifiers are introduced.

Note (non-normative): A composite head therefore sends zero TENSOR_DATA frames — TENSOR_DESCRIPTOR immediately followed by TENSOR_DATA_END — as does any tensor whose total_data_bytes is 0. This is governed by the general rule in § Ordering Invariant ("zero or more frames"); the head is not a special case.


Parallel Transfers

Overview

A tensor MAY be transferred as a set of shards delivered simultaneously over multiple independent streams (e.g., multiple TCP connections or RDMA queue pairs). Each shard is a rectangular sub-region of the logical tensor, described by the shard descriptor mechanism defined in memory-layout.md (fields parent_shape and shard_offset).

The client indicates willingness to receive parallel shards by setting the PARALLEL_OK flag in the TENSOR_REQUEST. The server indicates parallel transfer support via the PARALLEL_STREAMS capability flag in SERVER_HELLO.

Parallel Transfer Flow

  1. The client sends a single TENSOR_REQUEST with PARALLEL_OK set.
  2. The server selects a sharding strategy (number of shards, shard boundaries) and responds on N separate streams, one per shard. Each stream carries an independent TENSOR_DESCRIPTOR → TENSOR_DATA → TENSOR_DATA_END sequence.
  3. Each TENSOR_DESCRIPTOR payload MUST include:
    • A tensor descriptor with a shard descriptor (parent_shape, shard_offset, and the shard's own shape) embedded as defined in metadata.md.
    • shard_index and total_shards fields in the transport header.
  4. The client reassembles the logical tensor by placing each shard at the position indicated by its shard_offset within a buffer sized for parent_shape.

Shard Consistency

All shards of a single tensor MUST share the same:

  • parent_shape
  • element type
  • layout tag (outer layout; the shard's inner layout MAY vary in future extensions)

The union of all shard bounding boxes (defined by shard_offset and shape) MUST exactly cover the full parent_shape without overlap, satisfying the coverage and non-overlap constraints from memory-layout.md.

A client MUST validate shard consistency upon receiving all TENSOR_DESCRIPTOR messages. A client MUST reject a parallel transfer where any shard descriptor violates these constraints.

Note (non-normative): Sharding along the batch dimension (dimension 0) is the simplest and most common case — each shard is a contiguous slice of rows. The protocol does not restrict sharding to any particular dimension or sharding scheme.


RDMA Data Plane

Overview

When both client and server advertise the RDMA_DATA_PLANE capability flag during session establishment, the data plane for individual tensor transfers MAY use RDMA rather than TCP TENSOR_DATA frames. The control plane (TCP) is still used for all session management, descriptor exchange, and completion signalling.

Note (non-normative): The RDMA data plane bypasses TCP framing for the tensor buffer itself. For GB-scale tensors this eliminates CPU copies and TCP serialisation overhead, achieving near-line-rate GPU-to-GPU transfer via GPUDirect RDMA. The underlying RDMA operations are performed by an RDMA library such as UCX (ucp_put_nb / ucp_get_nb) or libibverbs (ibv_post_send). The Hurray protocol specifies the handshake messages; it does not mandate a specific RDMA library.

Handshake Flow (Server → Client Tensor Transfer)

When both parties have advertised RDMA_DATA_PLANE, the server MAY substitute the TENSOR_DATA / TENSOR_DATA_END sequence with an RDMA handshake. The client MUST be prepared to handle either path.

Client                          Server
  |                               |
  |--- TENSOR_REQUEST ----------->|
  |<-- TENSOR_DESCRIPTOR ---------|
  |<-- RDMA_REGISTER -------------|  (server registers source buffer, shares rkey + addr)
  |--- RDMA_REGISTER ------------>|  (client registers destination buffer; only if RDMA_GPUDIRECT)
  |--- RDMA_READY --------------->|  (client is ready; RDMA operation may begin)
  |                               |
  |   [RDMA Write executes outside the TCP control plane]
  |                               |
  |<-- TENSOR_DATA_END -----------|  (server signals that buffer is ready on client side)

The client's RDMA_REGISTER step is OPTIONAL and only occurs when both peers advertised the RDMA_GPUDIRECT capability flag in their respective HELLO messages. See GPUDirect Destination Registration.

The server MUST send TENSOR_DESCRIPTOR before RDMA_REGISTER. The RDMA_REGISTER message MUST be sent on the same stream as the corresponding TENSOR_DESCRIPTOR.

RDMA_REGISTER Payload

The RDMA_REGISTER message is sent in one or two roles per transfer:

  • The source-buffer owner sends RDMA_REGISTER to declare its source memory region. This is the server for TENSOR_REQUEST transfers and the client for TENSOR_PUT transfers.
  • When both peers advertised the RDMA_GPUDIRECT capability flag, the destination-buffer owner MAY also send RDMA_REGISTER to declare a pre-pinned destination memory region. This is the client for TENSOR_REQUEST transfers and the server for TENSOR_PUT transfers. See GPUDirect Destination Registration.

The payload table is identical in both roles; the role is determined by the sender and the position of the message in the handshake sequence.

FieldTypeDescription
remote_addruint64Virtual address of the registered memory region on the sender's side. Little-endian.
lengthuint64Size of the memory region in bytes. MUST equal total_data_bytes from the preceding TENSOR_DESCRIPTOR. Little-endian.
rkeybyte sequenceOpaque RDMA memory key. Encoded as a uint32 byte-length prefix followed by that many bytes. The format is RDMA-library-specific (e.g., a UCX packed rkey blob, or a 4-byte IB verbs rkey).

A receiver that cannot complete RDMA setup (e.g., memory pinning failed, no RDMA hardware available on the required path) MUST respond with an ERROR message instead of RDMA_READY. The source-buffer owner MUST then fall back to transmitting the tensor via TENSOR_DATA frames on the control plane.

GPUDirect Destination Registration

When both peers advertised the RDMA_GPUDIRECT capability flag in their respective HELLO messages, the destination-buffer owner (the receiver) MAY pre-register a destination memory region and share its rkey with the sender via a second RDMA_REGISTER message. This enables the sender to write directly into the receiver's device memory (e.g., GPU memory), eliminating an intermediate host-to-device copy.

  1. The receiver MUST send its RDMA_REGISTER (destination) after the sender's RDMA_REGISTER (source) and before RDMA_READY, if it intends to use GPUDirect.
  2. The length field of the destination RDMA_REGISTER MUST equal total_data_bytes from the preceding TENSOR_DESCRIPTOR.
  3. The receiver MUST register a buffer whose device type is consistent with the device_tag reported in TENSOR_DESCRIPTOR. If the server fell back to a different device per Device Negotiation, the receiver MUST NOT send a destination RDMA_REGISTER for a buffer on the originally requested device. The receiver MUST either accept the fallback and proceed without GPUDirect, or close the stream with ERROR.
  4. If the receiver does NOT send RDMA_REGISTER, the sender MUST use its own buffer management strategy for the RDMA destination — typically landing data in the receiver's host memory via the RDMA library's managed staging area.
  5. A sender that receives a destination RDMA_REGISTER but cannot perform a GPUDirect write (e.g., topology mismatch) MUST send ERROR instead of RDMA_READY.

Note (non-normative): GPUDirect requires the NIC and GPU to share a PCIe root complex (or to be NVLink-connected). The protocol cannot validate this topology; the receiver is responsible for ensuring it before advertising RDMA_GPUDIRECT and before sending a destination RDMA_REGISTER.

RDMA_READY Payload

The RDMA_READY message has an empty payload (payload_length = 0). It signals that the receiver has processed RDMA_REGISTER and is ready for the RDMA transfer to begin.

Completion

After the RDMA operation completes on the sender's side, the sender MUST send TENSOR_DATA_END over the control plane. The receiver MUST NOT read from the transferred buffer before receiving TENSOR_DATA_END.

For cross-machine transport, the sender MUST set sync_mode = SYNC_PRODUCER_SYNCED (0x00) in every buffer handle within the transmitted TENSOR_DESCRIPTOR. TENSOR_DATA_END is the cross-machine equivalent of SYNC_PRODUCER_SYNCED per buffer-protocol.md § Stream and Event Synchronisation: the receiver MUST NOT read from the transferred buffer before receiving TENSOR_DATA_END, and upon receipt of TENSOR_DATA_END the buffer is considered producer-synced. The sync_mode values SYNC_EVENT (0x01) and SYNC_CONSUMER_STREAM (0x02) are FORBIDDEN on cross-machine transports because device event and stream handles are not valid in a different driver context on a different host. A receiver MUST reject a cross-machine TENSOR_DESCRIPTOR whose buffer handle declares any other mode.

Note (non-normative): RDMA Write completion on the sender does not imply that the receiver has observed the data without an explicit memory fence or signal. TENSOR_DATA_END serves as that authoritative "buffer is ready" signal. The sender MUST ensure the RDMA operation has completed (e.g., via a completion queue event) before sending TENSOR_DATA_END.

TENSOR_PUT with RDMA

For TENSOR_PUT (client → server), roles are reversed relative to the server-to-client flow: the client is the source-buffer owner and the server is the receiver.

Client                          Server
  |                               |
  |--- TENSOR_PUT --------------->|  (tensor key + descriptor)
  |--- RDMA_REGISTER ------------>|  (client registers source buffer, shares rkey + addr)
  |<-- RDMA_REGISTER -------------|  (server registers destination buffer; only if RDMA_GPUDIRECT)
  |<-- RDMA_READY ----------------|  (server is ready; RDMA operation may begin)
  |                               |
  |   [RDMA Write executes outside the TCP control plane]
  |                               |
  |--- TENSOR_DATA_END ---------->|  (client signals data placed)
  |<-- TENSOR_PUT_ACK ------------|  (server confirmed receipt)

The server's RDMA_REGISTER (destination) step is OPTIONAL and only occurs when both peers advertised the RDMA_GPUDIRECT capability flag in their respective HELLO messages.

In TENSOR_PUT, the client unilaterally declares the destination device_tag in the descriptor; the server has no negotiation opportunity (there is no analogue of preferred_device for PUT transfers). If the server cannot accept the tensor on the declared device, it MUST send ERROR with error_code = DEVICE_UNAVAILABLE before any RDMA exchange and close the stream.

The rules in GPUDirect Destination Registration apply symmetrically with roles inverted: the server is the destination-buffer owner, and its destination RDMA_REGISTER declares a region whose device type MUST match the device_tag declared by the client in the descriptor.

If the client (source-buffer owner) cannot perform a GPUDirect write after receiving the server's destination RDMA_REGISTER (e.g., topology mismatch), the client MUST send ERROR and close the stream before TENSOR_DATA_END.


TENSOR_PUT

Overview

TENSOR_PUT allows a client to push a tensor to the server. The protocol defines the wire exchange only — the server-side storage model (lifetime, eviction, collision handling) is an implementation concern and is intentionally out of scope.

TENSOR_PUT Flow

Client                          Server
  |--- TENSOR_PUT --------------->|  (tensor key + descriptor)
  |--- TENSOR_DATA (one or more)->|  (data frames)
  |--- TENSOR_DATA_END ---------->|
  |<-- TENSOR_PUT_ACK ------------|  (server confirmed receipt)

With the RDMA data plane, TENSOR_DATA frames are replaced by the RDMA handshake described in TENSOR_PUT with RDMA.

TENSOR_PUT Payload

FieldTypeDescription
tensor_keyutf8 stringIdentifier for the pushed tensor. Encoded as a uint32 byte length followed by UTF-8 bytes.
descriptorbyte sequenceSerialized tensor descriptor as defined in metadata.md. Encoded as a uint32 byte length followed by the descriptor bytes.
total_data_bytesuint64Total number of bytes that will follow in TENSOR_DATA frames.

TENSOR_PUT_ACK Payload

The TENSOR_PUT_ACK message has an empty payload (payload_length = 0). It signals that the server has received and accepted the complete tensor buffer. It does not imply anything about how the server stores, forwards, or uses the tensor.

If the server cannot accept the tensor for any reason (e.g., policy rejection, resource exhaustion), it MUST send an ERROR message instead of TENSOR_PUT_ACK and close the stream.

Note (non-normative): Server-side storage semantics — including tensor lifetime, eviction policy, and key collision handling — are deliberately unspecified. A server implementation is free to store the tensor for the session, forward it immediately to another peer, or discard it after use. The protocol's role is delivery confirmation, not storage coordination.


Error Handling

ERROR Payload

FieldTypeDescription
error_codeuint32Error code (see below).
messageutf8 stringHuman-readable error description. uint32 length prefix followed by UTF-8 bytes.
CodeNameMeaning
0x00000001PROTOCOL_VERSION_MISMATCHIncompatible protocol versions
0x00000002UNKNOWN_TENSORRequested tensor key not found
0x00000003LAYOUT_UNAVAILABLENo acceptable layout could be served
0x00000004TRANSCODE_LIMIT_EXCEEDEDTranscoding refused: buffer too large
0x00000005DEVICE_UNAVAILABLERequested device not available
0x00000006INVALID_MESSAGEMalformed message received
0x00000007MESSAGE_TOO_LARGEpayload_length exceeds receiver's limit
0x00000008SHARD_MISMATCHParallel shard descriptors are inconsistent
0x000000F0–0x000000FE(implementation-defined)

Upon sending or receiving an ERROR message, both parties MUST close the affected stream. The connection MAY remain open for other streams.


Open Questions Summary

[OQ-1]: Endianness negotiation: should the transport protocol allow a client to request big-endian wire encoding? Resolved: No endianness negotiation. The wire format is always little-endian; big-endian clients MUST byte-swap on receipt. Rationale: all AI/ML inference hardware targeted by Hurray is little-endian; negotiation would add protocol complexity with zero practical benefit. Consistent with Arrow, DLPack, and SafeTensors.

[OQ-2]: RDMA data plane handshake. Resolved: RDMA_REGISTER (0x0000000C) and RDMA_READY (0x0000000D) message types are now defined. The party owning the source buffer registers its memory region and sends RDMA_REGISTER (rkey + remote address + length) over the control plane; the peer responds with RDMA_READY; the RDMA operation executes out-of-band; TENSOR_DATA_END is sent over the control plane as the authoritative completion signal. See RDMA Data Plane.

[OQ-3]: Multiplexing scheme. Resolved: The stream_id field is defined as an opaque per-stream identifier. Implementations MAY multiplex multiple streams over a single TCP connection using stream_id for demultiplexing, but the protocol does not mandate a normative multiplexing scheme. Each stream MAY equivalently run on its own connection. Normative multiplexing rules are deferred to a future revision once the format is stable.

[OQ-4]: TENSOR_PUT semantics. Resolved: Server-side storage model is explicitly out of scope. TENSOR_PUT_ACK means "received and accepted"; it carries no implication about persistence, lifetime, or collision handling. Those are implementation concerns. The server sends ERROR to reject a PUT for any reason. See TENSOR_PUT.

[OQ-5]: Device negotiation and GPUDirect RDMA destination registration. Resolved: A normative Device Negotiation section defines server device selection rules. GPUDirect destination registration uses bidirectional RDMA_REGISTER gated by the new RDMA_GPUDIRECT capability flag (bit 3). See docs/adr/ADR-011-server-device-selection.md (server device selection) and docs/adr/ADR-012-gpudirect-rdma.md (GPUDirect RDMA).


Interaction with Other Sections

  • Memory Layout (memory-layout.md): defines the layout tag space used in capability advertisement and layout negotiation. Shard descriptors (parent_shape, shard_offset) are the logical basis for parallel transfers.
  • Metadata (metadata.md): defines the binary encoding of the tensor descriptor transmitted in TENSOR_DESCRIPTOR messages.
  • Buffer Protocol (buffer-protocol.md): defines buffer alignment requirements that the client expresses via min_alignment in TENSOR_REQUEST, and device memory semantics relevant to the preferred_device field.
  • Element Types (element-types.md): defines the little-endian wire encoding of tensor element data, which is the data transmitted in TENSOR_DATA frames.

File Format — Hurray Format Specification

Status: Draft

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

Scope

This section defines the Hurray file format: a random-access container for one or more named tensors, intended for on-disk model storage, distribution, and mmap-based zero-copy loading. It is the companion to the streaming IPC format defined in interchange.md.

The two formats are complementary, not competing:

Streaming formatFile format
TransportSocket, pipe, RDMA, IPCSeekable file or mmap-able region
Tensor countUnbounded, unknown upfrontFixed, enumerable via index
Tensor namesNoneRequired, unique UTF-8
Random accessNoYes — seek to any tensor by name
Writer constraintSingle-pass, no seek requiredSingle-pass, sequential write + footer
Reader startImmediately (streaming)After file is complete (index in footer)
Prior artArrow IPC stream, gRPCSafeTensors, GGUF, Arrow IPC file

Both formats share the same tensor descriptor encoding defined in metadata.md. The file format adds a container layer; it does not redefine how individual tensors are described.

See docs/adr/ADR-011-file-format-random-access-container.md for the design decisions.


File Layout

A Hurray file has the following structure, in order:

[ File header          ]   64 bytes, fixed
[ Padding              ]   0x00 bytes, to first_descriptor_offset
[ Tensor region        ]   repeated: descriptor → padding → data buffer(s) → padding
[ KV metadata section  ]   optional, located by trailer
[ Index section        ]   located by trailer
[ Trailer              ]   40 bytes, fixed, at file_size - 40

All multi-byte fields MUST be encoded in little-endian byte order.


File Header

The file header occupies the first 64 bytes of the file.

OffsetFieldTypeDescription
0magicuint8[8]MUST be 0x48 0x52 0x52 0x59 0x46 0x49 0x4C 0x45 (ASCII HRRYFILE).
8container_version_majoruint8Container format major version. Current: 0x01.
9container_version_minoruint8Container format minor version. Current: 0x00.
10_reserveduint8[2]MUST be 0x00.
12file_flagsuint32Bitmask of file-level flags. See § File Flags.
16data_buffer_alignmentuint32Alignment (bytes) applied to all tensor data buffers within the file. MUST be a power of two and MUST be at least 4096. MUST NOT exceed 2097152 (2 MiB).
20first_descriptor_offsetuint64Absolute byte offset of the first tensor descriptor. MUST be >= 64. Typically 64 (immediately after the header).
28tensor_count_hintuint64Number of tensors in the file, if known at write time. Writers that do not know this upfront MUST set this field to 0xFFFFFFFFFFFFFFFF. Readers MUST NOT rely on this field for correctness; use the index entry count instead.
36_reserved_headeruint8[28]MUST be 0x00. Reserved for future use.

Total: 64 bytes.

A reader MUST reject a file whose magic does not equal HRRYFILE. A reader MUST reject a file whose container_version_major exceeds the highest major version the reader supports.

File Flags

BitNameMeaning
0HAS_KV_METADATAKV metadata section is present; kv_offset and kv_length in the trailer are non-zero.
1SORTED_INDEXIndex entries are sorted by UTF-8 byte order of name, enabling binary search. A writer MUST NOT set this flag unless the index is actually sorted.
2HAS_INDEX_CRC32CThe index_crc32c field in the trailer carries a valid CRC-32C of the index section bytes. When this flag is set, a reader MUST verify the CRC and MUST reject the file if it does not match. Writers SHOULD always set this flag and populate index_crc32c. When this flag is not set, the index_crc32c field MUST be 0x00000000 and readers MUST NOT perform checksum verification.
3–31(reserved)MUST be 0. A reader MUST reject a file with reserved flag bits set.

Tensor Region

Following the file header (and any padding to first_descriptor_offset), tensors are written sequentially. Each tensor occupies the following region:

[ Tensor descriptor    ]   as defined in metadata.md (begins with HRRY magic)
[ Padding              ]   0x00 bytes to align next data buffer to data_buffer_alignment
[ Data buffer 0        ]   tensor data, byte_size bytes
[ Padding              ]   0x00 bytes to align next item to data_buffer_alignment
[ Data buffer 1        ]   (if buffer_count > 1)
[ Padding              ]
  ...
[ Padding              ]   0x00 bytes to align next descriptor to 8 bytes

Descriptor Placement

Each tensor descriptor MUST begin at a byte offset that is a multiple of 8. The first descriptor begins at first_descriptor_offset (which MUST be 8-byte aligned). Subsequent descriptors begin at the next 8-byte-aligned offset after the previous tensor's last data buffer (including padding).

Data Buffer Placement

The first data buffer of a tensor MUST begin at a byte offset that is a multiple of data_buffer_alignment. If the tensor descriptor ends at a byte offset that is not a multiple of data_buffer_alignment, the writer MUST insert 0x00 padding bytes to reach the next data_buffer_alignment boundary.

If a tensor has multiple data buffers (e.g., dense data + quantization parameter buffers), each buffer MUST begin at a data_buffer_alignment-aligned offset. Padding bytes between buffers MUST be 0x00.

Empty Tensors

An empty tensor (any dimension size 0) has data buffer byte size 0. Its data buffer region occupies 0 bytes. The writer MUST still insert sufficient padding after the descriptor to satisfy alignment requirements for the next descriptor.


KV Metadata Section

The KV metadata section, when present (HAS_KV_METADATA flag set), is located by the trailer's kv_offset and kv_length fields. It MUST be aligned to an 8-byte boundary.

KV Section Encoding

FieldTypeDescription
kv_countuint32Number of key-value entries.
kv_entries[kv_count](variable)Sequentially encoded KV entries.

Each KV entry is encoded as:

FieldTypeDescription
key_lengthuint16Length of the key in bytes. MUST be at least 1.
keyuint8[key_length]UTF-8 key bytes, no null terminator.
value_taguint8Type tag for the value (see § KV Value Types).
value(variable)Value payload, encoded per value_tag.

Keys MUST be unique within the KV section (case-sensitive, byte-exact comparison). A reader MUST reject a file with duplicate KV keys. Keys MUST be valid UTF-8.

KV Value Types

TagTypePayload encoding
0x01utf8 stringuint32 byte length, then UTF-8 bytes.
0x02int648 bytes, little-endian.
0x03uint648 bytes, little-endian.
0x04float648 bytes, little-endian IEEE 754 binary64.
0x05bool1 byte. 0x00 = false, 0x01 = true. All other values MUST be rejected.
0x06byte sequenceuint32 byte length, then opaque bytes.
0x07arrayuint8 element type tag (MUST be 0x01–0x06), then uint32 element count, then element payloads concatenated.
0x08–0xEF(reserved)MUST NOT be used. A reader MUST reject a file containing a reserved value tag.
0xF0–0xFE(extension)Implementation-private. MUST NOT appear in files exchanged between independent implementations unless agreed out of band.
0xFF(invalid)MUST NOT be used.

Note (non-normative): The KV section is intended for model-level metadata: architecture name, quantization configuration identifier, tokenizer vocabulary size, etc. It is not a substitute for per-tensor metadata; per-tensor fields belong in the tensor descriptor.


Index Section

The index section is located by the trailer's index_offset and index_length fields. It MUST be aligned to an 8-byte boundary.

Index Encoding

FieldTypeDescription
index_entry_countuint64Number of index entries. MUST equal the number of tensors in the file.
index_entries[index_entry_count](variable)Sequentially encoded index entries.

Each index entry is encoded as:

FieldTypeDescription
name_lengthuint16Length of the tensor name in bytes. MUST be at least 1.
nameuint8[name_length]UTF-8 tensor name bytes, no null terminator.
descriptor_offsetuint64Absolute byte offset of the tensor descriptor from the start of the file.
descriptor_lengthuint32Length of the tensor descriptor in bytes. MUST equal the descriptor's own internal descriptor_length field; a reader MUST reject a file where they disagree.
data_offsetuint64Absolute byte offset of the first data buffer from the start of the file.
data_lengthuint64Total byte length of all data buffers for this tensor (sum of all buffer byte_size values, plus inter-buffer padding within the tensor's data region).
flagsuint32Reserved for future use. MUST be 0x00000000.

Index entry names MUST be unique within the index (case-sensitive, byte-exact). Index entries are written in tensor write order by default. If the SORTED_INDEX file flag is set, entries MUST be sorted by UTF-8 byte order of name (strict byte comparison, no Unicode normalisation).

Note (non-normative): The descriptor_length field in the index duplicates the descriptor's own internal length to enable fast enumeration: a reader that wants to list all tensor names, shapes, and dtypes can parse the index without touching any descriptor byte, at O(index size) cost instead of O(file size).

Note (non-normative): data_length covers the full data region including inter-buffer padding. A reader that wants to mmap the entire data region of a tensor can use data_offset and data_length without parsing the buffer table.


Composite Tensors

A composite tensor (head + members; see layouts/composite.md) is written as a head descriptor followed by its member_count = N members, all as consecutive tensors in the tensor region, in that order. The head is an ordinary tensor with layout_tag = 0x0B and buffer_count = 0, so its data region occupies 0 bytes (its data_length in the index is 0); each member is written as an ordinary tensor with its own descriptor and data buffers. Every tensor — head and members alike — gets its own index entry.

No data_buffer_alignment padding after a head. § Data Buffer Placement's alignment rule applies to a tensor's data buffer(s); a buffer_count = 0 head has none — unlike an empty tensor (§ Empty Tensors), which still has one buffer of byte_size = 0 and is therefore still aligned as a buffer. A writer MUST NOT insert data_buffer_alignment padding after a composite head's descriptor. The next descriptor (the head's first member) follows at the ordinary 8-byte descriptor-aligned offset (§ Descriptor Placement).

Membership is recovered from the head's member_count plus descriptor-offset order: the members are the next N tensors, ordered by ascending descriptor_offset, following the head. This recovery rule uses descriptor_offset order (write order in the tensor region), which is preserved regardless of whether the SORTED_INDEX file flag is set. Setting SORTED_INDEX reorders only the index entries (by tensor name, for binary search); it does not move tensors within the tensor region, so descriptor_offset order still recovers head→member adjacency.

The composite closes at the Nth member. A reader MUST run the close-time validation for the composition rule (layouts/composite.md § Validation) once all N members are located. A file in which fewer than N members follow a head (a torn composite) is invalid: a strict reader MUST reject it; a permissive reader MAY treat the arrived members as independent shard tensors but MUST NOT present the composite as complete.

Nested composites are permitted: a member MAY itself be a head, whose own members are the immediately following tensors (pre-order), subject to the depth limit in layouts/composite.md § Binding.

Note (non-normative): Because membership is positional, composite members are not required to carry distinguishing names beyond the file format's per-tensor uniqueness rule. A reader that only wants the merged logical view resolves it from the head plus its N following members; a reader indexing by name still sees each member as an addressable tensor.


Trailer

The trailer occupies the last 40 bytes of the file (bytes file_size - 40 through file_size - 1).

Offset from trailer startFieldTypeDescription
0index_offsetuint64Absolute byte offset of the index section from the start of the file.
8index_lengthuint64Byte length of the index section.
16kv_offsetuint64Absolute byte offset of the KV metadata section. 0 if no KV section.
24kv_lengthuint32Byte length of the KV metadata section. 0 if no KV section.
28index_crc32cuint32CRC-32C of the index section bytes (the index_length bytes starting at index_offset). Valid only when the HAS_INDEX_CRC32C file flag (bit 2) is set. MUST be 0x00000000 when the flag is not set.
32_reserveduint8[4]MUST be 0x00. Reserved for future trailer fields.
36trailer_magicuint8[4]MUST be 0x48 0x52 0x52 0x59 (ASCII HRRY).

Total: 40 bytes.

Note (non-normative): kv_length is uint32 (4-byte), capping the KV metadata section at approximately 4 GiB. This is intentional: KV metadata holds model-level annotations (architecture, tokenizer config, quantization settings) and is not expected to exceed this limit in practice. Tensor data, which can be arbitrarily large, is addressed by uint64 offsets in the index.

A reader locates the trailer by seeking to file_size - 40. It MUST verify trailer_magic before trusting any other trailer field.

A reader MUST check the HAS_INDEX_CRC32C file flag before interpreting index_crc32c. If the flag is set, the reader MUST verify the CRC-32C against the index section bytes and MUST reject the file on mismatch. If the flag is not set, index_crc32c MUST be 0x00000000; a reader that finds a non-zero value with the flag unset MUST reject the file.

A reader MUST reject a file whose index_offset + index_length extends into the trailer (i.e., index_offset + index_length > file_size - 40).


Reader Protocol

Random-Access (Seek-Capable) Reader

  1. Read bytes 0–7. MUST equal HRRYFILE.
  2. Read bytes 8–63 (remainder of file header). Check container_version_major.
  3. Seek to file_size - 40. Read trailer. Verify trailer_magic. If HAS_INDEX_CRC32C is set in file_flags, record index_crc32c for verification after step 4.
  4. Seek to index_offset. Read index_length bytes. Parse index.
  5. Optionally seek to kv_offset. Read KV metadata.
  6. For each requested tensor: seek to descriptor_offset, parse descriptor; seek to data_offset, mmap or read data_length bytes.

Sequential Reader (No Seek)

A reader that cannot seek MAY consume the file sequentially. Such a reader MUST track the running byte offset from the start of the file at every step and use offset arithmetic — never inspection of data buffer content — to determine section boundaries.

  1. Read and verify the 64-byte file header. Set the running offset to 64.
  2. Read padding bytes until the running offset equals first_descriptor_offset.
  3. For each tensor in the tensor region: a. Read the tensor descriptor's first 10 bytes to obtain descriptor_length (bytes 6–9), then read the remaining descriptor_length - 10 bytes of the descriptor. Advance the running offset by descriptor_length. b. Read padding bytes until the running offset is a multiple of data_buffer_alignment (the value declared in the file header). c. For each entry in the descriptor's buffer table, read byte_size bytes of data and then read padding bytes until the running offset is a multiple of data_buffer_alignment. Advance the running offset by the buffer size and the padding. d. Read padding bytes until the running offset is a multiple of 8 (alignment for the next descriptor). e. The reader has now reached the start of either the next tensor descriptor, the KV metadata section, or the index section.
  4. To determine whether step (3) should be repeated, the reader cannot inspect data content (a tensor's data buffer may legitimately contain the byte sequence 0x48 0x52 0x52 0x59, and the KV / index sections do not begin with HRRY). The end of the tensor region MUST be detected by one of:
    • Trailer probe (preferred): a sequential reader that has buffered the entire file in memory after the fact MAY locate the trailer at file_size - 40 and use index_offset (and kv_offset if present) to determine the tensor region's end offset.
    • Tensor count hint: if the file header's tensor_count_hint is not 0xFFFFFFFFFFFFFFFF, the reader MAY iterate exactly that many tensors. The reader MUST then verify that the running offset corresponds to a declared section boundary.
    • EOF: the reader MAY consume bytes until end-of-file and treat the terminal 40 bytes as the trailer; it MUST NOT treat that 40 bytes as a tensor region.

A sequential reader cannot perform random access and cannot look up tensors by name without reading the entire file in order. This mode is OPTIONAL to implement and is significantly more constrained than the random-access reader defined above; conforming implementations SHOULD prefer the random-access path.


Writer Protocol

A conforming streaming writer produces a valid Hurray file in a single forward pass:

  1. Write the file header. Set tensor_count_hint to 0xFFFFFFFFFFFFFFFF if the count is not known.
  2. For each tensor, in any order: a. Record the current byte offset as descriptor_offset. b. Write the tensor descriptor. c. Insert 0x00 padding to the next data_buffer_alignment boundary. d. Record the current byte offset as data_offset. e. Write each data buffer; insert padding between buffers and after the last one to maintain data_buffer_alignment. f. Insert 0x00 padding to the next 8-byte boundary. g. Record descriptor_length and data_length for the index.
  3. Write the KV metadata section (if any). Record its offset and length.
  4. Write the index section. Record its offset and length.
  5. Compute CRC-32C over the index section bytes. Set HAS_INDEX_CRC32C in file_flags. Write the 40-byte trailer with index_crc32c populated.

The writer MUST NOT seek backward at any point. All offset information is tracked in memory as a list of (name, descriptor_offset, descriptor_length, data_offset, data_length) tuples, which is the only in-memory state required beyond the tensors themselves.


Alignment and Padding Summary

ItemRequired alignmentPadding fill
File headern/a (byte 0)—
First tensor descriptorfirst_descriptor_offset (≥ 8-byte aligned)0x00
Subsequent tensor descriptors8-byte0x00
Data buffers (all)data_buffer_alignment (≥ 4096)0x00
KV metadata section8-byte0x00
Index section8-byte0x00
Trailerlast 40 bytes of file—

Relationship to Other Sections

  • metadata.md defines the tensor descriptor encoding used verbatim within tensor regions. The descriptor_length field in the index caches the value of the descriptor's own internal length field.
  • interchange.md defines the streaming IPC format. The two formats are complementary: file magic HRRYFILE distinguishes the file format from a stream that begins with an HRRY descriptor.
  • buffer-protocol.md defines alignment rules. The file format's data_buffer_alignment (minimum 4096 bytes) enables zero-copy mmap of tensor data. The same device-tag and ownership rules apply to mmapped buffers.
  • versioning.md defines descriptor versioning. Container versioning (container_version_major / container_version_minor) is independent of descriptor versioning.

Open Questions

[OQ-1]: Should single-tensor files be required to use a specific tensor name? Resolved: No required name. Tensor names are always meaningful and left to the producer. For the array database use case (Core Property 10), mandating a generic name like "data" would erase the semantic identity of the tensor. Readers that need a single-tensor API SHOULD use the sole entry in the footer index without reference to its name.

[OQ-2]: Should a future SORTED_INDEX_NFC flag be defined for NFC-normalised or case-folded comparisons? Resolved: Strict UTF-8 byte order is sufficient. Tensor names in practice are ASCII identifiers; NFC normalisation adds implementation complexity with no practical benefit for the target use case. A new flag can be defined if a future use case genuinely requires Unicode-aware sorting.

[OQ-3]: Should the trailer carry a CRC-32C of the footer index for integrity verification? Resolved: Added. The index_crc32c field (uint32, offset 28 in the trailer) carries the CRC-32C of the index section bytes. Writers SHOULD always populate it; a value of 0x00000000 signals "not computed" and readers MAY accept without verification. The trailer grows from 32 to 40 bytes accordingly. Related metadata.md OQ-1 (descriptor checksum) was resolved as "no" — file-at-rest integrity is a stronger argument than in-flight descriptor integrity.

Versioning — Hurray Format Specification

Status: Draft

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

Scope

This section defines the versioning model for the Hurray format and the compatibility policy that conforming readers and writers MUST follow.

The Hurray format carries three independent version axes:

  1. Descriptor version — the format version of the tensor descriptor itself.
  2. Container version — the format version of the file-level container.
  3. Quantization scheme version — the version of an individual quantization scheme's wire encoding.

The three axes evolve independently: a single major version of the descriptor may coexist with multiple major versions of the container, and quantization schemes may version their parameter encodings without affecting either of the other two axes.

Note (non-normative): Decoupling these axes avoids forcing a global version bump every time a single component evolves. A new scheme tag, for example, only requires a descriptor minor version increment — it does not require the container or any other scheme to change versions.


Version Axes

Descriptor Version (version_major / version_minor)

The descriptor version describes the wire encoding of the tensor descriptor as defined in metadata.md.

  • Location: tensor descriptor fixed header, byte 4 (version_major, uint8) and byte 5 (version_minor, uint8). See metadata.md § Fixed Header.
  • Current values: version_major = 0x01, version_minor = 0x00 (i.e., descriptor format version 1.0).

A change to the descriptor encoding falls into one of three classes (see § Change Classification).

A version_minor increment is REQUIRED when any of the following changes are made to the descriptor encoding:

  • A new optional flag bit is defined in the descriptor flags field.
  • A new optional descriptor section is defined (gated by a new flag bit).
  • A new optional trailing field is appended to an existing section without changing the offset of any field defined in the previous minor version.
  • A new type_tag, layout_tag, or device_tag value is allocated within the existing tag space.
  • A new quantization scheme tag is allocated within the existing scheme tag space.

A version_major increment is REQUIRED when any of the following changes are made:

  • An existing field's offset, type, or semantics changes.
  • An existing flag bit's meaning changes.
  • An existing tag value's meaning changes.
  • A previously OPTIONAL field becomes REQUIRED, or vice versa.
  • A previously valid encoding is forbidden.
  • The minimum descriptor length grows.

Reader behaviour for the descriptor version is defined in § Compatibility Matrix. The normative reader rules are established in metadata.md § Fixed Header and § Version Compatibility; this section does not duplicate them, it only classifies the changes that justify each rule.

Note (non-normative): During the pre-1.0 draft period the descriptor, container, and quantization-scheme version values remain fixed at their initial 1.0 values; all features added while the specification is in draft accumulate into that initial 1.0. The minor- and major-increment rules in this section govern only changes made AFTER the first stable (non-draft) release is published. Adding a new layout, scheme, or tag during the draft period therefore does not bump version_minor. This does not weaken the post-release increment rules, which apply in full once 1.0 is published.

Container Version (container_version_major / container_version_minor)

The container version describes the wire encoding of the Hurray file format as defined in file-format.md.

  • Location: file header, byte 8 (container_version_major, uint8) and byte 9 (container_version_minor, uint8). See file-format.md § File Header.
  • Current values: container_version_major = 0x01, container_version_minor = 0x00 (i.e., container format version 1.0).

The container version is independent of the descriptor version. A file at container version 1.0 MAY contain tensor descriptors at descriptor version 1.0, 1.1, or any later compatible descriptor version. A reader MUST NOT infer the descriptor version from the container version, or vice versa.

A container_version_minor increment is REQUIRED when any of the following changes are made to the container encoding:

  • A new optional file-level flag bit is defined in file_flags.
  • A new optional KV value tag is allocated within the existing KV value tag space.
  • A new optional trailing field is appended to the file header, the trailer, or an index entry without changing the offset of any field defined in the previous minor version.

A container_version_major increment is REQUIRED when any of the following changes are made:

  • An existing field's offset, type, or semantics changes in the file header, index, or trailer.
  • The trailer length changes.
  • An existing flag bit's meaning changes.
  • The data buffer alignment minimum or maximum bounds change.
  • A previously OPTIONAL section becomes REQUIRED, or vice versa.

The normative reader rules for the container version are established in file-format.md § File Header.

Quantization Scheme Version (scheme_version)

The quantization scheme version describes the wire encoding of an individual quantization scheme's parameter payload as defined in quantization.md.

  • Location: quantization descriptor header, byte 1 (scheme_version, uint8). See quantization.md § Descriptor Header.
  • Current values: scheme_version = 0x01 for every scheme defined in quantization.md and its sub-files.

Each (scheme_tag, scheme_version) pair is independently versioned. Incrementing the version of one scheme does not affect any other scheme.

A scheme_version increment is REQUIRED when any of the following changes are made to a scheme's parameter encoding:

  • A new field is added to the scheme's payload.
  • A reserved byte or flag bit is repurposed.
  • The interpretation of an existing field changes.
  • The dequantization formula changes (this is also a backward-incompatible change at the descriptor level — see below).

Adding a brand-new scheme does not increment any existing scheme's scheme_version; it allocates a new scheme_tag and starts that tag at scheme_version = 0x01. Allocating the new scheme_tag requires a descriptor minor version increment, as established in quantization.md § Version Compatibility.

The normative reader rule for the scheme version is established in quantization.md § Descriptor Header: a reader MUST reject a descriptor whose scheme_version exceeds the highest version defined in this specification for the given scheme_tag.

Note (non-normative): Removing a scheme or changing the dequantization formula for an existing (scheme_tag, scheme_version) pair is a backward-incompatible change to the descriptor encoding and therefore requires a descriptor version_major increment, not merely a scheme_version bump. The scheme_version field exists to support backward-compatible additions to a scheme's parameter encoding within a single descriptor major version.


Change Classification

Changes to any of the three version axes fall into one of three classes.

ClassWire-format effectRequired version bump
MAJORBackward-incompatible. A reader built for the previous major version cannot correctly parse data written at the new major version.Major version increment on the affected axis.
MINORBackward-compatible addition. A reader built for the previous minor version can still parse data written at the new minor version (ignoring new optional content).Minor version increment on the affected axis.
PATCHNo wire-format change. Documentation clarifications, editorial fixes, examples, and test vector additions.No version increment.

Examples of MAJOR changes

  • Removing a field, flag bit, or tag value.
  • Changing the offset, type, or semantics of an existing field.
  • Adding a new mandatory field that all writers MUST emit and all readers MUST parse.
  • Changing the dequantization formula for an existing (scheme_tag, scheme_version) pair.
  • Changing the meaning of an existing flag bit.
  • Changing the encoding of magic bytes, the descriptor length field, or any field a reader is required to consult before parsing the rest of a structure.

Examples of MINOR changes

  • Adding a new optional flag bit to a flags field.
  • Adding a new optional section gated by a new flag bit.
  • Allocating a new type_tag, layout_tag, device_tag, KV value tag, or quantization scheme_tag within the existing tag space.
  • Adding a new optional trailing field to an existing section without disturbing the offsets of fields defined in the previous minor version.

Minor amendments MUST comply with the Evolvability Contract § Spec Amendment Rules (in particular S6 — no fixed-offset additions) and the defaults table in § Defaults for Appended Trailing Fields (S4).

Examples of PATCH changes

  • Clarifying ambiguous wording.
  • Adding non-normative notes or worked examples.
  • Adding test vectors.
  • Fixing typos that do not affect interpretation.

Compatibility Matrix

Let R denote the highest version a reader supports on a given axis, and W denote the version recorded in the data being read on the same axis. The compatibility rules apply identically to all three axes (descriptor, container, and quantization scheme).

RelationshipReader behaviour
R.major < W.majorMUST reject the data.
R.major > W.majorMUST reject the data, unless this specification defines an explicit migration path for the older major version. No such migration path is defined for any axis at version 1.x.
R.major == W.major, R.minor < W.minorMUST parse all fields defined at R.minor. MUST ignore any optional fields, flag bits, sections, or tag values that are not defined at R.minor. MUST use the relevant length prefix (descriptor_length, quantization_length, or section length in the file index) to skip unknown trailing content.
R.major == W.major, R.minor >= W.minorNormal read. All fields written at W.minor are defined at R.minor or earlier.

A reader that encounters a flag bit, tag value, or scheme not defined at its supported minor version MUST treat the affected feature as unknown:

  • For an unknown descriptor flag bit: a reader MUST reject the descriptor, because flag bits in descriptor minor version 1.0 are required to be 0 for all reserved positions, so an unknown flag bit at 1.x > 0 indicates the writer used a feature the reader does not understand.
  • For an unknown type tag (outside the extension range 0xF0–0xFE): a reader MUST reject the descriptor.
  • For an unknown layout tag (outside the extension range): a reader MUST reject the descriptor.
  • For an unknown quantization scheme tag: a reader MUST reject the descriptor unless operating in permissive mode, as defined in quantization.md § Descriptor Header.
  • For an unknown KV value tag: a reader MUST reject the file, as defined in file-format.md § KV Value Types.
  • For an unknown file flag bit: a reader MUST reject the file, as defined in file-format.md § File Flags.

Note (non-normative): The asymmetry between "unknown trailing content MUST be ignored" and "unknown flag bits / tags MUST be rejected" is intentional. Trailing content is opt-in by definition: it can only be reached via a length prefix the reader already trusts. Unknown flag bits or tags signal that the writer relied on a feature whose semantics are unknowable to the reader, which would lead to silent misinterpretation of the data buffer.


Evolvability Contract

The Evolvability Contract states the stability guarantees the Hurray format makes to implementors and downstream tools across the lifetime of major version 1.x, and defines the rules that make the format safe to change over time: which compatibility a reader can rely on when it encounters data from a different minor version, and which spec-amendment moves are admissible at each step.

It is the normative counterpart to the "Format Evolvability" property listed in README § Core Properties. The guarantee period begins at descriptor and container version 1.0; pre-1.0 drafts are explicitly excluded (see § Out of Scope below).

The per-tag-space mechanics — reserved-range layouts, extension-range boundaries, and tag allocation tables — are normatively defined in the per-section files. This section does not restate those rules; it cross- references them:

  • Element type tag space and extension range: see Element Types § Type Tag Space.
  • Layout tag space, extension range, and per-layout reserved bytes: see Memory Layout § Layout Tag Space and the per-layout files under docs/spec/layouts/.
  • Device tag space and extension range: see Buffer Protocol § Device Tag Space.
  • Quantization scheme tag space, scheme reserved ranges, and permissive-mode parsing: see Quantization § Scheme Tag Space and § Descriptor Header.
  • KV value tag space and file flag bits: see File Format § KV Value Types and § File Flags.

Stability Commitments

For the lifetime of major version 1.x, this specification commits to the following invariants. Conforming readers, writers, and downstream tools MAY rely on every one of them.

  1. Reserved tag ranges are stable. Reserved tag ranges defined by this specification — across every public tag space (element type, layout, device, quantization scheme, KV value, and flag bits) — MUST NOT be repurposed, narrowed, or removed within major version 1.x. A reserved range allocated at 1.0 MUST remain reserved with the same boundaries and the same intended use class throughout 1.x.
  2. Implementation-private ranges remain implementation-private. The implementation-private ranges 0xF0–0xFE for element type tags, layout tags, and device tags MUST remain implementation-private for the lifetime of major version 1.x. This specification MUST NOT allocate any named public value into a private range, and a future minor revision MUST NOT reclaim a private range for public allocation. Equivalent implementation-private ranges defined for other tag spaces in their per- section files (e.g., the quantization scheme private range in quantization.md) are subject to the same guarantee.
  3. Reserved flag bits remain available for feature gating. Reserved flag bits in the tensor descriptor header flags field, the file header file_flags field, and every per-section flag field defined in this specification MUST remain available for backward-compatible feature gating throughout major version 1.x. A reserved flag bit MUST NOT be removed or have its reserved status withdrawn within 1.x; when allocated, it MUST be allocated as an optional feature gated by a minor version increment, per § Change Classification.
  4. Every variable-length section is length-prefixed. Every variable-length section in the tensor descriptor and the file format MUST carry a length prefix that allows an older reader to skip unknown trailing content without rejecting the structure. A future minor revision of the descriptor or container MUST NOT introduce a variable-length section that lacks such a length prefix. The applicable prefixes (descriptor_length, quantization_length, file index entry section lengths, and any equivalent fields defined in future minor revisions) MUST continue to bound exactly the bytes whose interpretation may change.
  5. Permissive-mode parsing is preserved. A reader MUST be able to parse the tensor descriptor's shape and buffer table even when it cannot interpret an unknown layout tag or an unknown quantization scheme tag. This specification MUST NOT, within major version 1.x, introduce a change that requires interpreting a layout tag or a quantization scheme tag in order to recover the shape, rank, element type, or buffer table. The exact behaviour of permissive mode for each tag space is defined in its per-section file (see Quantization § Descriptor Header and Memory Layout § Layout Tag Space).
  6. The three version axes evolve independently. The descriptor, container, and per-quantization-scheme versions MUST evolve independently. Adding a new feature on one axis MUST NOT force a version bump on the others. A descriptor minor increment MUST NOT require a container minor increment; a scheme-version bump for a single (scheme_tag, scheme_version) pair MUST NOT require any change to the descriptor or container version; a container minor increment MUST NOT require any change to the descriptor or to any scheme version. This commitment complements § Version Axes, which establishes the axes themselves.
  7. Public tag allocation goes through the spec amendment process. Any new public tag value — including a new element type tag, layout tag, device tag, quantization scheme tag, KV value tag, or named flag bit — MUST be added by a spec amendment that increments the appropriate minor version (per § Change Classification). New public named values MUST NOT be added by implementations independently of the specification. Implementations that need a private value MUST use the appropriate implementation-private range and remain subject to commitment (2) above.

Note (non-normative): Together, commitments (1)–(7) form the stable "extension surface" that downstream array databases, runtime registries, language bindings, and compatibility-testing harnesses can build against. A new tag allocated at descriptor 1.5 is guaranteed to retain its meaning, encoding, and tag-space neighbourhood through descriptor 1.99.

Out of Scope

The Evolvability Contract is a finite guarantee. The following properties are deliberately not guaranteed by this specification, and conforming readers, writers, and tools MUST NOT rely on them:

  1. Forward compatibility across major versions is out of scope. This contract applies within major version 1.x only. A reader MUST reject data whose major version on any axis exceeds the reader's supported major version, per § Compatibility Matrix. The guarantees in § Stability Commitments do not transfer to major version 2.x or beyond; a future major version MAY revise tag spaces, reserved ranges, flag bits, and length-prefix conventions without preserving 1.x semantics.
  2. Interpretation of unknown content is out of scope. Permissive-mode parsing allows a reader to extract shape, rank, element type, and buffer table when a layout tag or scheme tag is unknown. It does not authorise the reader to interpret the associated data buffer. A reader that does not understand the layout tag or quantization scheme tag of a tensor MUST NOT attempt to dereference, dequantize, or otherwise interpret the data buffer; doing so would constitute silent misinterpretation, which this contract explicitly forbids.
  3. Interoperability of implementation-private tags is out of scope. Tag values in any implementation-private range (e.g., 0xF0–0xFE for element type, layout, and device tags, and equivalent ranges in other tag spaces) MUST NOT be exchanged between independent implementations without an out-of-band agreement on their meaning. Encountering a private-range value from an unknown source MUST be treated as an unknown tag under the rules in § Compatibility Matrix; permissive-mode parsing remains available where defined, but no semantic interoperability is implied.
  4. A runtime plugin or codec mechanism is out of scope. This specification provides no registered plugin interface, no dynamic codec loader, and no implementation-supplied extension descriptor. New element types, layouts, devices, quantization schemes, and KV value tags MUST be added by a spec amendment under commitment (7); they MUST NOT be added by a runtime registration call, a sidecar manifest, or any implementation- private mechanism.
  5. User-defined non-numeric element types are out of scope. The element type extension range defined in Element Types exists only for new numeric encodings (integer, floating-point, and numerically-equivalent storage types). It MUST NOT be used to encode strings, structured records, opaque blobs, references to other tensors, or any other non-numeric content. Carrying non-numeric data over Hurray is the responsibility of the KV metadata section in File Format, not of the element type system.
  6. Back-compatibility of pre-1.0 drafts is out of scope. The Evolvability Contract begins at descriptor and container version 1.0. Pre-1.0 draft versions of this specification MAY have used different tag allocations, reserved ranges, or wire encodings, and conforming 1.x readers and writers MUST NOT assume any compatibility with them. A reader encountering data that claims a pre-1.0 version SHOULD reject it; it MAY accept it only if the reader was explicitly configured to consume draft data and has applied an implementation-defined migration.

Note (non-normative): The boundary between "what we promise" and "what we deliberately do not promise" is what makes the Evolvability Contract usable. Without the out-of-scope list, downstream tooling could infer guarantees that the format cannot actually defend — for example, assuming that a private-range tag from one runtime is meaningful in another, or that permissive-mode parsing implies safe buffer access. Calling out non-commitments is part of the contract.

Compatibility Direction

  • BACKWARD (CD1): A reader at minor M MUST correctly parse data written at any minor N ∈ {0, …, M} on the same major version on the relevant axis.

  • FORWARD_ADDITIVE (CD2): A reader at minor M reading data written at minor N > M on the same major version MUST correctly parse every field defined at minor M — the fixed header, the buffer table, and every length-prefixed section whose gating flag bit is defined at minor M, including trailing bytes of those sections up to the prefix length. The reader MUST reject the data if it encounters any flag bit or public tag value not defined at minor M, except within the permissive-mode exceptions for layout tags and quantization scheme tags defined in Memory Layout § Layout Tag Space and Quantization § Descriptor Header.

    Note (non-normative): The asymmetry between rejecting new flag-gated sections and skipping additive trailing bytes is intentional. An unknown flag bit may gate a new section whose presence changes the semantics of the data buffer; the reader has no safe way to ignore such a gate. Additive trailing bytes inside an already-known length-prefixed section, by contrast, extend a structure whose framing the reader already understands and can step past using the existing length prefix.

  • CD3: A reader supporting major K on a given axis MUST reject data whose major version on that axis is K + 1 or higher.

  • CD4: Cross-major reading is not automatic. A K+1-major reader is not required to read K-major data; it MAY do so only via the migration specification required by S5 below.

Writer Rules

  • W3: A writer MUST NOT emit a deprecated public tag value or flag bit when a non-deprecated equivalent exists.
  • W4: When a writer appends an optional trailing field to an existing length-prefixed section under a version_minor increment, the writer MUST emit that field at the documented offset and MUST update the enclosing length prefix accordingly. A writer MUST NOT emit a partial trailing field.

Note (non-normative): Rules W1 and W2 are the writer requirements stated in § Writer Requirements below. W3 and W4 extend them with the evolution-specific constraints needed by the Evolvability Contract.

Reader Rules

  • R3: A deprecated public tag value MUST be treated as semantically equivalent to its non-deprecated definition. Deprecation MUST NOT change a value's wire semantics.
  • R4: When a reader at minor M encounters a length-prefixed section shorter than the section length defined for minor M (i.e., data written at some minor N < M), the reader MUST treat every field beyond the data's section length as carrying its documented default from the defaults table in § Defaults for Appended Trailing Fields below.

Note (non-normative): Rules R1 and R2 are the reader behaviours stated in § Compatibility Matrix above. R3 and R4 extend them with the evolution-specific constraints needed by the Evolvability Contract.

Spec Amendment Rules

  • S1: New public tag values MUST be allocated from the documented public reserved range of their tag space (see § Stability Commitments, items 1 and 7).
  • S2 (Anti-rebind): An allocated public tag value MUST NOT be rebound to a different meaning within the same major version, even after deprecation.
  • S3 (Deprecation convention): A deprecated tag table entry MUST be marked "deprecated since 1.N" and SHOULD point to a replacement. Deprecation is a writer-facing signal only — see R3 for reader obligations.
  • S4 (Defaults for trailing fields): Any new optional trailing field appended to an existing section MUST have a normatively documented default in the same minor revision. The default MUST be recorded in § Defaults for Appended Trailing Fields below.
  • S5 (Migration commitment): A future major version MUST be accompanied by a normative migration specification mapping the prior major version's encoding onto the new major version's encoding for every field, flag bit, and tag value that survives the transition.
  • S6 (No fixed-offset additions): A new field in a minor revision MUST be gated by a flag bit, a tag value, or a length-prefixed trailing extension. A minor amendment MUST NOT allocate a new field at a fixed offset that an older reader would parse as part of an existing structure.

Defaults for Appended Trailing Fields

The following table records the default value that a reader at the prior minor version conceptually sees for each trailing field appended under a minor bump. This table is normative for R4. It MUST be updated as part of any spec amendment that adds a trailing field.

SectionFieldIntroduced inDefault for prior-minor readers
(empty — no trailing fields have been appended at 1.0)

Note (non-normative): The table is currently empty because no optional trailing fields have been appended to any section at descriptor version 1.0. The first such amendment MUST add its entry here.

Anti-Patterns

Note (non-normative): This sub-section is non-normative commentary; the operative prohibition lives in the bullet items themselves, which use MUST NOT to bind future spec amendments to the choice made here.

  • Per-field numeric tagging (Protobuf, Thrift): imposing a tag and a length-or-type word on every field defeats fixed-offset zero-copy reads and forces a per-field decode loop even for readers that need only a small subset of fields. This approach MUST NOT be adopted as the descriptor's encoding strategy within major version 1.x.
  • vtables (FlatBuffers): adding a per-object vtable indirection breaks single-pass streamability (the vtable is referenced by an offset that may point backward relative to the object) and adds extra cache-line traffic per field access. This approach MUST NOT be adopted as the descriptor's encoding strategy within major version 1.x.
  • Hurray's evolvability mechanism, by contrast, is the flag-bit + length-prefix model: new sections are gated by flag bits and framed by length prefixes; additive trailing fields live behind the enclosing length prefix; tag spaces grow only within their documented reserved ranges. This combination preserves fixed-offset zero-copy reads for every field defined at the reader's minor version while still admitting backward-compatible extension.

Worked Example

Note (non-normative): This sub-section is illustrative. The hypothetical bias_correction field described below is not part of the format at descriptor version 1.0; no MXFP trailing field has been appended at 1.0. The example shows how W4, R4, and S4 work together when a future minor revision appends a trailing field.

Worked Example: MXFP Scheme Evolution Across Minor Versions

Suppose a future descriptor version 1.1 appends an optional bias_correction field (float32, 4 bytes) to the MXFP quantization scheme payload (see MXFP (OCP Microscaling) § Binary Encoding) immediately after the existing scale_buffer_index field, extending the MXFP descriptor from 16 bytes to 20 bytes. The following steps illustrate the contract:

  1. Spec amendment (S4). The 1.1 amendment records bias_correction in the defaults table in § Defaults for Appended Trailing Fields with default value 0.0 (IEEE 754 float32 zero).
  2. 1.1 writer (W4). The writer emits the full 20-byte MXFP payload including bias_correction, and sets the enclosing quantization_length prefix to 20 (plus any further trailing bytes added by an even later minor revision the writer participates in).
  3. 1.0 reader against 1.1 data (CD2, R4). The reader sees quantization_length ≥ 20 but parses only the first 16 bytes defined at 1.0. The trailing 4 bytes are skipped via the length prefix; the reader synthesises bias_correction = 0.0 per the defaults table, even though it never actually reads the bytes (a 1.0 reader does not know bias_correction exists, so the synthesised default is only observable to a downstream 1.1 consumer that subsequently reparses the data).
  4. 1.1 reader against 1.1 data. The reader parses all 20 bytes, including bias_correction, and interprets the field directly.
  5. 1.1 reader against 1.0 data (CD1, R4). The reader sees quantization_length = 16 (the 1.0 MXFP length). Every field beyond the data's section length is treated as carrying its documented default; the reader synthesises bias_correction = 0.0 per the defaults table and proceeds with dequantization as if the writer had emitted the default explicitly. No descriptor rejection occurs.

This example does not introduce any normative requirement that is not already stated by W4, R4, and S4 — it only illustrates how those rules compose.


Writer Requirements

A conforming writer:

  • MUST set version_major and version_minor to the highest descriptor version whose features it actually uses. A writer that only uses descriptor 1.0 features MUST emit version_major = 0x01, version_minor = 0x00, even if the writer's implementation is aware of higher minor versions.
  • MUST set container_version_major and container_version_minor to the highest container version whose features it actually uses, under the same rule.
  • MUST set scheme_version to the lowest scheme version whose features it actually uses for each emitted quantization descriptor.
  • MUST NOT set any reserved flag bit, reserved tag value, or reserved byte unless this specification has defined the corresponding feature.

Note (non-normative): Writers SHOULD emit the lowest version number compatible with the features they use, to maximise the population of readers that can consume the output. A writer that knows it only emits descriptor 1.0 features SHOULD emit 1.0, not 1.5, even if the writer's library supports both.


Version Registry

Current values for every version field defined in this specification:

AxisFieldLocationCurrent value
Descriptorversion_majortensor descriptor, byte 40x01
Descriptorversion_minortensor descriptor, byte 50x00
Containercontainer_version_majorfile header, byte 80x01
Containercontainer_version_minorfile header, byte 90x00
Quantization schemescheme_version (per-tensor affine, scheme_tag = 0x01)quantization descriptor, byte 10x01
Quantization schemescheme_version (per-channel affine, scheme_tag = 0x02)quantization descriptor, byte 10x01
Quantization schemescheme_version (per-block affine, scheme_tag = 0x03)quantization descriptor, byte 10x01
Quantization schemescheme_version (NF4, scheme_tag = 0x04)quantization descriptor, byte 10x01
Quantization schemescheme_version (MXFP, scheme_tag = 0x05)quantization descriptor, byte 10x01

Note (non-normative): The full set of allocated scheme_tag values is maintained in quantization.md § Scheme Tag Space and its sub-files. This registry tracks only the version of each scheme's wire encoding, not the tag allocation itself. When a new scheme is added to quantization.md, a corresponding row MUST be added to this table.


Relationship to Other Sections

  • metadata.md establishes the normative reader rules for the descriptor version and the structure of the descriptor that the version describes.
  • file-format.md establishes the normative reader rules for the container version and the structure of the file that the version describes.
  • quantization.md establishes the normative reader rules for the quantization scheme version and defines which scheme tags exist.
  • element-types.md, memory-layout.md, and the per-layout files under layouts/ define the tag spaces (type tags, layout tags) whose allocation is gated by the descriptor version policy stated in this section.

References — Hurray Format Specification

Status: Draft

Normative References

The following documents are referenced normatively by one or more sections of the Hurray format specification. Conforming implementations MUST comply with the relevant portions of these documents as cited.


[RFC2119]

Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, March 1997.

https://www.rfc-editor.org/rfc/rfc2119

Used by: All sections. Normative keywords (MUST, SHOULD, MAY, etc.) are interpreted as defined in this document.


[IEEE754]

IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019, July 2019.

https://ieeexplore.ieee.org/document/8766229

Used by: element-types.md — float16 (binary16), float32 (binary32), float64 (binary64) bit-pattern definitions and NaN/infinity handling rules.


[OFP8]

Open Compute Project, "OCP 8-bit Floating Point Specification (OFP8)", Version 1.0, September 2023.

https://www.opencompute.org/documents/ocp-8-bit-floating-point-specification-ofp8-revision-1-0-2023-12-01-pdf-1

Used by: element-types.md — float8_e4m3 and float8_e5m2 bit-pattern definitions. quantization/mxfp.md — float8_e8m0 scale factor encoding and the MXFP8 element type definitions.


[OCPMX]

Open Compute Project, "OCP Microscaling Formats (MX) Specification", Version 1.0, 2023.

https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf

Used by: quantization/mxfp.md — MXFP block size (32), float8_e8m0 shared exponent scale encoding, and valid MXFP element types.


[QLoRA]

Dettmers, T., Pagnoni, A., Rodola, G., and Zettlemoyer, L., "QLoRA: Efficient Finetuning of Quantized LLMs", arXiv:2305.14314, May 2023.

https://arxiv.org/abs/2305.14314

Used by: quantization/nf4.md — the NF4 lookup table values and the NF4 dequantization formula are taken from the reference implementation accompanying this paper.


[SKILLING2004]

Skilling, J., "Programming the Hilbert Curve", AIP Conference Proceedings, Volume 707, pp. 381–387, 2004.

https://doi.org/10.1063/1.1751381

Used by: layouts/hilbert.md — the normative CoordsToHilbert and HilbertToCoords algorithms are the algorithms defined in this paper.


Informative References

The following documents are referenced for context or as prior art. They are not normative; conforming implementations need not comply with them.


[DLPACK]

DLPack: Open In-Memory Tensor Structure.

https://github.com/dmlc/dlpack

Referenced by: buffer-protocol.md — the release callback (single deleter) model is inspired by DLManagedTensor.deleter. docs/impl/python-bindings.md — the __dlpack__ / __dlpack_device__ protocol for Python zero-copy interop.


[ARROW]

Apache Arrow: A cross-language development platform for in-memory data.

https://arrow.apache.org

Referenced by: buffer-protocol.md and interchange.md — IPC framing and buffer protocol design reference. docs/prior-art.md § 4.2.1.


[ARROWFLIGHT]

Apache Arrow Flight: A framework for high-performance data services.

https://arrow.apache.org/docs/format/Flight.html

Referenced by: interchange.md — streaming RPC model reference. docs/prior-art.md § 4.2.2.


[SAFETENSORS]

Hugging Face SafeTensors: Safe serialization for tensors.

https://github.com/huggingface/safetensors

Referenced by: docs/prior-art.md § 4.3.1.


[GGUF]

GGUF: GPT-Generated Unified Format (llama.cpp).

https://github.com/ggerganov/ggml/blob/master/docs/gguf.md

Referenced by: quantization/per-block-affine.md — per-block affine quantization covers the GGUF block quantization family (Q4_0, Q4_1, Q8_0). docs/prior-art.md § 4.3.2.


[ONNX]

Open Neural Network Exchange (ONNX): An open standard for machine learning interoperability.

https://onnx.ai

Referenced by: element-types.md — type system breadth reference. data-model.md — zero-size dimension policy comparison.


[ZARR]

Zarr: Chunked, compressed, N-dimensional arrays.

https://zarr.dev

Referenced by: docs/prior-art.md § 4.3.3 — chunk/shard layout reference.


[ARRAYAPI]

Consortium for Python Data API Standards, "Python Array API Standard".

https://data-apis.org/array-api

Referenced by: element-types.md — Tier 1 type vocabulary alignment with the Python Array API Standard.


[UCX]

Unified Communication X (UCX): An optimized communication layer.

https://openucx.org

Referenced by: interchange.md — RDMA data plane, UCX packed rkey blob as one possible RDMA key encoding.


[BITSANDBYTES]

Dettmers, T. et al., bitsandbytes: 8-bit optimizers and quantization.

https://github.com/TimDettmers/bitsandbytes

Referenced by: quantization/nf4.md — block_size = 64 is the bitsandbytes default for NF4 quantization.

Hurray Implementation Requirements

This directory contains requirements for implementations of the Hurray format. These requirements are distinct from the format specification (docs/spec/):

docs/spec/docs/impl/
ScopeWhat the binary encoding and protocol must look likeWhat a conforming implementation must provide
LanguageLanguage-agnosticSpecific to each implementation layer
AudienceAnyone writing a Hurray reader/writerAuthors of the Hurray implementations themselves

The format spec is the source of truth for the wire format. These documents define the API contracts, binding conventions, compliance criteria, and quality requirements that the reference implementation and language bindings must satisfy.

Implementations in Scope

ImplementationLanguageCrate / Module
Reference implementationRusthurray-core, hurray-io
C FFI layerC (via Rust)hurray-ffi
Python bindingsPython (via PyO3)hurray-python

Documents

DocumentDescription
ComplianceConformance levels, mandatory vs optional feature support, test surface
Implementation StatusWhich of those features each implementation actually provides — generated from the crates, the Python module, and the C header
Rust ReferenceRequirements for hurray-core and hurray-io
C FFIC ABI layer requirements: opaque handles, function table, panic safety
Python BindingsPython codec + zero-copy bridge: DLPack, NumPy/PyTorch interop, native Hurray buffer protocol

Compliance Requirements — Hurray Implementation Requirements

Overview

A conforming Hurray implementation is one that correctly reads and writes the binary format as defined in docs/spec/. This document defines conformance levels, the mandatory and optional feature surface, and the test requirements that determine whether an implementation is conforming.

Conformance Levels

Level 1 — Reader

A Level 1 conforming implementation can read any valid Hurray tensor descriptor and associated data buffer for all Tier 1 element types and all Tier 1 named layouts. It does not need to produce output.

Mandatory:

  • Parse all fixed-header fields (magic, version, flags, type_tag, layout_tag, rank).
  • Reject descriptors with invalid magic, unsupported major version, or set reserved flag bits.
  • Correctly interpret shape, byte_offset, and layout-specific fields for all Tier 1 layouts (0x01–0x09).
  • Read and validate the buffer table (count, byte_size, alignment, device_tag).
  • Skip optional sections using descriptor_length when flags are not understood.
  • Return an error for unrecognised Tier 1 layout or type tags (unless in permissive mode).

Optional:

  • Tier 2 element types (float8_e4m3, float8_e5m2, float8_e8m0, complex64, complex128, sub-byte types).
  • Tier 2 layouts (hilbert, tag 0x40).
  • Quantization section (HAS_QUANTIZATION).
  • Statistics section (HAS_STATISTICS).
  • Extension type and layout tags.
  • Permissive mode.

Level 2 — Writer

A Level 2 conforming implementation can write valid Hurray tensor descriptors and data buffers. Level 2 implies Level 1.

Mandatory (in addition to Level 1):

  • Emit a correctly structured fixed header (magic 0x48 0x52 0x52 0x59, current version 0x01 0x00).
  • Compute and emit a correct descriptor_length.
  • Emit all mandatory fields for the chosen layout tag.
  • Emit a buffer table with correct byte_size and alignment (minimum 64 bytes).
  • Set byte_offset = 0 for sparse layouts (COO, CSR, CSC, CSF).
  • Set reserved flag bits to 0.

Level 3 — Network Transport

A Level 3 conforming implementation supports the Hurray network transport protocol as defined in docs/spec/interchange.md. Level 3 implies Level 2.

Mandatory:

  • Implement CLIENT_HELLO / SERVER_HELLO session establishment.
  • Implement TENSOR_REQUEST / TENSOR_DESCRIPTOR / TENSOR_DATA / TENSOR_DATA_END.
  • Implement ERROR, PING, PONG.
  • Correctly handle descriptor_length to delimit descriptors in the stream.

Optional:

  • Layout negotiation (supported_layouts, ALLOW_TRANSCODE).
  • On-the-fly transcoding (TRANSCODING capability flag).
  • Parallel shard transfers (PARALLEL_STREAMS, PARALLEL_OK).
  • RDMA data plane (RDMA_DATA_PLANE, RDMA_REGISTER, RDMA_READY).
  • TENSOR_PUT / TENSOR_PUT_ACK.

Mandatory Element Type Support

All conforming implementations MUST support Tier 1 element types:

TypeTag
float160x01
bfloat160x02
float320x03
float640x04
int80x10
uint80x11
int160x12
uint160x13
int320x14
uint320x15
int640x16
uint640x17
bool0x20

"Support" means: correctly read the descriptor, compute element addresses and buffer sizes, and preserve element bit patterns exactly during zero-copy interchange. An implementation is not required to perform arithmetic on every supported type.

Mandatory Layout Support

All conforming implementations MUST support Tier 1 layouts (0x01–0x0B, the full core named-layout range) for reading descriptors:

LayoutTag
Row-major0x01
Column-major0x02
Strided0x03
Tiled / Blocked0x04
Morton0x05
COO0x06
CSR0x07
CSC0x08
CSF0x09
Block-paged0x0A
Composite / Virtual0x0B

Test Requirements

A conforming implementation MUST pass a test suite that covers:

  • Descriptor parsing: valid descriptors for all mandatory type × layout combinations.
  • Rejection cases: invalid magic, unsupported major version, reserved flag bits set, out-of-bounds byte_offset, invalid shard offset, rank = 65 (descriptor MUST be rejected per data-model.md § Rank, ADR-008).
  • Round-trip: write a tensor descriptor, read it back, verify all fields are identical.
  • Buffer size: verify computed buffer sizes match expected values for all mandatory types and layouts.
  • Zero-copy invariant: verify that bit patterns are preserved exactly after a round-trip (no NaN canonicalization, no subnormal flushing).
  • Sparse invariant validation: for COO, CSR, CSC — verify that constraint violations are rejected. For CSF — verify that violations of per-level pos monotonicity, crd sortedness within each parent slice, and mode_order permutation validity are rejected.

Empty Tensor Round-Trip Vectors

Per ADR-007 (permit empty tensors), a conforming implementation MUST round-trip the following empty-tensor descriptors without loss of information:

  • A rank-1 tensor with shape [0] (empty vector).
  • A rank-3 tensor with shape [3, 0, 5] (zero-size middle dimension; element_count = 0).
  • A rank-2 CSR sparse matrix with shape = [4, 5] and nnz = 0 (no stored non-zeros; row_ptr = [0, 0, 0, 0, 0], values and col_indices buffers have byte_size = 0).

For each empty-tensor vector: writer emits the descriptor with a complete buffer table (zero-size buffers permitted), reader accepts the descriptor without error, all descriptor fields match exactly after round-trip.

The reference Rust implementation provides the canonical test suite. Language binding test suites SHOULD mirror the reference suite and additionally test language-specific interoperability (see python-bindings.md, c-ffi.md).

Implementation Status

Which spec features each Hurray implementation actually provides. Rows are format features, taken from Compliance; columns are implementations.

ImplementationCovers
hurray-core + hurray-ioRust reference implementation
hurray-pythonPython bindings (PyO3)
hurray-ffiC ABI surface

This page is generated, never hand-written. Tag coverage is read back out of the decoders one byte at a time, each capability is proven by an encode → decode round-trip, the Python column is introspected from the compiled module, and the C column is the set of symbols the generated header exports. CI regenerates the page and fails if it differs from the committed copy, so a feature an implementation gains — or loses — cannot pass unreported.

What no probe can detect carries a declared status and a justification, listed under the table it appears in.

Legend

  • ✅  Implemented
  • ◐  Partly implemented — see the note
  • ❌  Not implemented
  • ➖  Not this layer's to implement — see the note
  • 📄  Specified, implemented nowhere — see the note

Adding a third-party implementation is a column, not a redesign: append an [[implementations]] block to website/coverage-matrix.toml with default = "no", then override that per section or per row as the implementation covers more. No row changes shape.

Conformance Levels

The three levels defined in Compliance. Level 2 implies Level 1, and Level 3 implies Level 2.

Featurehurray-core + hurray-iohurray-pythonhurray-ffi
Level 1 — Reader✅✅◐ (a)
Level 2 — Writer✅✅❌
Level 3 — Network transport📄 (b)📄 (b)📄 (b)
  • (a) hurray_descriptor_decode parses and validates a descriptor through hurray-core, so the reading itself is complete, but the C surface exposes only the type tag, layout tag, rank, shape, byte offset, and buffer table. Layout-specific payload fields — CSR nnz, tile shape, the block table — have no accessor, so a C caller cannot yet interpret every Tier 1 layout it can decode.
  • (b) No crate implements any part of the Level 3 transport: none of the message types, capability flags, or the RDMA data plane appear anywhere in hurray-core, hurray-io, hurray-ffi, hurray-python, or hurray-inspect. hurray-io implements the stream framing and the file container — Levels 1 and 2 — and stops there. The protocol is specified so that an implementation has something to build against; scheduling one is tracked separately.

Element Types

Tier 1 types are mandatory for every conforming implementation; Tier 2 types are optional. Extension types (0xF0–0xFE) are listed under Optional Descriptor Sections, since the type tag and its describing section are one feature on the wire.

FeatureTagTierhurray-core + hurray-iohurray-pythonhurray-ffi
float160x011✅✅➖
bfloat160x021✅✅➖
float320x031✅✅➖
float640x041✅✅➖
int80x101✅✅➖
uint80x111✅✅➖
int160x121✅✅➖
uint160x131✅✅➖
int320x141✅✅➖
uint320x151✅✅➖
int640x161✅✅➖
uint640x171✅✅➖
bool0x201✅✅➖
float8_e4m30x402✅✅➖
float8_e5m20x412✅✅➖
float8_e8m00x422✅✅➖
float4_e2m10x432✅✅➖
float6_e2m30x442✅✅➖
float6_e3m20x452✅✅➖
float1280x462✅✅➖
int40x482✅✅➖
uint40x492✅✅➖
int20x4A2✅✅➖
uint20x4B2✅✅➖
complex640x502✅✅➖
complex1280x512✅✅➖
  • hurray-ffi — The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag, hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change to hurray-ffi.

Memory Layouts

Tier 1 layouts (0x01–0x0B) are mandatory for reading; Hilbert (0x40) is Tier 2.

FeatureTagTierhurray-core + hurray-iohurray-pythonhurray-ffi
Row-major0x011✅✅➖
Column-major0x021✅✅➖
Strided0x031✅✅➖
Tiled / blocked0x041✅✅➖
Morton (Z-order)0x051✅✅➖
COO0x061✅✅➖
CSR0x071✅✅➖
CSC0x081✅✅➖
CSF0x091✅✅➖
Block-paged0x0A1✅✅➖
Composite / virtual0x0B1✅✅➖
Hilbert curve0x402✅✅➖
  • hurray-ffi — The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag, hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change to hurray-ffi.

Quantization Schemes

Carried in the optional quantization section. Support means encoding and decoding the scheme descriptor; performing the dequantization arithmetic is a compute concern and outside the format's scope.

FeatureTagTierhurray-core + hurray-iohurray-pythonhurray-ffi
Per-tensor affine0x011✅✅➖
Per-channel affine0x021✅✅➖
Per-block affine0x031✅✅➖
NF4 (NormalFloat4)0x042✅✅➖
MXFP (OCP Microscaling)0x052✅✅➖
  • hurray-ffi — The C ABI reports the raw tag byte (hurray_descriptor_element_type_tag, hurray_descriptor_layout_tag) and never interprets it. There is no per-tag support to report at this layer: a C caller decides for itself which tags it can handle, and gains a new one without any change to hurray-ffi.

Optional Descriptor Sections

Sections a descriptor may carry beyond the fixed header, each gated by a flag bit. A reader that does not understand one skips it using descriptor_length, so none of these is required at any conformance level.

Featurehurray-core + hurray-iohurray-pythonhurray-ffi
Quantization✅✅❌
Shard✅✅❌
Statistics✅✅❌
Extension type (0xF0–0xFE)✅✅❌
Composite member✅✅❌

Streaming and File Interchange

The two framings that share the descriptor encoding: the self-delimiting stream format and the HRRYFILE container.

Featurehurray-core + hurray-iohurray-pythonhurray-ffi
Stream — write✅✅❌
Stream — read✅✅❌
Stream — composite tensors✅✅❌
File — write✅✅❌
File — read✅✅❌
File — KV metadata section✅✅❌
File — composite tensors✅✅❌

Network Transport (Level 3)

The cross-machine protocol in Interchange. Specified in full, implemented nowhere — see the note below the table.

Featurehurray-core + hurray-iohurray-pythonhurray-ffi
Session establishment (CLIENT_HELLO / SERVER_HELLO)📄📄📄
Tensor transfer (TENSOR_REQUEST / TENSOR_DESCRIPTOR / TENSOR_DATA / TENSOR_DATA_END)📄📄📄
Control messages (ERROR / PING / PONG)📄📄📄
Client push (TENSOR_PUT / TENSOR_PUT_ACK)📄📄📄
Layout negotiation (supported_layouts, ALLOW_TRANSCODE)📄📄📄
On-the-fly transcoding (TRANSCODING)📄📄📄
Parallel shard transfers (PARALLEL_STREAMS, PARALLEL_OK)📄📄📄
RDMA data plane (RDMA_DATA_PLANE, RDMA_REGISTER, RDMA_READY)📄📄📄
  • Every column — No crate implements any part of the Level 3 transport: none of the message types, capability flags, or the RDMA data plane appear anywhere in hurray-core, hurray-io, hurray-ffi, hurray-python, or hurray-inspect. hurray-io implements the stream framing and the file container — Levels 1 and 2 — and stops there. The protocol is specified so that an implementation has something to build against; scheduling one is tracked separately.

Rust Reference Implementation Requirements — Hurray Implementation Requirements

Overview

The Rust reference implementation is the canonical implementation of the Hurray format. It is authoritative: when the spec is ambiguous, the reference implementation defines the correct behaviour. When the implementation deviates from the spec, the implementation is wrong.

The implementation is split across three crates:

CrateResponsibility
hurray-coreFormat types, tensor descriptor, buffer handle, quantization descriptors. No I/O, no async.
hurray-ioAsync streaming read/write, file format support. Depends on hurray-core and tokio.
hurray-ffiC ABI layer. See c-ffi.md.

hurray-core

Type System

  • MUST define a TensorDescriptor type that encodes all fields from docs/spec/metadata.md.
  • MUST define an ElementType enum covering all Tier 1 and Tier 2 type tags.
  • MUST define a LayoutTag enum covering all named layout tags.
  • MUST define layout-specific descriptor types for each named layout (e.g., StridedLayout, TiledLayout).
  • MUST define a BufferHandle type carrying byte_size, alignment, device_tag, and a release callback (see c-ffi.md).
  • MUST define an Error enum via thiserror. No unwrap() or expect() in library code.

Serialization

  • MUST implement binary serialization of TensorDescriptor to the wire format defined in docs/spec/metadata.md.
  • MUST implement binary deserialization with full validation (magic, version, flag bits, bounds checks, sparse invariants).
  • Serialization MUST be no_std-compatible when the alloc feature is enabled.
  • A serde feature gate MUST provide serde::Serialize / serde::Deserialize for TensorDescriptor (JSON/CBOR interchange for tooling, not the wire format).

Buffer Safety

  • unsafe code MUST be isolated in dedicated modules.
  • Every unsafe block MUST have a // SAFETY: comment explaining the invariant that makes the code sound.
  • Buffer aliasing across runtimes MUST be mediated through the BufferHandle reference count and release callback.

Correctness

  • cargo clippy -- -D warnings MUST pass.
  • All public items MUST have /// doc comments with at least one example.
  • Test coverage for the public API MUST be ≥ 80%.

hurray-io

Streaming Read

  • MUST implement an async tensor descriptor reader that reads exactly descriptor_length bytes before emitting a parsed TensorDescriptor.
  • MUST implement an async data frame reader that yields data in chunks without buffering the entire tensor.
  • A reader MUST be able to start processing tensor data without buffering the entire input (streamable principle).

Streaming Write

  • MUST implement an async tensor descriptor writer that emits the descriptor before any data bytes.
  • MUST implement an async data frame writer that emits data incrementally.
  • A writer MUST be able to emit tensors one at a time without buffering the entire output.

Async Runtime

  • MUST use tokio as the async runtime.
  • MUST NOT mix rayon thread pool calls directly in async contexts. CPU-bound operations MUST use tokio::task::spawn_blocking.
  • All async functions MUST be Send + 'static to support multi-threaded tokio runtimes.

Streaming Format

  • MUST support reading and writing the streaming IPC format defined in docs/spec/interchange.md: a sequence of zero or more tensor descriptors + data buffers, terminated by an end-of-stream marker.
  • The streaming format MUST be self-delimiting: descriptor_length allows a reader to advance past any descriptor without full parsing.
  • Back-references and end-of-file indexes are forbidden in the streaming format (streamable principle).

File Format

  • MUST support reading and writing the Hurray file format defined in docs/spec/file-format.md. The file format is a single-pass writable, random-access readable container for one or more named tensors.
  • A writer MUST emit the file in a single forward pass (no seek-back), producing the structure: HRRYFILE magic + 64-byte file header + zero or more tensor regions (each tensor descriptor followed by its data buffer(s) with appropriate padding) + optional KV metadata section + index section + 40-byte trailer at file_size - 40.
  • The writer MUST track per-tensor (name, descriptor_offset, descriptor_length, data_offset, data_length) tuples in memory and emit them in the index section after all tensor regions are written.
  • A random-access reader MUST locate the trailer by seeking to file_size - 40, verify trailer_magic (ASCII HRRY), and use index_offset / index_length from the trailer to locate the index section.
  • When the HAS_INDEX_CRC32C file flag is set, the reader MUST verify the CRC-32C of the index section and reject the file on mismatch.
  • The implementation MUST support mmap-based zero-copy loading of tensor data buffers when the file's data_buffer_alignment matches or exceeds the host page size.

Code Quality

  • No unwrap() or expect() in library code. All errors propagate with ?.
  • Feature flags: serde (serialization support), tokio (async I/O, enabled in hurray-io).
  • MSRV (minimum supported Rust version): tracked in Cargo.toml and enforced in CI.
  • All changes to hurray-core and hurray-io MUST be reviewed by the rust-reviewer agent before merge.

C FFI Layer Requirements — Hurray Implementation Requirements

Overview

The hurray-ffi crate exposes a stable C ABI that allows non-Rust runtimes to consume and produce Hurray tensors without depending on Rust tooling. It is the foundation for all non-Python language bindings.

ABI Stability

  • All public symbols MUST use the #[no_mangle] attribute and extern "C" linkage.
  • The ABI MUST be declared stable across patch versions and SHOULD be stable across minor versions. Breaking ABI changes require a major version bump.
  • All struct layouts exposed across the FFI boundary MUST be #[repr(C)].
  • Enums exposed across the FFI boundary MUST be #[repr(u8)] or #[repr(i32)] as appropriate, never #[repr(Rust)].

C ABI Version

The C ABI carries a single uint32 version identifier exposed via a constant HURRAY_C_ABI_VERSION and a runtime accessor hurray_c_abi_version(). The current version is 4:

VersionChanges
1Initial C ABI: opaque handles, buffer release callbacks, panic-safe error returns.
2Per-mode buffer handoff sync payloads (SYNC_PRODUCER_SYNCED, SYNC_EVENT, SYNC_CONSUMER_STREAM) and the event-release callback. See Buffer Handoff Synchronisation.
3HurrayBufferList for multi-buffer tensors, and the native protocol capsule now wraps a list rather than a single HurrayBuffer (ADR-030). See Buffer Lists.
4HurrayTensorContext, so a consumer in any language can read a capsule's descriptor and ABI version (ADR-034). See Tensor Context.

A consumer of the C ABI MUST query hurray_c_abi_version() before invoking any function whose contract changed in a later version. A consumer compiled against version 1 of the ABI that links against a runtime providing version 2 will receive buffer handles whose sync_mode is SYNC_PRODUCER_SYNCED (0x00) by default, which is the safe fallback: a version-1 consumer that does not inspect sync_mode will still observe the strongest synchronisation guarantee and will not race against the producer's device writes.

Opaque Handles

All Hurray objects crossing the FFI boundary MUST be represented as opaque pointer handles. Callers MUST NOT dereference or inspect the pointed-to memory directly.

Handle typeRepresents
HurrayDescriptor*A parsed tensor descriptor
HurrayBuffer*A buffer handle (data + metadata)
HurrayBufferList*An ordered, owning collection of buffer handles
HurrayTensorContext*A capsule's descriptor bytes, ABI version, and owner reference
HurrayReader*A streaming tensor reader
HurrayWriter*A streaming tensor writer

Each handle is obtained from a hurray_*_create function and MUST be released by the corresponding hurray_*_destroy function. Double-free and use-after-free are undefined behaviour on the caller side; the implementation MUST detect them in debug builds (e.g., via a poisoned sentinel).

Buffer Lists

A tensor whose descriptor references more than one buffer — per-channel / NF4 / MXFP quantization, sparse layouts, block-paged, composite — needs all of its buffers to travel together. HurrayBufferList is that carrier (ADR-030).

FunctionContract
hurray_buffer_list_new(capacity, out_list)Creates an empty list. capacity is a hint.
hurray_buffer_list_push(list, buffer)Appends buffer, transferring ownership to the list on success. On failure ownership stays with the caller.
hurray_buffer_list_len(list, out_len)Number of buffers in the list.
hurray_buffer_list_get(list, index, out_buffer)Borrows the handle at index. Returns HURRAY_ERR_INDEX_OUT_OF_BOUNDS if index >= len.
hurray_buffer_list_destroy(list)Destroys the list and every handle it owns, then writes null through list.

The following rules are normative.

  • A list owns every handle pushed into it. A handle obtained from hurray_buffer_list_get is borrowed: the caller MUST NOT call hurray_buffer_destroy on it. It remains valid until the list is destroyed.
  • Buffers MUST be pushed in descriptor buffer-table order: element i of the list is buffer index i of the descriptor.
  • hurray_buffer_list_destroy takes a pointer to the caller's handle variable and MUST write null through it, so the caller's variable is observably dead and a repeated destroy is a no-op. This is the sound half of the "release marks the structure released" discipline: the list allocation is freed, so only memory the caller owns can carry the marker.
  • Destroying a list MUST destroy every owned handle exactly once, and MUST null each owned slot as it goes, so that a release callback which panics or re-enters cannot cause a double free.

Tensor Context

A native-protocol capsule carries two things: a HurrayBufferList as its pointer, and a HurrayTensorContext as its context (ADR-034). The list holds the bytes; the context holds the encoded tensor descriptor that says what those bytes are, plus the ABI version of the build that produced them.

Before ADR-034 the context was a structure private to hurray-python, so the buffers crossed the language boundary and the descriptor did not. A consumer in any other language received element bytes with no element type, shape, layout, or quantization.

HurrayStatus hurray_tensor_context_new(uint32_t abi_version,
                                       const uint8_t *descriptor_bytes,
                                       uint64_t descriptor_len,
                                       void *owner,
                                       HurrayOwnerReleaseFn owner_release,
                                       struct HurrayTensorContext **out_ctx);

HurrayStatus hurray_tensor_context_abi_version(const struct HurrayTensorContext *ctx,
                                               uint32_t *out);
HurrayStatus hurray_tensor_context_descriptor(const struct HurrayTensorContext *ctx,
                                              const uint8_t **out_bytes,
                                              uint64_t *out_len);
HurrayStatus hurray_tensor_context_destroy(struct HurrayTensorContext **ctx);
  • A consumer MUST call hurray_tensor_context_abi_version first and compare the result against its own HURRAY_C_ABI_VERSION before calling any other accessor. That ordering is what allows later ABI versions to add accessors without breaking older consumers: a consumer that checked the version knows which ones exist.
  • hurray_tensor_context_descriptor returns a borrow owned by the context, valid until the context is destroyed. A caller that needs it longer MUST copy it. An empty descriptor reports a null pointer and a zero length.
  • The context owns a copy of the descriptor bytes; a borrow would tie its validity to a buffer the producer may drop.
  • owner and owner_release are opaque and never interpreted. They exist so a producer can keep whatever owns the tensor's memory alive for the capsule's lifetime — hurray-python parks a Python object reference there, which is how a Python type is kept out of the C ABI entirely. owner_release is invoked exactly once, during hurray_tensor_context_destroy.
  • Destroying a null handle is a no-op returning HURRAY_OK, so cleanup paths may call it unconditionally. Destroy nulls the caller's pointer.

Panic Safety

Rust panics MUST NOT propagate across the FFI boundary. Every extern "C" function that calls Rust code MUST wrap the call in std::panic::catch_unwind. If a panic is caught, the function MUST:

  1. Log or store the panic message (implementation-defined).
  2. Return a well-defined error code (e.g., HURRAY_ERR_INTERNAL_PANIC).
  3. Leave no partially-constructed state visible to the caller.

Error Handling

All fallible FFI functions MUST return an error code of type HurrayStatus (int32). The value 0 (HURRAY_OK) indicates success. All other values indicate errors.

typedef int32_t HurrayStatus;

#define HURRAY_OK                    0
#define HURRAY_ERR_INVALID_MAGIC    -1
#define HURRAY_ERR_VERSION_MISMATCH -2
#define HURRAY_ERR_INVALID_LAYOUT   -3
#define HURRAY_ERR_INVALID_TYPE     -4
#define HURRAY_ERR_BUFFER_TOO_SMALL -5
#define HURRAY_ERR_NULL_POINTER     -6
#define HURRAY_ERR_INTERNAL_PANIC   -7
/* ... */

Functions MUST return HURRAY_ERR_NULL_POINTER for any required pointer argument that is NULL, without invoking undefined behaviour.

Buffer Release Callbacks

Buffer handles carry a release callback to support zero-copy buffer sharing with non-Rust runtimes. When hurray-ffi wraps an externally-owned buffer, the caller provides a release function and a context pointer:

typedef void (*HurrayReleaseCallback)(void* buffer, void* context);

HurrayStatus hurray_buffer_from_ptr(
    void*                 data,
    uint64_t              byte_size,
    uint32_t              alignment,
    uint8_t               device_tag,
    HurrayReleaseCallback release,
    void*                 release_context,
    HurrayBuffer**        out_handle
);

The release callback MUST be called exactly once when the buffer's reference count reaches zero. The implementation MUST NOT call the release callback from a destructor that runs on a foreign thread without the caller's consent.

Thread Safety

  • All handles MUST be safe to use from a single thread at a time (i.e., Send but not Sync in Rust terms).
  • Concurrent access to the same handle from multiple threads is undefined behaviour unless documented otherwise.
  • Reference counting for shared buffer handles MUST be performed with atomic operations (std::sync::atomic).

Naming Conventions

All public symbols MUST be prefixed with hurray_. Type names use Hurray prefix with PascalCase. Error codes use HURRAY_ERR_ prefix with SCREAMING_SNAKE_CASE.

Header Generation

A C header file (hurray.h) MUST be generated from the Rust source using cbindgen as part of the build process. The generated header MUST be checked into the repository and kept in sync with the Rust source. CI MUST fail if the generated header differs from the committed one.

Buffer Handoff Synchronisation

The buffer protocol's sync_mode field (see docs/spec/buffer-protocol.md § Stream and Event Synchronisation) declares one of three producer-side synchronisation mechanisms in the binary descriptor. The C ABI carries the corresponding payload out of band; the discriminant itself is read from the buffer handle's binary descriptor and is NOT duplicated in the ABI struct.

The ABI layer MUST cross-check the sync_mode declared in the descriptor against the payload provided at handoff time. A mismatch is a producer-side bug; the ABI MUST reject the handoff with HURRAY_ERR_SYNC_MODE_MISMATCH before returning a buffer handle to the consumer.

Per-Mode Payloads

sync_mode = SYNC_PRODUCER_SYNCED (0x00)

No additional fields beyond the buffer pointer, byte size, alignment, device tag, release callback, and release context defined in Buffer Release Callbacks.

The producer MUST have issued a host-side wait on the device stream(s) that wrote the buffer before calling the handoff function. For CPU buffers (device_tag == 0x00), the producer MUST have issued a host memory fence if concurrent host-side writes exist.

sync_mode = SYNC_EVENT (0x01)

The handoff struct carries an opaque event handle and an event-release callback in addition to the buffer fields:

FieldTypeDescription
sync_handleopaque pointer (void*)Device-vendor-specific event handle (e.g., cudaEvent_t, hipEvent_t, MTLSharedEvent, VkSemaphore). Opaque to the ABI layer.
sync_handle_device_taguint8Device tag identifying the event handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not.
event_release_fnvoid (*)(void* sync_handle, void* context)Callback the consumer calls exactly once after issuing its stream-wait. Thread-safe.
event_release_contextopaque pointer (void*)Context pointer passed to event_release_fn.

The producer MUST record the event on the writing stream(s) before calling the handoff function. The producer MUST NOT defer event recording past the handoff; doing so would allow the consumer to issue a stream-wait on an unrecorded event and deadlock.

The consumer MUST issue a device-stream-wait on sync_handle on every stream that will access the buffer before enqueuing any work that touches the buffer's bytes. The consumer MUST call event_release_fn(sync_handle, event_release_context) exactly once after all stream-waits have been issued (typically immediately after handoff). event_release_fn MUST be safe to call from any thread.

The event-release callback is separate from the buffer-release callback defined in Buffer Release Callbacks: a consumer in SYNC_EVENT mode makes two release calls per buffer, with independent lifetimes.

sync_mode = SYNC_CONSUMER_STREAM (0x02)

At handoff request time, the consumer supplies an opaque stream handle to the producer:

FieldTypeDescription
consumer_streamopaque pointer (void*)Device-vendor-specific stream handle (e.g., cudaStream_t, hipStream_t, id<MTLCommandQueue>). Opaque to the ABI layer. Supplied by the consumer at handoff request time.
consumer_stream_device_taguint8Device tag identifying the stream handle's driver context. MUST equal the buffer's device_tag. The ABI MUST reject the handoff if it does not.

The producer MUST issue a device-side ordering dependency from its writing stream(s) onto consumer_stream before the handoff function returns a buffer handle. The producer-side stream is not blocked; the ordering is established on the device.

The consumer MAY access the buffer on consumer_stream after handoff returns, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait on that other stream.

SYNC_CONSUMER_STREAM does not introduce a second release callback: there is no event handle whose lifetime must be managed.

ABI Cross-Check

For every buffer handed off through the C ABI, the implementation MUST:

  1. Read the sync_mode byte at offset 13 of the buffer handle binary descriptor.
  2. Verify that the payload provided by the producer at handoff time matches the declared sync_mode:
    • SYNC_PRODUCER_SYNCED MUST NOT carry a sync_handle or consumer_stream.
    • SYNC_EVENT MUST carry a non-NULL sync_handle, an event_release_fn, and a sync_handle_device_tag equal to the buffer's device_tag.
    • SYNC_CONSUMER_STREAM MUST carry a non-NULL consumer_stream and a consumer_stream_device_tag equal to the buffer's device_tag.
  3. Reject any reserved sync_mode value (0x03–0xFE) and the invalid value 0xFF with HURRAY_ERR_INVALID_SYNC_MODE.
  4. Reject a mismatch between descriptor and payload with HURRAY_ERR_SYNC_MODE_MISMATCH.

Backward Compatibility for Pre-Version-2 Consumers

A consumer compiled against C ABI version 1 that does not inspect sync_mode will receive buffers whose sync_mode == SYNC_PRODUCER_SYNCED by default. This is the safe fallback: the strongest synchronisation guarantee, no per-mode payload required, and behaviour identical to the version-1 contract. A producer that wishes to interoperate with version-1 consumers MUST set sync_mode = SYNC_PRODUCER_SYNCED for every buffer it hands off and MUST issue the corresponding host-side wait before handoff.

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" wrapping a HurrayBufferList pointer from hurray-ffi. 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 HurrayBufferList owning one HurrayBuffer per buffer. A single-buffer tensor is the N = 1 case, not a separate path.
  • Element i of the 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 list length 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".
  • Capsule name after consumption: "used_hurray_tensor". 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", the destructor MUST call hurray_buffer_list_destroy on the wrapped pointer, which destroys every handle the list owns. If the capsule has been consumed (renamed), the consumer MUST call hurray_buffer_list_destroy exactly once. 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.

The capsule context

The capsule context MUST be a HurrayTensorContext from hurray-ffi (ADR-034), carrying the encoded TensorDescriptor and the producing build's HURRAY_C_ABI_VERSION. It MUST NOT be a structure private to hurray-python: the protocol serves consumers in other languages, and a private structure leaves them the buffers without the descriptor that says what the buffers are.

The strong reference that keeps the source tensor alive MUST travel as the context'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_context_abi_version before any other accessor and before dereferencing the buffer list; a mismatch MUST raise hurray.UnsupportedError. See C FFI § Tensor Context 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 a buffer list and one descriptor; 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 a tensor as soon as its descriptor and buffers have arrived, and a writer MUST emit each tensor as it is given one.

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

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

Blocking

Both classes are blocking. Each owns a tokio runtime for its lifetime and MUST release the GIL around every call into it, so other Python threads run while a stream waits on its transport. An asyncio surface is deliberately absent; see ADR-035 § 1.

hurray.StreamReader

  • MUST implement __iter__ returning itself and __next__ yielding a hurray.Tensor, 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: 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.
  • MUST raise hurray.UnsupportedError, naming the composite, when the stream contains one. It MUST NOT skip it.

hurray.StreamWriter

  • MUST accept a hurray.Tensor in write, transmitting every buffer its descriptor references, in descriptor order.
  • 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
the stream contains a compositehurray.UnsupportedError

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.

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"), or otherwise invalid.
  • hurray.UnsupportedError if the HURRAY_C_ABI_VERSION embedded in the capsule 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 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).

Quickstart

Build a tensor, encode it to the Hurray wire format, and read it back — in Rust with hurray-core, or in Python with the hurray package. Use the tabs to switch languages; your choice is remembered across the book.

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
    TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Describe a float32 [2, 3] row-major tensor.
    let shape = Shape::new(vec![2u64, 3])?;
    let buffer = BufferHandle::new(
        24,                     // 6 elements × 4 bytes
        MIN_BUFFER_ALIGNMENT,   // 64-byte SIMD alignment
        DeviceTag::Cpu,
        SyncMode::ProducerSynced,
    )?;
    let desc = TensorDescriptor::new(
        1, 0,                   // descriptor format version 1.0
        ElementType::Float32,
        shape,
        0,                      // byte_offset to element [0, 0]
        LayoutDescriptor::RowMajor,
        vec![buffer],
        None, None, None, None, // no quantization / shard / statistics / extension-type
    )?;

    // Encode to the self-delimiting wire format …
    let bytes = desc.encode()?;
    println!("descriptor: {} bytes", bytes.len());

    // … and decode it straight back.
    let decoded = TensorDescriptor::decode(&bytes)?;
    assert_eq!(decoded, desc);
    println!(
        "shape = {:?}, dtype = {:?}",
        decoded.shape.dims(),
        decoded.element_type,
    );
    Ok(())
}
import os
import tempfile

import numpy as np
import hurray

# Build a float32 [2, 3] tensor, zero-copy from a NumPy array.
arr = np.arange(6, dtype=np.float32).reshape(2, 3)
t = hurray.from_numpy(arr)
print("shape =", t.shape, "dtype =", t.dtype, "device =", t.device)

# Hand it back to NumPy zero-copy via DLPack (dense Tier-1 tensors share the buffer).
view = np.from_dlpack(t)
assert np.array_equal(view, arr)

# Round-trip through the Hurray file format.
path = os.path.join(tempfile.gettempdir(), "quickstart.hrry")
hurray.save(path, {"x": t})
loaded = hurray.load(path)
print("loaded:", list(loaded.keys()), "→", loaded["x"].shape)
os.unlink(path)

What just happened

  • A tensor descriptor carries everything needed to interpret a buffer: element type, shape, byte offset, memory layout, buffer handles, and optional sections (quantization, shard, statistics, extension type). The four trailing Nones in the Rust call are those optional sections.
  • encode produces the self-delimiting binary descriptor — the first 10 bytes give its total length, so a reader can consume it without any external framing. decode reverses it exactly (decoded == desc).
  • On the Python side, np.from_dlpack is zero-copy: the tensor and the array share one buffer. from_numpy shares too when the array's address meets the format's 64-byte alignment floor, and copies into an aligned allocation when it does not — see Buffer Protocol. save/load use the on-disk HRRYFILE container (named tensors, footer index, mmap-friendly alignment).

Where to next

Framework Interop

hurray-python is a codec and zero-copy bridge, not a compute library: you move a tensor into NumPy, PyTorch, JAX, or CuPy and do the math there. For dense Tier 1 tensors the hand-off is DLPack, which every major array framework speaks.

The universal path: DLPack

Any framework whose from_dlpack accepts an object exposing __dlpack__ can consume a Hurray tensor with no copy:

import numpy as np
import hurray

t = hurray.from_numpy(np.arange(6, dtype=np.float32).reshape(2, 3))

arr = np.from_dlpack(t)          # NumPy, zero-copy — shares t's buffer
arr[0, 0] = 42.0
assert hurray.from_numpy(arr)    # the write is visible through the shared buffer

DLPack also carries the tensor's device: t.__dlpack_device__() returns the (DLDeviceType, device_id) pair, so a consumer sends data to the right place.

NumPy

# Ingest a NumPy array zero-copy (C-contiguous):
t = hurray.from_numpy(np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32))

# Export back to NumPy — via DLPack, or the __array__ protocol (with optional cast):
a = np.from_dlpack(t)
a64 = t.__array__(dtype=np.float64)   # __array__ may copy when casting

PyTorch

DLPack works directly (torch.from_dlpack(t)), and hurray ships one-call conveniences:

import torch

torch_t = t.to_torch()               # hurray.Tensor → torch.Tensor, zero-copy
back = hurray.from_torch(torch_t)    # torch.Tensor → hurray.Tensor, zero-copy

JAX

import jax.numpy as jnp

x = jnp.from_dlpack(t)               # zero-copy on the same device

CuPy (GPU)

For a Hurray tensor already on a CUDA device (device_tag = CUDA), CuPy shares the device buffer — no host round-trip:

import cupy as cp

g = cp.from_dlpack(t)                # device-to-device, zero-copy

What DLPack cannot carry

DLPack only describes dense, strided, standard-dtype tensors. Some things fall outside it:

  • bool — Hurray packs it 1 bit per element; DLPack's bool is 1 byte, so there is no zero-copy mapping. Use __array__ / from_numpy instead.
  • bfloat16 — no native NumPy dtype; it crosses to PyTorch/JAX via DLPack but not to plain NumPy.
  • Everything beyond dense Tier 1 — sparse layouts, quantized and sub-byte element types, tiled/Morton/Hilbert/composite layouts. DLPack has no vocabulary for these.

For those, use Hurray's own full-fidelity protocol.

The native protocol

__hurray__ / hurray.from_hurray exchange the entire tensor descriptor — quantization, sparse and exotic layouts, sub-byte types, device and sync metadata — between Hurray-aware components, zero-copy:

capsule = t.__hurray__()      # full-fidelity, all dtypes
u = hurray.from_hurray(t)     # reconstruct from any object exposing it

What adoption would unlock (non-normative). Today only hurray-python implements __hurray__, so full-fidelity exchange is Hurray-to-Hurray. If a framework adopted the protocol, the copies that live at the edges today would disappear. For example, hurray.from_scipy / hurray.sparse_coo currently repack SciPy's separate row/col arrays into Hurray's packed [nnz, rank] layout (one interleave copy); a SciPy that spoke __hurray__ could hand its sparse structure across without that copy. The same applies to quantized and sub-byte tensors, which have no DLPack representation at all — a consumer implementing the native protocol could receive them directly instead of falling back to save/load.

See also

Quantized Inference

Hurray treats quantization as first-class metadata, orthogonal to the storage type: a tensor is quantized if and only if it carries a quantization descriptor. Five schemes are normative — per-tensor affine, per-channel affine, per-block affine, NF4 (QLoRA), and MXFP (OCP Microscaling) — and the quantization parameters (scales, zero-points) live in separate buffer-table entries, never interleaved with the data, so both stay zero-copy.

This recipe builds a per-block-affine int4 weight tensor in Rust, round-trips it, and reads the scheme back.

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerBlockAffine,
    QuantizationDescriptor, Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A [256, 256] int4 weight matrix, per-block affine along axis 1, block_size 64.
    let shape = Shape::new(vec![256u64, 256])?;

    // Buffer 0 — packed int4 data: 256 × 256 values, 2 per byte = 32,768 bytes.
    let data = BufferHandle::new(
        32_768, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    )?;
    // Buffer 1 — float32 scales, one per block: 256 rows × (256 / 64) = 1024 scales.
    let scales = BufferHandle::new(
        1024 * 4, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    )?;

    // Per-block affine (symmetric): axis 1, block_size 64, scales in buffer index 1,
    // scale element type float32.
    let quant = QuantizationDescriptor::PerBlockAffine(
        PerBlockAffine::new_symmetric(1, 64, 1, ElementType::Float32)?,
    )
    .encode_to_vec();

    let desc = TensorDescriptor::new(
        1, 0,
        ElementType::Int4,          // storage type is orthogonal to the scheme
        shape,
        0,
        LayoutDescriptor::RowMajor,
        vec![data, scales],         // data + scale buffers
        Some(quant),                // HAS_QUANTIZATION
        None, None, None,           // no shard / statistics / extension-type
    )?;

    // Round-trip the descriptor.
    let bytes = desc.encode()?;
    let decoded = TensorDescriptor::decode(&bytes)?;
    assert_eq!(decoded, desc);

    // Read the scheme back from the (raw) quantization section.
    let (q, _) = QuantizationDescriptor::decode(decoded.quantization.as_ref().unwrap())?;
    println!("scheme tag = 0x{:02X}", q.scheme_tag().tag()); // 0x03 = per-block-affine
    if let QuantizationDescriptor::PerBlockAffine(pb) = q {
        println!("axis = {}, block_size = {}", pb.axis(), pb.block_size());
    }
    Ok(())
}

Dequantization

The scheme's dequantization formula is normative (see Layer 2: Quantization Descriptors and docs/spec/quantization.md). For per-block affine, each element uses the scale (and, for asymmetric, the zero-point) of the block it belongs to:

value = (q_code - zero_point) * scale[block_index]

For a symmetric descriptor the zero-point is implicitly 0, so value = q_code * scale[block]. The block index is derived from the element's coordinate on the quantized axis and block_size.

Other schemes

  • NF4 (QLoRA) uses a fixed 16-level lookup table — hurray_core::NF4_LUT — plus a per-block scale: QuantizationDescriptor::Nf4(Nf4::new(axis, block_size, scale_buffer)).
  • MXFP (OCP Microscaling) pairs an 8-bit shared exponent per block with the element micro-floats: QuantizationDescriptor::Mxfp(Mxfp::new(axis, block_size, scale_buffer)).
  • Per-tensor / per-channel affine cover the classic INT8 cases (PerTensorAffine::new, PerChannelAffine::new_symmetric / new_asymmetric).

hurray_core::validate_buffer_placement checks that the scale/zero-point buffer indices a descriptor references actually exist in the buffer table.

Reading quantized tensors elsewhere

Python authors and reads every scheme — per-tensor, per-channel, per-block, NF4 and MXFP — and the multi-buffer descriptors the last four need. See Authoring Quantized Tensors for the constructors and Python: Layouts for how a tensor carries its parameter buffers.

import struct
import hurray

weight_bytes = bytes(1024 * 512)                        # your quantizer's output
scale_bytes = struct.pack("1024f", *[0.02] * 1024)      # one float32 scale per row

weights = hurray.Tensor(
    weight_bytes,
    hurray.dtype.int8,
    [1024, 512],
    aux_buffers=[scale_bytes],
    quantization=hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1),
)
assert weights.quantization.axis == 0

What Python does not do is dequantize: applying the formula above is the consuming framework's job, not the codec's.

To inspect a quantized descriptor byte by byte (scheme, axis, block size, buffer indices), use the CLI:

hurray-inspect weights.hrry

See hurray-inspect CLI; it decodes every quantization scheme and formats Tier 2 / sub-byte element values.

Multi-Buffer Tensors

Most tensors have one buffer. Anything whose descriptor references a second one has more:

FeatureExtra buffers
Per-channel affine quantizationscale buffer, optional zero-point buffer
NF4 / MXFP quantizationscale buffer
COO sparseindex array
CSR / CSC sparseprimary and secondary index arrays
Block-pagedpage table

Per-tensor affine quantization is the exception: its scale and zero_point are inline in the descriptor, so a per-tensor-affine tensor still has exactly one buffer.

The rule that makes this work is positional: element i of the transport is buffer index i of the descriptor's buffer table. A scale_buffer_index of 1 means the second buffer handed over, whatever the transport. Drop a buffer along the way and the descriptor still decodes cleanly — it just points at something that was never delivered.

Carrying every buffer in process

__hurray__ puts all of a tensor's buffers in one capsule wrapping a HurrayBufferList (ADR-030). One capsule, one lifetime, one destroy.

Sparse is not a special case here — it is simply the multi-buffer case, which is why there is no separate __hurray_sparse_buffer__ to probe for.

use hurray_ffi::buffer::{hurray_buffer_byte_size, hurray_buffer_from_ptr};
use hurray_ffi::buffer_list::{
    hurray_buffer_list_destroy, hurray_buffer_list_get, hurray_buffer_list_len,
    hurray_buffer_list_new, hurray_buffer_list_push,
};
use hurray_ffi::{HurrayBuffer, HurrayBufferList, HURRAY_OK};

#[repr(align(64))]
struct Aligned([u8; 64]);

fn main() {
    let mut weights = Aligned([0xAB; 64]);
    let mut scales = Aligned([0x01; 64]);

    let mut list: *mut HurrayBufferList = std::ptr::null_mut();
    // SAFETY: out-pointer is a valid stack variable.
    unsafe { hurray_buffer_list_new(2, &mut list) };

    // Push order is descriptor buffer-table order: weights are index 0, the
    // per-channel scales index 1 — what scale_buffer_index refers to.
    for data in [&mut weights, &mut scales] {
        let mut handle: *mut HurrayBuffer = std::ptr::null_mut();
        // SAFETY: data is 64-byte aligned and 64 bytes long.
        unsafe {
            hurray_buffer_from_ptr(
                data.0.as_mut_ptr().cast(),
                64,
                64,
                0x00, // CPU
                0x00, // ProducerSynced
                0x00, // Standard
                None,
                std::ptr::null_mut(),
                &mut handle,
            );
            // Push transfers ownership of the handle to the list.
            hurray_buffer_list_push(list, handle);
        }
    }

    let mut len: u64 = 0;
    // SAFETY: list is live.
    unsafe { hurray_buffer_list_len(list, &mut len) };
    assert_eq!(len, 2);

    for index in 0..len {
        let mut borrowed: *mut HurrayBuffer = std::ptr::null_mut();
        // SAFETY: list is live and index < len. The handle is BORROWED — the list
        // owns it, so it must not be destroyed here.
        unsafe { hurray_buffer_list_get(list, index, &mut borrowed) };
        let mut byte_size: u64 = 0;
        // SAFETY: borrowed is a live handle owned by the list.
        unsafe { hurray_buffer_byte_size(borrowed, &mut byte_size) };
        println!("buffer[{index}]: {byte_size} bytes");
    }

    // Destroys the list and every handle it owns, exactly once, then nulls the
    // caller's pointer — so a second destroy is a safe no-op.
    // SAFETY: first and only destroy.
    unsafe { hurray_buffer_list_destroy(&mut list) };
    assert!(list.is_null());
}
import numpy as np
import hurray

# A COO tensor keeps values in one buffer and coordinates in another.
values = np.array([5.0, 7.0], dtype=np.float32)
indices = np.array([[0, 0], [1, 1]], dtype=np.uint64)  # [nnz, rank]
sparse = hurray.sparse_coo(values, indices, [2, 2])

# One protocol for every tensor kind — probe exactly as for a dense tensor.
assert hasattr(sparse, "__hurray__")
assert not hasattr(sparse, "__hurray_sparse_buffer__")

capsule = sparse.__hurray__()

# The consumer receives the full descriptor with every buffer attached, in
# descriptor order: values first, then the index array.
back = hurray.from_hurray(sparse)
assert back.shape == (2, 2)
assert back.dtype == hurray.float32

Ownership: one owner, everything else borrowed

The list owns every handle in it. hurray_buffer_list_get returns a borrowed pointer: the caller reads it and must not destroy it. Destroying the list destroys every handle exactly once.

This is the same discipline as Arrow's C Data Interface, where a consumer releases the base structure but never its children. Getting it wrong in the other direction — a consumer destroying a borrowed handle — double-frees when the list is destroyed.

hurray_buffer_list_destroy takes a pointer to your pointer and writes null through it:

use hurray_ffi::buffer_list::{hurray_buffer_list_destroy, hurray_buffer_list_new};
use hurray_ffi::HurrayBufferList;
let mut list: *mut HurrayBufferList = std::ptr::null_mut();
unsafe { hurray_buffer_list_new(0, &mut list) };
// SAFETY: list is live; first and only destroy.
unsafe { hurray_buffer_list_destroy(&mut list) };
assert!(list.is_null());

// Idempotent: destroying an already-nulled pointer does nothing.
// SAFETY: *list is null, treated as a no-op.
unsafe { hurray_buffer_list_destroy(&mut list) };

Nulling the caller's variable is the sound half of Arrow's "release marks the structure released" trick. Arrow can leave a marker inside the struct because the consumer owns that memory and it outlives the call; here the list allocation is freed, so the only memory that can safely be marked is the caller's own pointer.

Files

hurray.save() writes every buffer of a tensor, and hurray.load() reads them back in descriptor order. A multi-buffer tensor round-trips through a .hrry file byte for byte.

A tensor whose buffer count disagrees with its descriptor's buffer table is rejected on both paths, rather than producing a tensor whose buffer indices do not resolve.

Version check

The capsule shape changed in C ABI version 3. A consumer built against version 2 that receives a version 3 capsule raises hurray.UnsupportedError instead of misreading a HurrayBufferList as a HurrayBuffer:

import hurray

# Producer and consumer must agree on the ABI version; the check happens before
# the capsule pointer is ever dereferenced.
tensor = hurray.Tensor(bytes(16), hurray.float32, [4])
received = hurray.from_hurray(tensor)  # UnsupportedError on mismatch

What this does not yet do

The transport carries any number of buffers, but Python cannot yet author a quantization descriptor, so a per-channel-quantized tensor cannot be built from Python even though it now travels correctly once built. Descriptor-authoring classes are the next step.

See also

Authoring Quantized Tensors

Hurray describes quantization; it does not compute it. These types package scales and zero points you already have — from your own quantizer, or from a model you are converting — into a descriptor any Hurray reader understands.

Where the parameters live

SchemeParametersBuffers
Per-tensor affineone scale + zero point, inline in the descriptor1
Per-channel affineone scale per slice along an axis2 (+1 if asymmetric)
Per-block affineone scale per block of block_size2 (+1 if asymmetric)
NF4one scale per block2
MXFPone shared exponent per block2

Only per-tensor affine keeps its parameters in the descriptor. Every other scheme puts them in a separate buffer and refers to it by index — so building one means supplying the parameter bytes and the index that points at them.

Building one

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerChannelAffine,
    QuantizationDescriptor, Shape, SyncMode, TensorDescriptor,
    DESCRIPTOR_VERSION_MAJOR, DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let handle = |len: u64| {
        BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
    };

    // Buffer 0 is the int8 data; buffer 1 holds one float32 scale per row.
    let buffers = vec![handle(8)?, handle(8)?];

    // The scheme names buffer 1 by index — symmetric, so no zero points.
    let quant = QuantizationDescriptor::PerChannelAffine(
        PerChannelAffine::new_symmetric(0, 1)?,
    );

    let desc = TensorDescriptor::new(
        DESCRIPTOR_VERSION_MAJOR,
        DESCRIPTOR_VERSION_MINOR,
        ElementType::Int8,
        Shape::new(vec![2u64, 4])?,
        0,
        LayoutDescriptor::RowMajor,
        buffers,
        Some(quant.encode_to_vec()),
        None, // no shard
        None, // no statistics
        None, // no extension type
    )?;

    println!("descriptor: {} bytes", desc.encode()?.len());
    Ok(())
}
import struct
import hurray

# One float32 scale per row of a [2, 4] tensor.
scales = struct.pack("2f", 0.02, 0.017)

# The scheme names buffer 1 by index — symmetric, so no zero points.
quant = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)

weights = hurray.Tensor(
    bytes(8),              # buffer 0: the int8 weights
    hurray.int8,
    [2, 4],
    aux_buffers=[scales],  # buffer 1: what scale_buffer_index points at
    quantization=quant,
)

Per-tensor affine needs no companion buffer, because its parameters are inline:

q = hurray.PerTensorAffine(0.02, 128)
t = hurray.Tensor(bytes(8), hurray.int8, [2, 4], quantization=q)   # still 1 buffer

An index that points at nothing is refused

A descriptor claiming a scale buffer that was never supplied encodes and decodes perfectly well — the consumer simply finds a dangling index. So it is rejected where the mistake was made:

try:
    # No aux_buffers, but the scheme references buffer 1.
    hurray.Tensor(bytes(8), hurray.int8, [2, 4],
                  quantization=hurray.PerChannelAffine.symmetric(0, 1))
    raise AssertionError("buffer 1 does not exist")
except hurray.InvalidDescriptorError as exc:
    print(exc)

Symmetric and asymmetric

Schemes with an optional zero point expose two constructors rather than a flag, so "asymmetric but no zero-point buffer" cannot be expressed at all:

sym  = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)
asym = hurray.PerChannelAffine.asymmetric(
    axis=0, scale_buffer_index=1, zero_point_buffer_index=2
)

assert sym.zero_point_buffer_index is None      # wire sentinel 0xFFFFFFFF
assert asym.zero_point_buffer_index == 2

Per-block affine additionally declares the element type of its scales, which must be float16, bfloat16, or float32:

q = hurray.PerBlockAffine.symmetric(1, 32, 1, hurray.float32)
assert q.scale_type == hurray.float32

Statistics

Each statistic carries a validity bit saying whether it means anything. You pass values and the mask is derived, so a number can never be present with its bit unset:

s = hurray.Statistics(nnz=1024, value_min=-1.0, value_max=1.0, value_abs_max=1.0)

assert s.nnz == 1024
assert s.value_mean is None        # not supplied, so not claimed

Fields that share one bit on the wire must be supplied together — value_min / value_max / value_abs_max, value_mean / value_stddev, nm_n / nm_m, and has_nan / has_inf. A partial group raises rather than silently zero-filling the rest:

try:
    hurray.Statistics(value_min=-1.0)
    raise AssertionError("value_max and value_abs_max are missing")
except hurray.InvalidDescriptorError as exc:
    print(exc)

Shard

Records this tensor's position inside a larger logical one:

shard = hurray.Shard(parent_shape=[1024, 512], shard_offset=[512, 0])
piece = hurray.Tensor(bytes(8), hurray.int8, [2, 4], shard=shard)

Checking what you built

save() writes every buffer, so the scales travel with the weights:

hurray.save("weights.hrry", {"w": weights})

hurray-inspect then shows the scheme byte by byte:

   141  14 00 00 00                     quantization_length = 20
   145  02                              scheme_tag = 0x02 (per-channel-affine)
   149  00 00 00 00                     axis = 0
   153  01 00 00 00                     scale_buffer_index = 1
   157  FF FF FF FF                     zero_point_buffer_index = none (symmetric)
   161  03                              scale_type = float32

Reading it back

A consumer that receives a tensor — off disk, off the wire, or over the native protocol — can ask what it is holding. The getters return the same classes the constructor accepts, so an inspected scheme can be passed straight back to build another tensor.

use hurray_core::{QuantizationDescriptor, TensorDescriptor};

fn describe(desc: &TensorDescriptor) -> Result<(), Box<dyn std::error::Error>> {
    match desc.quantization.as_ref() {
        None => println!("not quantized"),
        Some(bytes) => {
            let (scheme, _read) = QuantizationDescriptor::decode(bytes)?;
            match scheme {
                QuantizationDescriptor::PerChannelAffine(q) => println!(
                    "per-channel: axis {}, scales in buffer {}",
                    q.axis(),
                    q.scale_buffer_index()
                ),
                other => println!("scheme: {other:?}"),
            }
        }
    }
    Ok(())
}
import hurray

loaded = hurray.load("weights.hrry")["w"]

q = loaded.quantization
if q is None:
    print("not quantized")
else:
    print(f"per-channel: axis {q.axis}, scales in buffer {q.scale_buffer_index}")

print(f"buffers: {loaded.buffer_count}")

statistics and shard read back the same way, and every section is None when absent:

t = hurray.Tensor(bytes(16), hurray.float32, [4])

assert t.quantization is None
assert t.statistics is None
assert t.shard is None

A statistic that was never supplied stays unclaimed after the round trip — the validity mask travels with the values:

s = hurray.Statistics(nnz=6)
t = hurray.Tensor(bytes(24), hurray.float32, [2, 3], statistics=s)

assert t.statistics.nnz == 6
assert t.statistics.value_mean is None

See also

Converting an Externally Quantized Tensor

An external toolchain hands you quantized weights, per-block scales, and a zero-point plane. Two of those three transfer as-is.

Hurray dequantizes as scale * (q - zero_point) — the stored zero point is the value subtracted, with no implicit bias. Toolchains in the GPTQ family have conventionally stored zero_point - 1 and removed the bias in the loader, so the number in the file and the number Hurray subtracts are not the same number. Nothing in either file records which convention produced it.

That makes this the rare conversion error with no symptom. A descriptor built from un-normalized values satisfies every validity constraint of its scheme, and a reader has no way to detect the discrepancy — it simply decodes every element off by one scale step.

What transfers

From the toolchainInto HurrayChanges?
Quantized weightsbuffer 0no
Per-block scalesscale buffer, float32no
Zero-point planezero-point buffer, one int32 per blockyes — rebuilt

The last row is the useful one. Hurray's per-block affine scheme takes one int32 per block, while the foreign plane packs two 4-bit values per byte. You are rebuilding that buffer either way; the normalization is one line inside a transform you are already writing, not a step to remember afterwards.

The conversion

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerBlockAffine,
    QuantizationDescriptor, Shape, SyncMode, TensorDescriptor,
    DESCRIPTOR_VERSION_MAJOR, DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};

/// 4-bit zero points packed two per byte, low nibble first, each holding
/// `zero_point - 1`.
fn normalize_zero_points(packed: &[u8], count: usize) -> Vec<i32> {
    (0..count)
        .map(|i| {
            let byte = packed[i / 2];
            let nibble = if i % 2 == 0 { byte & 0x0F } else { byte >> 4 };
            i32::from(nibble) + 1 // remove the toolchain's bias, here or never
        })
        .collect()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // A [2, 16] int8 weight, quantized in blocks of 8 along axis 1: 4 blocks.
    let zero_points = normalize_zero_points(&[0x67, 0x78], 4);
    assert_eq!(zero_points, vec![8, 7, 9, 8]);

    let handle = |len: u64| {
        BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
    };

    // Buffer 0: weights. Buffer 1: float32 scales. Buffer 2: int32 zero points.
    let buffers = vec![handle(32)?, handle(16)?, handle(16)?];

    let quant = QuantizationDescriptor::PerBlockAffine(
        PerBlockAffine::new_asymmetric(1, 8, 1, 2, ElementType::Float32)?,
    );

    let desc = TensorDescriptor::new(
        DESCRIPTOR_VERSION_MAJOR,
        DESCRIPTOR_VERSION_MINOR,
        ElementType::Int8,
        Shape::new(vec![2u64, 16])?,
        0,
        LayoutDescriptor::RowMajor,
        buffers,
        Some(quant.encode_to_vec()),
        None, // no shard
        None, // no statistics
        None, // no extension type
    )?;

    assert_eq!(desc.buffers.len(), 3);
    Ok(())
}
import struct
import hurray

def normalize_zero_points(packed, count):
    """4-bit zero points packed two per byte, each holding zero_point - 1."""
    out = []
    for i in range(count):
        byte = packed[i // 2]
        nibble = byte & 0x0F if i % 2 == 0 else byte >> 4
        out.append(nibble + 1)   # remove the toolchain's bias, here or never
    return out

# A [2, 16] int8 weight, quantized in blocks of 8 along axis 1: 4 blocks.
zero_points = normalize_zero_points(bytes([0x67, 0x78]), 4)
assert zero_points == [8, 7, 9, 8]

quant = hurray.PerBlockAffine.asymmetric(1, 8, 1, 2, hurray.float32)

weights = hurray.Tensor(
    bytes(32),                                  # buffer 0: the weights
    hurray.int8,
    [2, 16],
    aux_buffers=[
        struct.pack("4f", 0.02, 0.015, 0.025, 0.01),   # buffer 1: scales
        struct.pack("4i", *zero_points),               # buffer 2: zero points
    ],
    quantization=quant,
)

assert weights.buffer_count == 3

The scales went in untouched. The zero points did not.

What skipping it costs

Hurray describes tensors; it does not compute on them, so there is no dequantize in the library and there should not be one. The formula below is read out of the spec, not called from an API:

fn main() {
    // One block: scale 0.02, true zero point 8, one quantized value.
    let (s, q) = (0.02f32, 9i32);

    let correct = s * (q - 8) as f32; // normalized
    let wrong = s * (q - 7) as f32;   // foreign value copied straight through

    assert!((wrong - correct - s).abs() < 1e-6); // off by exactly one scale step
}

One scale step, on every element, in both descriptors that a reader accepts without complaint. Which is the whole reason the normalization is normative: quantization.md § Zero-Point Convention places the obligation on the writer, because after the buffer is written the information needed to detect the mistake is gone.

Scope

This recipe converts buffers you already hold in memory. Hurray is an interchange format, not a converter library — there is no GPTQ, safetensors, or GGUF reader here, and parsing those files is the caller's job.

Two limits worth knowing before you plan a conversion:

  • Activation-order grouping is out of scope. GPTQ's act-order variant reorders the quantized axis, which per-block affine cannot express. Only the permutation-free subset — contiguous groups along one axis — maps onto scheme 0x03. See quantization.md § Extension Schemes.
  • GGUF K-quants are out of scope. Q2_K–Q6_K scale their per-block scales by a second super-block factor; per-block affine carries one scale array. See Authoring Quantized Tensors for what each scheme does cover.

See also

IPC and Streaming Interchange

Hurray's streaming format moves tensors between a producer and a consumer — in one process, across a pipe or socket (IPC), or between machines. It is self-delimiting and descriptor-before-data, so a reader can start work before the whole payload has arrived and a writer can emit tensors one at a time without buffering the output.

In Rust this is the hurray-io streaming reader/writer over any async byte stream. In Python today the equivalent producer→consumer hand-off is the file format (save in one process, load in another); the incremental streaming API is Rust / C-FFI.

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape, SyncMode,
    TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::stream::{StreamReader, StreamWriter};

fn tensor(elems: u64) -> Result<(TensorDescriptor, Vec<u8>), Box<dyn std::error::Error>> {
    let handle = BufferHandle::new(
        elems, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    )?;
    let desc = TensorDescriptor::new(
        1, 0, ElementType::Uint8, Shape::new(vec![elems])?, 0,
        LayoutDescriptor::RowMajor, vec![handle], None, None, None, None,
    )?;
    Ok((desc, (0..elems as u8).collect()))
}

// Requires hurray-io's `tokio` feature.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Producer — `wire` stands in for any async writer (a TcpStream, a pipe, …).
    let mut wire = Vec::<u8>::new();
    let mut writer = StreamWriter::new(&mut wire);
    let (d0, data0) = tensor(8)?;
    let (d1, data1) = tensor(4)?;
    writer.write_tensor(&d0, &[&data0]).await?; // descriptor then data buffers
    writer.write_tensor(&d1, &[&data1]).await?;
    writer.finish().await?;

    // Consumer — reads incrementally; no back-references, no seeking.
    let mut reader = StreamReader::new(wire.as_slice());
    while let Some(t) = reader.next_tensor().await? {
        let bytes: usize = t.buffers.iter().map(|b| b.len()).sum();
        println!("got {:?} tensor, {bytes} bytes", t.descriptor.element_type);
    }
    Ok(())
}
import os
import tempfile

import numpy as np
import hurray

path = os.path.join(tempfile.gettempdir(), "handoff.hrry")

# Producer: write named tensors to the file container.
hurray.save(path, {
    "a": hurray.from_numpy(np.arange(8, dtype=np.uint8)),
    "b": hurray.from_numpy(np.arange(4, dtype=np.uint8)),
})

# Consumer (possibly another process): read them back.
loaded = hurray.load(path)
for name, t in loaded.items():
    print(name, t.shape, t.dtype)

os.unlink(path)

Transports

The Rust StreamWriter / StreamReader work over anything implementing the async read/write traits, so the same code drives:

  • In-process hand-off (an in-memory buffer, as above).
  • IPC over a Unix pipe or socket.
  • Cross-machine streaming over TCP — use StreamWriter::cross_machine / StreamReader::cross_machine, which add the length-prefixed framing needed when the transport does not preserve message boundaries.

Because the format is self-delimiting and forbids back-references and end-of-file indexes, the consumer never needs to seek — it can process each tensor as its bytes land.

See also

Private Extension Element Types

Element type tags 0xF0–0xFE are reserved for types the format deliberately says nothing about — a private posit, a block scale your runtime invented, an experiment that has not earned a standard tag. Nothing about their numeric semantics is specified, and two implementations that both use 0xF2 need not mean the same thing by it.

The price of that freedom is one obligation: the tensor must describe the type well enough for a stranger to size its buffers. That description is the descriptor's extension type section — a fixed 20 bytes carrying the bit width, the packing, and the floating-point parameters. A reader that has never heard of your type still knows exactly how many bytes to move, which is what an interchange format has to guarantee.

The flag and the tag are one fact: a descriptor whose type tag is in 0xF0–0xFE MUST carry the section, and one whose tag is anything else MUST NOT. Neither half stands alone.

The tag alone says almost nothing

An extension type reports a bit_width of 0. That is a sentinel, not a claim: the real width lives in the section, so the generic buffer-size helper has nothing to compute from.

use hurray_core::{buffer_size_bytes, ElementType};

fn main() -> Result<(), hurray_core::Error> {
    let private = ElementType::from_tag(0xF2)?;

    assert_eq!(private.tag(), 0xF2);
    assert_eq!(private.bit_width(), 0);          // sentinel: ask the section
    assert_eq!(buffer_size_bytes(private, 10), 0);

    Ok(())
}
import hurray

private = hurray.Dtype.from_tag(0xF2)

assert private.tag == 0xF2
assert private.bit_width == 0            # sentinel: ask the section

# Python refuses rather than returning a plausible 0 — as a byte count that is
# wrong, not unknown, and would size a buffer to nothing.
try:
    hurray.buffer_size_bytes(private, 10)
except hurray.InvalidDescriptorError as exc:
    assert "ExtensionType" in str(exc)

Describing the type

packing_factor is how many elements fit in a byte. Whole-byte widths pack one; sub-byte widths pack 8 / bit_width, and only 1, 2 and 4 bits are legal — anything else would need a fractional number of elements per byte. Non-power-of-two sub-byte widths are reserved to the built-in type space, which is where the 6-bit floats live with their 4-elements-per-3-bytes packing.

The Python constructor derives packing_factor rather than asking for it: the spec leaves exactly one legal value per width, so restating it could only produce an error.

use hurray_core::descriptor::ExtensionTypeDescriptor;

fn main() -> Result<(), hurray_core::Error> {
    // A private 24-bit signed integer: whole-byte, so packing_factor is 1.
    let int24 = ExtensionTypeDescriptor::new(24, 1, false, true, 0, 0, 0, 0, false, false)?;
    assert_eq!(int24.buffer_size_bytes(4), 12);

    // A private 4-bit type: two per byte, rounding up.
    let nibble = ExtensionTypeDescriptor::new(4, 2, false, false, 0, 0, 0, 0, false, false)?;
    assert_eq!(nibble.buffer_size_bytes(7), 4);

    // 6-bit is reserved to the built-in types.
    assert!(ExtensionTypeDescriptor::new(6, 1, false, false, 0, 0, 0, 0, false, false).is_err());

    Ok(())
}
import hurray

int24 = hurray.ExtensionType(bit_width=24, is_signed=True)
assert int24.packing_factor == 1
assert int24.buffer_size_bytes(4) == 12

nibble = hurray.ExtensionType(bit_width=4)
assert nibble.packing_factor == 2        # derived: 8 / 4
assert nibble.buffer_size_bytes(7) == 4  # ceil(7 / 2)

try:
    hurray.ExtensionType(bit_width=6)
except hurray.InvalidDescriptorError as exc:
    assert "1, 2 or 4" in str(exc)

Where a float's sign lives

A float carries its sign in sign_bits; is_signed describes integer types only. Setting both would give a reader two answers to one question, so a float with is_signed set is rejected.

This is not a claim that float extension types are unsigned — it is what makes an unsigned float expressible. The built-in float8_e8m0 (0x42) is exactly that shape: 8 exponent bits, no sign, no mantissa, used as an MX block scale. A private analogue sets is_float with sign_bits = 0.

use hurray_core::descriptor::ExtensionTypeDescriptor;

fn main() -> Result<(), hurray_core::Error> {
    // Signed 16-bit float: 1-5-10, bias 15.
    let half = ExtensionTypeDescriptor::new(16, 1, true, false, 1, 5, 10, 15, true, true)?;
    assert_eq!(half.sign_bits, 1);
    assert!(!half.is_signed);

    // Unsigned float — the float8_e8m0 shape.
    let scale = ExtensionTypeDescriptor::new(8, 1, true, false, 0, 8, 0, 127, true, false)?;
    assert_eq!(scale.sign_bits, 0);

    // Both set: rejected.
    assert!(ExtensionTypeDescriptor::new(16, 1, true, true, 1, 5, 10, 15, true, false).is_err());

    Ok(())
}
import hurray

half = hurray.ExtensionType(
    bit_width=16, is_float=True,
    sign_bits=1, exponent_bits=5, mantissa_bits=10, exponent_bias=15,
    has_nan=True, has_inf=True,
)
assert half.sign_bits == 1
assert half.is_signed is False

# Unsigned float — the float8_e8m0 shape.
scale = hurray.ExtensionType(
    bit_width=8, is_float=True, exponent_bits=8, exponent_bias=127
)
assert scale.sign_bits == 0

try:
    hurray.ExtensionType(bit_width=16, is_float=True, is_signed=True, sign_bits=1)
except hurray.InvalidDescriptorError as exc:
    assert "is_signed" in str(exc)

Authoring a tensor

The section travels with the descriptor and is what sizes the buffer check. Without it the check would compute zero bytes and wave an empty buffer through for a tensor of any length.

use hurray_core::{
    descriptor::{ExtensionTypeDescriptor, TensorDescriptor},
    layout::LayoutDescriptor,
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, DESCRIPTOR_VERSION_MAJOR,
    DESCRIPTOR_VERSION_MINOR, MIN_BUFFER_ALIGNMENT,
};

fn main() -> Result<(), hurray_core::Error> {
    let int24 = ExtensionTypeDescriptor::new(24, 1, false, true, 0, 0, 0, 0, false, false)?;
    let buffer = BufferHandle::new(
        int24.buffer_size_bytes(4),
        MIN_BUFFER_ALIGNMENT,
        DeviceTag::Cpu,
        SyncMode::ProducerSynced,
    )?;

    let descriptor = TensorDescriptor::new(
        DESCRIPTOR_VERSION_MAJOR,
        DESCRIPTOR_VERSION_MINOR,
        ElementType::from_tag(0xF2)?,
        Shape::new(vec![4u64])?,
        0,
        LayoutDescriptor::RowMajor,
        vec![buffer],
        None,
        None,
        None,
        Some(int24),
    )?;

    // Round-trips: a consumer that has never heard of 0xF2 still learns the width.
    let restored = TensorDescriptor::decode(&descriptor.encode()?)?;
    let ext = restored.extension_type.as_ref().expect("carries a section");
    assert_eq!(ext.buffer_size_bytes(4), 12);

    Ok(())
}
import hurray

private = hurray.Dtype.from_tag(0xF2)
int24 = hurray.ExtensionType(bit_width=24, is_signed=True)

tensor = hurray.Tensor(
    bytes(int24.buffer_size_bytes(4)), private, [4], extension_type=int24
)
assert tensor.extension_type.bit_width == 24

# Round-trips: a consumer that has never heard of 0xF2 still learns the width.
restored = hurray.Descriptor.decode(tensor.descriptor.encode())
assert restored == tensor.descriptor
assert restored.extension_type.buffer_size_bytes(4) == 12

Both directions of the pairing are refused, at authoring time:

import hurray

private = hurray.Dtype.from_tag(0xF2)
int24 = hurray.ExtensionType(bit_width=24, is_signed=True)

# An extension dtype must describe itself.
try:
    hurray.Tensor(bytes(12), private, [4])
except hurray.InvalidDescriptorError as exc:
    assert "must describe itself" in str(exc)

# And the section cannot stand alone.
try:
    hurray.Tensor(bytes(16), hurray.float32, [4], extension_type=int24)
except hurray.InvalidDescriptorError as exc:
    assert "not a private extension" in str(exc)

# The section sizes the buffer check.
try:
    hurray.Tensor(bytes(4), private, [4], extension_type=int24)
except hurray.BufferError as exc:
    assert "need at least 12 bytes" in str(exc)

What this does not buy you

Portability. The spec is explicit: tensors using private extension tags MUST NOT be exchanged between independent implementations unless both parties have agreed on the semantics out of band. The section lets a stranger move your bytes, not interpret them. If you need a type both ends understand without a side agreement, request a built-in tag through the specification governance process instead.

See also

Layer 0: Element Types and Shape

Purpose

ElementType defines the numeric storage format for tensor elements (float32, int4, bool, etc.). Shape describes the dimensions of a tensor. Together, they form the foundation of the Hurray data model: what is stored (element type) and how many (shape). The buffer_size_bytes function computes how much memory a tensor requires.

ML Model with Mixed Precision

Suppose you're building an inference runtime for an LLM with quantization. Different layers use different types:

use hurray_core::{ElementType, Shape, buffer_size_bytes};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Weight tensor: float16 (half precision)
    let weights_shape = Shape::new(vec![768u64, 3072])?;
    let weights_type = ElementType::Float16;
    let weights_elements = weights_shape.element_count().expect("no dynamic dims");
    let weights_bytes = buffer_size_bytes(weights_type, weights_elements);
    println!("Weights [768, 3072] as float16: {} bytes", weights_bytes);

    // Activation tensor: float32 (full precision for numerical stability)
    let activation_shape = Shape::new(vec![32u64, 768])?;
    let activation_type = ElementType::Float32;
    let activation_elements = activation_shape.element_count().expect("no dynamic dims");
    let activation_bytes = buffer_size_bytes(activation_type, activation_elements);
    println!("Activations [32, 768] as float32: {} bytes", activation_bytes);

    // Quantized layer: int4 (4-bit integers)
    let quantized_shape = Shape::new(vec![768u64, 1024])?;
    let quantized_type = ElementType::Int4;
    let quantized_elements = quantized_shape.element_count().expect("no dynamic dims");
    let quantized_bytes = buffer_size_bytes(quantized_type, quantized_elements);
    println!("Quantized layer [768, 1024] as int4: {} bytes", quantized_bytes);

    Ok(())
}
import hurray

# Weight tensor: float16 (half precision)
weights = hurray.buffer_size_bytes(hurray.float16, 768 * 3072)
print(f"Weights [768, 3072] as float16: {weights} bytes")

# Activation tensor: float32 (full precision for numerical stability)
activations = hurray.buffer_size_bytes(hurray.float32, 32 * 768)
print(f"Activations [32, 768] as float32: {activations} bytes")

# Quantized layer: int4 (4-bit integers, two per byte)
quantized = hurray.buffer_size_bytes(hurray.dtype.int4, 768 * 1024)
print(f"Quantized layer [768, 1024] as int4: {quantized} bytes")

Output:

Weights [768, 3072] as float16: 4718592 bytes
Activations [32, 768] as float32: 98304 bytes
Quantized layer [768, 1024] as int4: 393216 bytes

Dynamic Dimensions (Batch Size Unknown)

When the batch dimension is not known at model load time, mark it DYNAMIC:

use hurray_core::{Shape, DYNAMIC};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Batch size unknown; sequence length fixed at 512
    let shape = Shape::new(vec![DYNAMIC, 512u64, 768])?;
    
    println!("Shape: {}", shape);                    // [?, 512, 768]
    println!("Has dynamic: {}", shape.has_dynamic()); // true
    println!("Element count: {:?}", shape.element_count()); // None
    
    // At runtime, after batch size is resolved to (say) 8:
    let resolved_shape = Shape::new(vec![8u64, 512, 768])?;
    let elements = resolved_shape.element_count().expect("now static");
    println!("Resolved element count: {}", elements); // 3145728

    Ok(())
}
import hurray

# Batch size unknown; sequence length fixed at 512.
signature = hurray.Tensor(b"", hurray.float32, [None, 512, 768])

assert signature.shape == (None, 512, 768)
assert signature.size is None          # an unknown extent, so an unknown count

# At runtime, once the batch size is resolved to (say) 8:
resolved = hurray.Tensor(bytes(4 * 8 * 512 * 768), hurray.float32, [8, 512, 768])
assert resolved.size == 3145728

Python spells a dynamic dimension None rather than exposing the wire sentinel, because Tensor.shape already returns None for one — so a shape read off a tensor can be handed straight back to the constructor. Anything that allocates (zeros, ones, empty, full) refuses it: you cannot allocate an unknown number of bytes.

Type Properties and Alignment

Query element type metadata:

use hurray_core::ElementType;

fn main() {
    let ty = ElementType::Float32;
    
    println!("Type: {}", ty);                         // float32
    println!("Wire tag: 0x{:02X}", ty.tag());         // 0x03
    println!("Bit width: {}", ty.bit_width());        // 32
    println!("Bytes per element: {}", ty.element_alignment()); // 4
    println!("Is float: {}", ty.is_float());          // true
    println!("Is integer: {}", ty.is_integer());      // false
    println!("Is signed: {}", ty.is_signed());        // true
    println!("Tier: {}", ty.tier());                  // 1 (core type)

    // Sub-byte types require special handling
    let int4 = ElementType::Int4;
    println!("\nType: {}", int4);                     // int4
    println!("Bit width: {}", int4.bit_width());      // 4
    println!("Is sub-byte: {}", int4.is_sub_byte());  // true
}
import hurray

ty = hurray.float32

print(f"Type: {ty.name}")                          # float32
print(f"Wire tag: 0x{ty.tag:02X}")                 # 0x03
print(f"Bit width: {ty.bit_width}")                # 32
print(f"Element alignment: {ty.element_alignment}")  # 4
print(f"Is float: {ty.is_float}")                  # True
print(f"Is integer: {ty.is_integer}")              # False
print(f"Is signed: {ty.is_signed}")                # True
print(f"Tier: {ty.tier}")                          # 1 (core type)

# Sub-byte types require special handling
int4 = hurray.dtype.int4
print(f"Bit width: {int4.bit_width}")              # 4
print(f"Is sub-byte: {int4.is_sub_byte}")          # True
print(f"Element alignment: {int4.element_alignment}")  # 1 — two share a byte

element_alignment is the element's own alignment, not the buffer's: a float32 buffer starts on a hurray.MIN_BUFFER_ALIGNMENT boundary, but its elements are 4-aligned within it. A packed element reports 1, having no address of its own.

Buffer Size Calculations

For different element types, the buffer size formula varies. buffer_size_bytes handles all cases:

use hurray_core::{ElementType, buffer_size_bytes};

fn main() {
    // Whole-byte types: element_count × byte_width
    println!("float32 × 100 elements: {} bytes",
        buffer_size_bytes(ElementType::Float32, 100)); // 400

    // 6-bit types: ceil(N/4) × 3
    println!("float6_e2m3 × 100 elements: {} bytes",
        buffer_size_bytes(ElementType::Float6E2M3, 100)); // 75

    // 4-bit types: ceil(N×4/8) = ceil(N/2)
    println!("int4 × 7 elements: {} bytes",
        buffer_size_bytes(ElementType::Int4, 7)); // 4

    // Boolean (1-bit): ceil(N/8)
    println!("bool × 9 elements: {} bytes",
        buffer_size_bytes(ElementType::Bool, 9)); // 2

    // Sub-byte types are packed; 0 elements always yields 0 bytes
    println!("any type × 0 elements: {} bytes",
        buffer_size_bytes(ElementType::Float32, 0)); // 0
}
import hurray

# Whole-byte types: element_count x byte_width
assert hurray.buffer_size_bytes(hurray.float32, 100) == 400

# 6-bit types: ceil(N/4) x 3
assert hurray.buffer_size_bytes(hurray.dtype.float6_e2m3, 100) == 75

# 4-bit types: ceil(N x 4 / 8) = ceil(N/2)
assert hurray.buffer_size_bytes(hurray.dtype.int4, 7) == 4

# Boolean (1-bit): ceil(N/8)
assert hurray.buffer_size_bytes(hurray.bool, 9) == 2

# Sub-byte types are packed; 0 elements always yields 0 bytes
assert hurray.buffer_size_bytes(hurray.float32, 0) == 0

Type Tag Round-Trip

Serialize and deserialize element types using the wire tag:

use hurray_core::ElementType;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialize: type to tag
    let original_type = ElementType::Float8E4M3;
    let tag = original_type.tag();
    println!("Serialized {} to tag 0x{:02X}", original_type, tag);

    // Deserialize: tag to type
    let recovered_type = ElementType::from_tag(tag)?;
    assert_eq!(original_type, recovered_type);
    println!("Deserialized tag 0x{:02X} back to {}", tag, recovered_type);

    Ok(())
}
import hurray

# Serialize: type to tag
tag = hurray.dtype.float8_e4m3.tag
assert tag == 0x40

# Deserialize: tag to type — and back to the same object, not a copy
assert hurray.Dtype.from_tag(tag) is hurray.dtype.float8_e4m3

Invalid Tags

Tags in reserved ranges are rejected:

use hurray_core::{ElementType, Error};

fn main() {
    // Permanently invalid sentinels
    assert!(matches!(ElementType::from_tag(0x00), Err(Error::InvalidTypeTag(0x00))));
    assert!(matches!(ElementType::from_tag(0xFF), Err(Error::InvalidTypeTag(0xFF))));

    // Reserved for future spec versions
    assert!(matches!(ElementType::from_tag(0x47), Err(Error::ReservedTypeTag(0x47))));
    assert!(matches!(ElementType::from_tag(0x80), Err(Error::ReservedTypeTag(0x80))));

    // The private-extension range is NOT an error: 0xF0-0xFE resolve to
    // ElementType::Extension(tag), whose semantics travel out of band.
    assert!(matches!(ElementType::from_tag(0xF0), Ok(ElementType::Extension(0xF0))));

    println!("All invalid tags correctly rejected");
}
import hurray

for tag in (
    0x00, 0xFF,   # permanently invalid sentinels
    0x47, 0x80,   # reserved for future spec versions
):
    try:
        hurray.Dtype.from_tag(tag)
        raise AssertionError(f"0x{tag:02X} should not resolve")
    except hurray.InvalidDescriptorError as exc:
        print(exc)

# The private-extension range is not an error: it resolves to an extension type
# whose semantics travel out of band. The tag is what identifies it.
private = hurray.Dtype.from_tag(0xF0)
assert private.name == "extension"
assert private.tag == 0xF0
assert private != hurray.Dtype.from_tag(0xF5)

Python collapses the Rust error variants into InvalidDescriptorError, but the message still says which case it was — and none of them is guessed at, since inventing a type for a reserved tag turns "the producer is newer than this reader" into silent wrong data.

Empty and Scalar Tensors

Hurray supports edge cases:

use hurray_core::{Shape, ElementType, buffer_size_bytes};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Scalar tensor (rank 0): one element, no shape
    let scalar = Shape::scalar();
    assert_eq!(scalar.rank(), 0);
    assert_eq!(scalar.dims(), &[]);
    assert_eq!(scalar.element_count(), Some(1));
    let scalar_bytes = buffer_size_bytes(ElementType::Float32, 1);
    println!("Scalar float32: {} bytes", scalar_bytes); // 4

    // Empty tensor: any zero dimension
    let empty = Shape::new(vec![5u64, 0, 10])?;
    assert!(empty.is_empty_tensor());
    assert_eq!(empty.element_count(), Some(0));
    let empty_bytes = buffer_size_bytes(ElementType::Float32, 0);
    println!("Empty tensor: {} bytes", empty_bytes); // 0

    Ok(())
}
import hurray

# Scalar tensor (rank 0): one element, no shape
scalar = hurray.Tensor(bytes(4), hurray.float32, [])
assert scalar.ndim == 0
assert scalar.shape == ()
assert scalar.size == 1

# Empty tensor: any zero dimension
empty = hurray.Tensor(b"", hurray.float32, [5, 0, 10])
assert empty.size == 0
assert empty.buffer_handles[0].byte_size == 0
assert empty.buffer_handles[0].alignment == 1     # nothing to align

Key Takeaways

  • ElementType — an enum with 26 numeric types from Tier 1 (core) and Tier 2 (extended)
  • Shape — a vector of u64 dimension sizes, supporting dynamic (DYNAMIC) and zero-size dimensions
  • buffer_size_bytes() — handles all packing rules (1-bit, 2-bit, 4-bit, 6-bit, and whole-byte types)
  • Tags are serialized as u8 in descriptors; use from_tag() / tag() for round-trip conversion
  • In Python, a dynamic dimension is spelled None — the same thing Tensor.shape returns — and hurray.buffer_size_bytes(dtype, count) applies the same packing rules
  • Scalar tensors have rank 0; empty tensors have 0 total elements but valid descriptors

See docs/spec/element-types.md and docs/spec/data-model.md for the normative specification.

Layer 1: Buffer Protocol

Purpose

A buffer handle declares a tensor's data buffer: its size in bytes, alignment guarantee, and which device (CPU, GPU, or custom) it resides in. A device tag identifies the memory space. A memory class describes how the buffer is accessible — standard device-private, host-pinned, unified, or peer-accessible. Together they form the bridge between the descriptor's binary metadata and the actual memory location — the handle does not hold a pointer (that comes out-of-band) but carries the rules readers must follow to safely dereference the data.

Creating Buffer Handles

The most common case: a CPU buffer with SIMD alignment (64 bytes minimum):

use hurray_core::{BufferHandle, DeviceTag, SyncMode, MIN_BUFFER_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a 1 KB CPU buffer with SIMD alignment.
    let handle = BufferHandle::new(1024, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
    
    assert_eq!(handle.byte_size(), 1024);
    assert_eq!(handle.alignment(), 64);
    assert_eq!(handle.device_tag(), DeviceTag::Cpu);
    assert_eq!(handle.sync_mode(), SyncMode::ProducerSynced);
    
    Ok(())
}
import hurray

# Python reads the buffer table rather than authoring it: the buffers a tensor
# holds already settle every field, so there is nothing left for a caller to
# supply. One handle per buffer, in descriptor order.
tensor = hurray.Tensor(bytes(1024), hurray.uint8, [1024])
handle, = tensor.buffer_handles

assert handle.byte_size == 1024
assert handle.alignment == hurray.MIN_BUFFER_ALIGNMENT
assert handle.sync_mode == "producer_synced"
assert handle.device is tensor.device       # colocation: one device per descriptor

A handle is a value copied out of the table, not a view into it: it holds no reference to its tensor and none to any buffer, so collecting handles across a stream pins nothing. That is also why metadata and bytes have separate accessors — tensor.buffer(i) hands back a byte view, tensor.buffer_handles[i] answers questions about those bytes without touching them. On a CUDA tensor the second works where the first cannot.

For GPU or IPC buffers, use page alignment (4096 bytes):

use hurray_core::{BufferHandle, DeviceTag, SyncMode, PAGE_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // CUDA buffer aligned to one page — safe for GPU + IPC transport.
    let gpu_buffer = BufferHandle::new(8192, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event)?;
    
    assert_eq!(gpu_buffer.alignment(), 4096);
    assert_eq!(gpu_buffer.device_tag(), DeviceTag::Cuda);
    
    Ok(())
}

Choosing Alignment

DeviceAlignmentWhy
CPU (SIMD)64 bytesMinimum for AVX-512, NEON, SVE without per-op negotiation
GPU, IPC, RDMA4096 bytesHost page size; avoids cross-page pinning and TLB fragmentation
Custom (private tag)≥64 bytesImplementation-defined; typically matches SIMD or page boundary

Always use the strongest alignment you can guarantee — readers may rely on it for performance.

Empty Buffers

A tensor with zero elements (e.g., shape [5, 0, 10]) has zero-byte buffers. Use BufferHandle::empty():

use hurray_core::{BufferHandle, DeviceTag};

fn main() {
    // Empty buffer — no data, alignment is waived.
    let empty = BufferHandle::empty(DeviceTag::Cpu);
    
    assert!(empty.is_empty());
    assert_eq!(empty.byte_size(), 0);
    assert_eq!(empty.alignment(), 1); // Any power-of-two is valid
}
import hurray

empty, = hurray.Tensor(b"", hurray.float32, [0]).buffer_handles

assert empty.is_empty
assert empty.byte_size == 0
assert empty.alignment == 1     # no byte to load, so nothing to align

Readers MUST NOT dereference the pointer of an empty buffer. In C ABI contexts, it may be a null pointer; in others, it may be non-null but uninitialized. Do not read or write.

Memory Class

A buffer's memory class describes how it is accessible, orthogonally to which device it resides on. The default is Standard (device-private memory), but other classes enable zero-copy sharing patterns:

ClassWire byteMeaning
Standard0x00Device-private memory; default for all devices
HostPinned0x01CPU-accessible pinned memory (e.g., CUDA cudaMallocHost)
Unified0x02Unified/managed memory accessible from both CPU and GPU
Peer0x03Peer-to-peer memory accessible from a second GPU

Use BufferHandle::new() for the common case (Standard); use BufferHandle::with_memory_class() when the class is known:

use hurray_core::{BufferHandle, DeviceTag, MemoryClass, SyncMode, PAGE_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // CUDA unified (managed) memory — CPU and GPU can both access it directly.
    let unified = BufferHandle::with_memory_class(
        8192,
        PAGE_ALIGNMENT,
        DeviceTag::Cuda,
        SyncMode::ProducerSynced,
        MemoryClass::Unified,
    )?;
    assert_eq!(unified.memory_class(), MemoryClass::Unified);

    // Host-pinned memory — GPU DMA can read it without staging.
    let pinned = BufferHandle::with_memory_class(
        4096,
        PAGE_ALIGNMENT,
        DeviceTag::Cuda,
        SyncMode::Event,
        MemoryClass::HostPinned,
    )?;
    assert_eq!(pinned.memory_class(), MemoryClass::HostPinned);
    
    Ok(())
}

The memory class round-trips through the wire format:

use hurray_core::MemoryClass;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialize: type → byte
    let original = MemoryClass::Unified;
    let byte = original.to_byte();
    assert_eq!(byte, 0x02);
    
    // Deserialize: byte → type
    let recovered = MemoryClass::from_byte(byte)?;
    assert_eq!(original, recovered);
    
    Ok(())
}

Private memory classes (0xF0–0xFE) are available for vendor-specific extensions, following the same pattern as private device tags:

use hurray_core::{BufferHandle, DeviceTag, MemoryClass, SyncMode, MIN_BUFFER_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let vendor_class = MemoryClass::from_byte(0xF1)?;
    let handle = BufferHandle::with_memory_class(
        2048,
        MIN_BUFFER_ALIGNMENT,
        DeviceTag::from_byte(0xF0)?,
        SyncMode::ProducerSynced,
        vendor_class,
    )?;
    assert!(handle.memory_class().is_private());
    
    Ok(())
}
import hurray

# A vendor memory class on a vendor device.
device = hurray.Device(0xF0, 0, memory_class=0xF1)

assert device.memory_class == "private"
assert device.memory_class_tag == 0xF1

tensor = hurray.Tensor(bytes(2048), hurray.float32, [512], device=device)
assert tensor.buffer_handles[0].device is tensor.device

Private Device Tags

For experimental or vendor-specific hardware, use the private range (0xF0–0xFE):

use hurray_core::{BufferHandle, DeviceTag, SyncMode, MIN_BUFFER_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a private device tag for a custom accelerator (e.g., TPU, custom FPGA).
    let custom_device = DeviceTag::from_byte(0xF2)?;
    let handle = BufferHandle::new(4096, MIN_BUFFER_ALIGNMENT, custom_device, SyncMode::ProducerSynced)?;
    
    assert!(custom_device.is_private());
    assert_eq!(custom_device.to_byte(), 0xF2);
    
    Ok(())
}
import hurray

# A private tag for a custom accelerator (e.g. a TPU, an FPGA).
custom = hurray.Device(0xF2)

assert custom.is_private
assert custom.tag == 0xF2
assert custom.kind == "private"     # the spec gives these no name

tensor = hurray.Tensor(bytes(4096), hurray.float32, [1024], device=custom)
assert tensor.device.tag == 0xF2

Python takes a wire byte where the spec has no name to give. kind is "private" for every tag in the range, so tag is what tells two apart — and repr carries it for the same reason.

Important: Private tags must not be exchanged between independent implementations without an out-of-band agreement on semantics. Use only when both producer and consumer control the device tag value.

Alignment Validation

Alignment must be a power of two:

use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode};

fn main() {
    // Alignment is not a power of two — rejected.
    let result = BufferHandle::new(512, 63, DeviceTag::Cpu, SyncMode::ProducerSynced);
    assert!(matches!(result, Err(Error::AlignmentNotPowerOfTwo { alignment: 63 })));
}

For non-empty buffers, alignment must be at least 64 bytes:

use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode};

fn main() {
    // Non-empty buffer with alignment below SIMD minimum — rejected.
    let result = BufferHandle::new(512, 32, DeviceTag::Cpu, SyncMode::ProducerSynced);
    assert!(matches!(
        result,
        Err(Error::AlignmentBelowMinimum { alignment: 32, minimum: 64 })
    ));
    
    // Empty buffers allow any power-of-two alignment, including 1.
    let empty = BufferHandle::new(0, 1, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
    assert!(empty.is_empty());
}

Alignment Is Measured, Not Asserted

The floor above is what makes the next part interesting. A producer does not get to claim 64-byte alignment — a consumer will issue aligned SIMD loads on the strength of that claim, and a claim the address cannot back invites a fault. So the Python binding measures the address it is given, and declares what it finds:

import numpy as np
import hurray

array = np.zeros(1 << 20, dtype=np.float32)          # 4 MiB

# NumPy promises no alignment beyond the dtype's own, and a large allocation served
# by a fresh mmap is 16 bytes past a page boundary — glibc puts its chunk header
# there — so it never reaches 64. A recycled chunk may land anywhere, which is no
# better: the address is not something a producer can arrange.
tensor = hurray.from_numpy(array)                    # copied if it does not qualify
assert tensor.buffer_handles[0].alignment >= hurray.MIN_BUFFER_ALIGNMENT

from_numpy, from_torch, from_scipy, sparse_coo, from_dlpack and asarray therefore take a copy argument, with the same meaning as NumPy's:

copyBehaviour
None (default)Copy into a 64-byte-aligned allocation only if the source is under-aligned
FalseNever copy; raise hurray.CopyRequiredError naming the alignment the source actually has
TrueAlways copy
under_aligned = array[1:]                            # 4-byte aligned, guaranteed

try:
    hurray.from_numpy(under_aligned, copy=False)
except hurray.CopyRequiredError as exc:
    print(exc)   # "array is 4-byte aligned, below the 64-byte minimum ..."

This is a real cost, and it is worth stating plainly rather than burying: zero-copy NumPy ingest copies for most arrays. copy=False exists so a caller who needs the guarantee gets an error instead of a silent memcpy. An array you allocated on a 64-byte boundary yourself is shared, not copied — and from_scipy decides per component, so a matrix's .data can be shared while its .indptr is copied.

Allocating arrays that need no copy

If you control the allocation, you can remove the copy entirely. NumPy ≥ 1.22 lets an extension install a data-memory handler (NEP 49), and alignment is the first motivation that NEP lists — NumPy considered guaranteeing it, declined, and shipped the hook instead, so this is the sanctioned answer rather than a workaround:

import numpy as np
import hurray

with hurray.aligned_allocator():
    weights = np.zeros((512, 512), dtype=np.float32)

tensor = hurray.from_numpy(weights, copy=False)     # accepted: no copy is needed
assert tensor.buffer_handles[0].alignment >= hurray.MIN_BUFFER_ALIGNMENT

That turns "Hurray always copies NumPy arrays" into "arrays allocated for Hurray are not copied" — a materially different bargain for a producer writing its own checkpoints.

Three properties make this safe to reach for, and one is a sharp edge:

  • The handler is stored per array. An array allocated inside the block is freed through the matching deallocator long after the block exits, so arrays outlive their block safely.
  • It is thread- and context-local, so installing it cannot leak into unrelated code, and it is restored on the way out even if the block raises. Blocks nest.
  • Arrays allocated outside are untouched, including ones that already existed.
  • A thread started inside the block does not inherit the policy. Arrays a worker thread allocates get NumPy's default allocator and are copied on ingest like any other. Enter the block on the thread that allocates.

One consequence worth knowing: alignment is exempt from the round-trip obligation that governs layout, quantization, statistics and shard. Alignment describes an address, and a rebuild that copies bytes has a different one. A tensor that arrived declaring 4096 will honestly declare 64 after a rebuild through Python bytes.

Sync Mode

sync_mode says when a buffer may be read. buffer-protocol.md § Consumer Requirement puts the duty on the consumer: for event and consumer_stream, wait on the producer's device event before touching a byte.

Everything the Python binding constructs is producer_synced, and that is a consequence rather than a default — the interpreter cannot enqueue device work through this API, so it cannot promise anything else. There is deliberately no sync_mode= keyword: a settable field could only author a contract nothing could honour.

tensor = hurray.Tensor(bytes(64), hurray.float32, [16])
assert tensor.buffer_handles[0].sync_mode == "producer_synced"

A tensor decoded from a stream, a file, or another producer's capsule reports what that producer declared. If it is not producer_synced, the paths that hand out bytes — buffer(), .values / .indices, __array__, to_torch, __dlpack__ — refuse, since the binding cannot perform the wait the contract requires. Relaying such a tensor onward with __hurray__ or StreamWriter.write still works: relaying a declaration is not reading a byte.

Device Colocation

All buffers in a single tensor (data + quantization parameters) must reside on the same device and in the same memory class. Validate this before processing:

use hurray_core::{BufferHandle, DeviceTag, SyncMode, validate_colocation};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let data_buffer = BufferHandle::new(1024, 64, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
    let scale_buffer = BufferHandle::new(16, 64, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
    
    // All on CPU with Standard memory class — passes.
    let device = validate_colocation(&[data_buffer, scale_buffer])?;
    assert_eq!(device, DeviceTag::Cpu);
    
    Ok(())
}

Mixed devices are rejected:

use hurray_core::{BufferHandle, DeviceTag, Error, SyncMode, validate_colocation};

fn main() {
    let cpu_buf = BufferHandle::new(1024, 64, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
    let gpu_buf = BufferHandle::new(256, 4096, DeviceTag::Cuda, SyncMode::ProducerSynced).unwrap();
    
    // Different devices — fails.
    let result = validate_colocation(&[cpu_buf, gpu_buf]);
    assert!(matches!(
        result,
        Err(Error::DeviceTagMismatch { expected: 0x00, found: 0x01 })
    ));
}

Mixed memory classes are also rejected — even when all buffers share the same device:

use hurray_core::{BufferHandle, DeviceTag, Error, MemoryClass, SyncMode, PAGE_ALIGNMENT, validate_colocation};

fn main() {
    let standard = BufferHandle::new(4096, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event).unwrap();
    let unified = BufferHandle::with_memory_class(
        4096, PAGE_ALIGNMENT, DeviceTag::Cuda, SyncMode::Event, MemoryClass::Unified,
    ).unwrap();
    
    // Same device, different memory class — fails.
    let result = validate_colocation(&[standard, unified]);
    assert!(matches!(
        result,
        Err(Error::MemoryClassMismatch { expected: 0x00, found: 0x02 })
    ));
}

Why? Quantized tensor kernels dereference both data and quantization parameters. Cross-device and cross-class transfers are expensive; colocation ensures efficient access. If buffers must use different memory classes, emit a separate tensor descriptor.

Device Tag Round-Trip

Serialize a device to its wire byte and back:

use hurray_core::DeviceTag;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Serialize: type → byte
    let original = DeviceTag::Cuda;
    let byte = original.to_byte();
    assert_eq!(byte, 0x01);
    
    // Deserialize: byte → type
    let recovered = DeviceTag::from_byte(byte)?;
    assert_eq!(original, recovered);
    
    println!("Round-trip: {} → 0x{:02X} → {}", original, byte, recovered);
    
    Ok(())
}

Bytes in the range 0x09–0xEF (reserved for future spec versions) and 0xFF (permanently invalid) are rejected:

use hurray_core::{DeviceTag, Error};

fn main() {
    assert!(matches!(DeviceTag::from_byte(0x09), Err(Error::ReservedDeviceTag(_))));
    assert!(matches!(DeviceTag::from_byte(0xFF), Err(Error::InvalidDeviceTag(_))));
}

Named Device Tags

The spec defines nine named device types:

TagVariantUse
0x00DeviceTag::CpuHost memory
0x01DeviceTag::CudaNVIDIA CUDA GPU
0x02DeviceTag::RocmAMD ROCm GPU
0x03DeviceTag::MetalApple Silicon (Metal/MPS)
0x04DeviceTag::VulkanVulkan cross-platform GPU compute
0x05DeviceTag::WebGpuWebGPU (browser inference)
0x06DeviceTag::HexagonQualcomm HVX/HMX DSP
0x07DeviceTag::LevelZeroIntel Level Zero / oneAPI
0x08DeviceTag::OpenClOpenCL (embedded/legacy GPU)

Tags 0x09–0xEF are reserved; 0xF0–0xFE are private; 0xFF is permanently invalid.

Key Takeaways

  • DeviceTag identifies where a buffer resides (CPU, GPU, or custom hardware)
  • MemoryClass describes how it is accessible: Standard (device-private), HostPinned, Unified, or Peer
  • Alignment must be a power of two; at least 64 bytes for non-empty, any power-of-two for empty
  • Page alignment (4096 bytes) recommended for GPU and IPC buffers
  • Colocation validation requires all buffers to share both the same device tag and the same memory class
  • Private tags (0xF0–0xFE) allow vendor-specific devices or memory classes but require out-of-band agreement
  • Empty buffers are never dereferenced; alignment rules are waived
  • In Python, alignment is measured from the address rather than asserted, and ingest copies an under-aligned source unless copy=False tells it to refuse instead
  • hurray.aligned_allocator() removes the copy for arrays you allocate yourself, via NumPy's NEP 49 handler — per-array and thread-local, so a child thread does not inherit it
  • sync_mode is read-only in Python, and a buffer that is not producer_synced refuses every path that hands out bytes

See docs/spec/buffer-protocol.md for the normative specification.

Layer 2: Quantization Descriptors

Purpose

Quantization descriptors specify how tensor elements are dequantized when retrieved from storage. Hurray supports five schemes covering per-tensor, per-channel, and per-block quantization strategies—essential for inference on quantized LLMs and diffusion models. Each scheme is encoded into a binary descriptor that precedes the tensor data.

Per-Tensor Affine Quantization (INT8)

When a single scale and zero point apply uniformly to all elements (the simplest case):

use hurray_core::{PerTensorAffine, QuantizationDescriptor};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a per-tensor affine descriptor: scale=0.015625, zero_point=128
    let q = PerTensorAffine::new(0.015625, 128)?;
    let desc = QuantizationDescriptor::PerTensorAffine(q);

    // Encode to bytes (16 bytes total: 4-byte header + 8 bytes scale/zp + 4 reserved)
    let mut buf = vec![0u8; desc.encoded_len()];
    let written = desc.encode_into(&mut buf)?;
    println!("Encoded: {} bytes", written); // 16

    // Decode back
    let (decoded, consumed) = QuantizationDescriptor::decode(&buf)?;
    assert_eq!(consumed, 16);
    assert_eq!(decoded, desc);

    // Dequantization formula: x_real = scale * (q - zero_point)
    // where q is a raw int8 value from storage
    Ok(())
}
import hurray

# scale=0.015625, zero_point=128
scheme = hurray.PerTensorAffine(0.015625, 128)

wire = scheme.encode()
assert len(wire) == 16          # 4-byte header + 8 bytes scale/zp + 4 reserved

assert hurray.decode_quantization(wire) == scheme

# Dequantization formula: x_real = scale * (q - zero_point), where q is a raw
# int8 from storage. Applying it is the consuming framework's job, not the codec's.

Use case: Uniform quantization across an entire weight matrix or activation tensor.

Per-Channel Affine Quantization (Output Channel Scaling)

When each output channel has its own scale (typical in INT8 quantized LLM weights):

use hurray_core::{PerChannelAffine, QuantizationDescriptor, BufferHandle, DeviceTag, MIN_BUFFER_ALIGNMENT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Symmetric mode: scale array only, zero point implicit (all zeros)
    let q_sym = PerChannelAffine::new_symmetric(
        0,  // quantization axis (e.g., output channels)
        1,  // scale buffer index in tensor's buffer table
    )?;
    let desc_sym = QuantizationDescriptor::PerChannelAffine(q_sym);

    let encoded_sym = desc_sym.encode_to_vec();
    println!("Symmetric per-channel: {} bytes", encoded_sym.len()); // 20

    // Asymmetric mode: both scale and zero_point arrays
    let q_asym = PerChannelAffine::new_asymmetric(
        1,  // quantization axis
        2,  // scale buffer index
        3,  // zero_point buffer index
    )?;
    let desc_asym = QuantizationDescriptor::PerChannelAffine(q_asym);

    let encoded_asym = desc_asym.encode_to_vec();
    println!("Asymmetric per-channel: {} bytes", encoded_asym.len()); // 20

    // Dequantization formula: x_real = scale[c] * (q - zero_point[c])
    // where c = logical_index[axis]
    Ok(())
}
import hurray

scheme = hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1)

assert scheme.axis == 0
assert hurray.decode_quantization(scheme.encode()) == scheme

Use case: Per-output-channel quantization in transformer weight matrices; achieves better accuracy than per-tensor.

Per-Block Affine Quantization (QLoRA-Style)

Divide a tensor into fixed-size blocks along one axis; each block carries its own scale and (optionally) zero point:

use hurray_core::{PerBlockAffine, QuantizationDescriptor, ElementType};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Symmetric: scale only (zero_point implicit)
    let q_sym = PerBlockAffine::new_symmetric(
        0,                      // quantization axis
        64,                     // block size (must be power of two ≥ 2)
        1,                      // scale buffer index
        ElementType::Float32,   // scale element type
    )?;
    let desc_sym = QuantizationDescriptor::PerBlockAffine(q_sym);

    let encoded_sym = desc_sym.encode_to_vec();
    println!("Per-block symmetric: {} bytes", encoded_sym.len()); // 24

    // Asymmetric: scale and zero_point arrays
    let q_asym = PerBlockAffine::new_asymmetric(
        1,                      // quantization axis
        32,                     // block size
        2,                      // scale buffer index
        3,                      // zero_point buffer index
        ElementType::Float16,   // scale in float16 (more compact)
    )?;
    let desc_asym = QuantizationDescriptor::PerBlockAffine(q_asym);

    let encoded_asym = desc_asym.encode_to_vec();
    println!("Per-block asymmetric: {} bytes", encoded_asym.len()); // 24

    // Dequantization formula: x_real = scale[b] * (q - zero_point[b])
    // where b = block_index = logical_index[axis] / block_size
    Ok(())
}
import hurray

scheme = hurray.PerBlockAffine.symmetric(
    axis=0, block_size=64, scale_buffer_index=1, scale_type=hurray.float32
)

assert scheme.symmetric
assert hurray.decode_quantization(scheme.encode()) == scheme

Scale types: Float16, BFloat16, or Float32 (controlled per descriptor).

Use case: QLoRA-style quantization; balances compression and accuracy in large language models.

NF4 Block Quantization

A non-linear 4-bit scheme with 16 fixed quantization levels (from QLoRA). Each block has a single absolute-maximum scale:

use hurray_core::{Nf4, QuantizationDescriptor, NF4_LUT};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // NF4: 4-bit storage, 16 fixed levels
    let q = Nf4::new(
        0,      // quantization axis
        64,     // block size (must be power of two ≥ 8)
        1,      // scale buffer index (contains absmax values)
    )?;
    let desc = QuantizationDescriptor::Nf4(q);

    let encoded = desc.encode_to_vec();
    println!("NF4: {} bytes", encoded.len()); // 16

    // Inspect the fixed NF4 lookup table
    println!("NF4 levels (indexed by 4-bit code):");
    for (i, &level) in NF4_LUT.iter().enumerate() {
        println!("  [{}] = {:.4}", i, level);
    }

    // Dequantization formula: x_real = scale[b] * NF4_LUT[q]
    // where q ∈ [0, 15] (the 4-bit storage code)
    // and b = block_index = logical_index[axis] / block_size
    Ok(())
}
import hurray

scheme = hurray.NF4(axis=0, block_size=64, scale_buffer_index=1)

assert scheme.block_size == 64
assert hurray.decode_quantization(scheme.encode()) == scheme

Block size constraint: Must be a power of two ≥ 8.

Use case: Quantization of weight matrices in LLMs using the QLoRA approach; achieves ≤4-bit effective precision with minimal accuracy loss.

MXFP Block Quantization (OCP Microscaling)

Open Compute Project Microscaling (OCP MX) format: blocks share a single exponent-only scale in float8_e8m0 format. Requires exact divisibility (no partial trailing blocks):

use hurray_core::{Mxfp, QuantizationDescriptor, MXFP_CANONICAL_BLOCK_SIZE};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // MXFP: standard block size from OCP MX spec
    let q = Mxfp::new(
        0,                      // quantization axis
        MXFP_CANONICAL_BLOCK_SIZE, // 32 (canonical OCP MX v1.0 block size)
        1,                      // scale buffer index
    )?;
    let desc = QuantizationDescriptor::Mxfp(q);

    let encoded = desc.encode_to_vec();
    println!("MXFP: {} bytes", encoded.len()); // 16

    // Alternative: custom block size (still must be power-of-two in [16, 2048])
    let q_custom = Mxfp::new(0, 64, 1)?;
    println!("MXFP with block_size=64: valid");

    // IMPORTANT: Unlike per-block affine, MXFP requires exact divisibility.
    // If shape[axis] is 1000 and block_size is 32, this descriptor is INVALID
    // because 1000 is not divisible by 32.

    // Dequantization formula: x_real = 2^(e - 127) * q
    // where e is the float8_e8m0 scale byte from scale buffer
    Ok(())
}
import hurray

scheme = hurray.MXFP(axis=0, block_size=32, scale_buffer_index=1)

assert scheme.block_size == 32
assert hurray.decode_quantization(scheme.encode()) == scheme

Block size constraint: Power of two in [16, 2048] (inclusive).

Divisibility requirement: shape[axis] must be a positive multiple of block_size. No partial blocks.

Use case: Hardware-friendly quantization for inference accelerators supporting OCP Microscaling (NVIDIA H100+, etc.).

Choosing a Quantization Scheme

SchemeScopeGrainStorageBest For
Per-Tensor AffineEntire tensorSingle scale/ZP8 bytes payloadUniform quantization, simplicity
Per-Channel AffineOne axisOne scale/ZP per sliceSeparate scale/ZP buffersLLM weight quantization (INT8)
Per-Block AffineBlocks along axisOne scale/ZP per blockSeparate scale/ZP buffersModerate compression (QLoRA)
NF4Blocks along axisOne absmax per blockFixed 16-level LUTAggressive 4-bit quantization
MXFPBlocks along axisExponent-only scaleSeparate scale buffer (float8_e8m0)Hardware-accelerated OCP MX targets

Validating Buffer Placement

Quantization descriptors reference external buffers (for scales and zero points). Validate placement before building a tensor descriptor:

use hurray_core::{
    BufferHandle, DeviceTag, Nf4, QuantizationDescriptor, SyncMode,
    validate_buffer_placement, MIN_BUFFER_ALIGNMENT,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create buffers on the same device
    let data_buf = BufferHandle::new(4096, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
    let scale_buf = BufferHandle::new(256, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)?;
    let buffers = [data_buf, scale_buf];

    // Create an NF4 descriptor with scale at buffer index 1
    let q = Nf4::new(0, 64, 1)?;
    let desc = QuantizationDescriptor::Nf4(q);

    // Validate: scale_buffer_index=1 must be in range, not alias data (index 0),
    // and be on the same device.
    validate_buffer_placement(&desc, &buffers, 0)?;

    println!("Buffer placement valid!");
    Ok(())
}
import hurray

# Python checks placement when the tensor is built, against the buffers it was
# actually given — there is no separate descriptor to validate in isolation.
tensor = hurray.Tensor(
    bytes(4096),
    hurray.dtype.int4,
    [64, 64],
    aux_buffers=[bytes(256)],
    quantization=hurray.NF4(axis=0, block_size=64, scale_buffer_index=1),
)
assert tensor.buffer_count == 2

# An index that names no buffer is refused.
try:
    hurray.Tensor(
        bytes(4096),
        hurray.dtype.int4,
        [64, 64],
        aux_buffers=[bytes(256)],
        quantization=hurray.NF4(axis=0, block_size=64, scale_buffer_index=7),
    )
    raise AssertionError("buffer 7 does not exist")
except hurray.InvalidDescriptorError as exc:
    print(exc)

Constraints checked:

  1. All quantization parameter buffer indices are within buffers.len().
  2. No quantization buffer index equals the data buffer index (no aliasing).
  3. All quantization buffers are on the same device as the data buffer.

Encoding and Decoding

Use encode_into for streaming writers (zero-alloc) or encode_to_vec for convenience:

use hurray_core::{PerTensorAffine, QuantizationDescriptor};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let desc = QuantizationDescriptor::PerTensorAffine(
        PerTensorAffine::new(0.5, 0)?
    );

    // Zero-alloc path (required for streaming)
    let mut buf = vec![0u8; desc.encoded_len()];
    let written = desc.encode_into(&mut buf)?;
    assert_eq!(written, desc.encoded_len());

    // Decode and verify round-trip
    let (decoded, consumed) = QuantizationDescriptor::decode(&buf)?;
    assert_eq!(consumed, written);
    assert_eq!(decoded, desc);

    println!("Roundtrip successful!");
    Ok(())
}
import hurray

scheme = hurray.PerTensorAffine(0.5, 0)

# 16 to 24 bytes, so there is no zero-alloc variant to reach for: the section
# is smaller than the cost of arranging to avoid the allocation.
wire = scheme.encode()
assert len(wire) == 16

assert hurray.decode_quantization(wire) == scheme

decode_quantization returns whichever of the five classes the section's scheme tag names, so a caller reads the bytes without knowing in advance which scheme wrote them — which is what a tagged section is for. Trailing bytes are ignored: the section carries its own length, which is what lets it sit inside a descriptor with other sections after it.

Note: encode_into is preferred in hot paths (streaming readers/writers) because it avoids allocation. The descriptor itself is small (16–24 bytes), so encode_to_vec is fine for initialization paths.

Layer 3: Layout Descriptors

Purpose

A layout descriptor tells a reader how the elements of a tensor are arranged in memory. Every tensor descriptor includes exactly one layout tag byte followed by layout-specific fields. The hurray-core LayoutDescriptor enum models all layouts defined in the spec, from the zero-overhead unit variants (RowMajor, ColMajor) to sparse multi-buffer formats and permissive-mode passthrough.

Quick reference: layout tags and buffer counts

VariantTagBuffer countNotes
RowMajor0x011No fields; strides are implicit
ColMajor0x021No fields; strides are implicit
Strided0x031Explicit strides: Vec<i64>; negative/zero valid
Tiled0x041Tile shape, outer/inner layout tags, optional strides; recursive
Morton0x051Per-dimension bit counts
Coo0x062nnz, is_sorted; values + index buffers
Csr0x073nnz; values + col_indices + row_ptr; rank-2 only
Csc0x083nnz; values + row_indices + col_ptr; rank-2 only
Csf0x092·rank+1nnz, mode_order permutation; values + per-level pos/crd; rank-3+ generalization of CSR/CSC. See CSF (Compressed Sparse Fiber)
BlockPaged0x0A3PagedAttention KV cache; page_pool + block_table + seq_ptr; rank-3 only. See Block-Paged KV Cache
Hilbert0x401hilbert_order, hilbert_rank; dims must be 2^order
PrivateExtension0xF0–0xFENoneOpaque; requires out-of-band agreement
Unknownany unrecognisedNonePermissive mode only; never dereference data

Constructing dense layouts

Unit variants need no constructor:

use hurray_core::layout::LayoutDescriptor;

let rm = LayoutDescriptor::RowMajor;
let cm = LayoutDescriptor::ColMajor;
assert_eq!(rm.tag(), 0x01);
assert_eq!(cm.tag(), 0x02);
import hurray

rm = hurray.RowMajorLayout()
cm = hurray.ColMajorLayout()
assert rm.tag == 0x01
assert cm.tag == 0x02

Python spells each layout as its own class rather than a tag byte (ADR-032); the tag is still there on every one of them.

Strided layout — explicit per-dimension strides in logical elements. Negative strides reverse a dimension; zero strides broadcast (virtual dimension, no physical replication):

use hurray_core::layout::{LayoutDescriptor, StridedLayout};

// Row-major strides for a 3×4 tensor: last dim varies fastest.
let rm_strides = LayoutDescriptor::Strided(StridedLayout::new(vec![4, 1]));

// Same tensor with first dimension reversed.
let reversed = LayoutDescriptor::Strided(StridedLayout::new(vec![-4, 1]));

// Broadcast along dimension 0: all rows map to row 0.
let broadcast = LayoutDescriptor::Strided(StridedLayout::new(vec![0, 1]));
import hurray

# Row-major strides for a 3x4 tensor: last dim varies fastest.
rm_strides = hurray.StridedLayout([4, 1])

# Same tensor with first dimension reversed.
reversed_ = hurray.StridedLayout([-4, 1])

# Broadcast along dimension 0: all rows map to row 0.
broadcast = hurray.StridedLayout([0, 1])

assert rm_strides.strides == (4, 1)

Tiled / blocked layout

2×4 tiles with row-major outer ordering and column-major inner ordering:

use hurray_core::layout::{LayoutDescriptor, TiledLayout};

let tiled = LayoutDescriptor::Tiled(Box::new(
    TiledLayout::new(
        vec![2, 4], // tile_shape
        0x01,       // outer_layout: row-major
        0x02,       // inner_layout: column-major
        None,       // outer_strides: None (implicit for row-major outer)
        None,       // inner_strides: None
        None,       // inner_tiled: None (not recursive)
    ).unwrap(),
));
import hurray

tiled = hurray.TiledLayout(
    [2, 4],                    # tile_shape
    outer_layout="row_major",
    inner_layout="col_major",
)

The nested layouts are named, not tagged: "row_major" rather than 0x01. Python reads the tag back off layout.tag when it needs the wire value.

Strided tile grid — outer_strides must be provided when outer_layout == 0x03:

use hurray_core::layout::{LayoutDescriptor, OuterStrides, TiledLayout};

let tiled_strided = LayoutDescriptor::Tiled(Box::new(
    TiledLayout::new(
        vec![2, 2],
        0x03, // strided outer
        0x01, // row-major inner
        Some(OuterStrides::new(vec![2, 1])), // tile-grid strides in units of tiles
        None,
        None,
    ).unwrap(),
));
import hurray

tiled_strided = hurray.TiledLayout(
    [2, 2],
    outer_layout="strided",            # tile-grid strides required
    inner_layout="row_major",
    outer_strides=[2, 1],              # in units of tiles
)

Recursive tiling (two levels of blocking, useful for hierarchical GEMM caches):

use hurray_core::layout::TiledLayout;

let inner = TiledLayout::new(vec![4, 4], 0x01, 0x01, None, None, None).unwrap();
let outer = TiledLayout::new(
    vec![32, 32],
    0x01,
    0x04, // inner_layout is itself tiled
    None,
    None,
    Some(Box::new(inner)),
).unwrap();
import hurray

inner = hurray.TiledLayout([4, 4], "row_major", "row_major")
outer = hurray.TiledLayout(
    [32, 32],
    outer_layout="row_major",
    inner_layout="tiled",              # the inner layout is itself tiled
    inner_tiled=inner,
)

Maximum recursion depth is 8 levels; deeper nesting returns Error::InvalidLayout.

Sparse layouts

COO — two buffers (values + flat index array):

use hurray_core::layout::{CooLayout, LayoutDescriptor};

let coo = LayoutDescriptor::Coo(CooLayout::new(
    42,   // nnz
    true, // is_sorted: non-zeros in lexicographic order
));
assert_eq!(coo.buffer_count().map(|n| n.get()), Some(2));
import hurray

coo = hurray.CooLayout(
    nnz=42,
    is_sorted=True,     # non-zeros in lexicographic order
)
assert coo.buffer_count == 2

CSR — three buffers (values + col_indices + row_ptr), rank-2 only:

use hurray_core::layout::{CsrLayout, LayoutDescriptor};

let csr = LayoutDescriptor::Csr(CsrLayout::new(100)); // nnz = 100
assert_eq!(csr.buffer_count().map(|n| n.get()), Some(3));
import hurray

csr = hurray.CsrLayout(nnz=100)
assert csr.buffer_count == 3

CSC — three buffers (values + row_indices + col_ptr), rank-2 only:

use hurray_core::layout::{CscLayout, LayoutDescriptor};

let csc = LayoutDescriptor::Csc(CscLayout::new(100));
assert_eq!(csc.buffer_count().map(|n| n.get()), Some(3));
import hurray

csc = hurray.CscLayout(nnz=100)
assert csc.buffer_count == 3

CSF (Compressed Sparse Fiber) — the rank-N (rank ≥ 3) generalization of CSR/CSC, with 2·rank + 1 buffers (values plus a pos/crd pair per level). The buffer count is derived from the rank, which CsfLayout carries via its mode_order permutation (mode_order[L] is the logical dimension stored at level L). Writers SHOULD prefer CSR/CSC for rank-2 sparse matrices and reserve CSF for rank ≥ 3:

use hurray_core::layout::{CsfLayout, LayoutDescriptor};
use hurray_core::Shape;

// Rank-3 sparse tensor, identity mode order, 4 non-zeros.
let csf = LayoutDescriptor::Csf(CsfLayout::new(4, vec![0, 1, 2]));
assert_eq!(csf.tag(), 0x09);
assert_eq!(csf.buffer_count().map(|n| n.get()), Some(7)); // 2*3 + 1

// rank ≥ 3 only; CSR/CSC own rank-2.
let shape = Shape::new(vec![2, 3, 4]).unwrap();
assert!(csf.validate_against_shape(&shape).is_ok());
assert!(csf
    .validate_against_shape(&Shape::new(vec![3, 4]).unwrap())
    .is_err());
import hurray

# Rank-3 sparse tensor, identity mode order, 4 non-zeros.
csf = hurray.CsfLayout(nnz=4, mode_order=[0, 1, 2])
assert csf.tag == 0x09
assert csf.buffer_count == 7        # 2*3 + 1

# rank >= 3 only; CSR/CSC own rank-2.
csf.validate_against_shape([2, 3, 4])
try:
    csf.validate_against_shape([3, 4])
    raise AssertionError("rank-2 should be refused")
except hurray.InvalidDescriptorError:
    pass

See CSF (Compressed Sparse Fiber) for the full per-level buffer layout and lookup.

Space-filling curve layouts

Morton (Z-order) — per-dimension bit counts control how many index bits are interleaved per dimension. Each shape[k] must satisfy shape[k] <= 2^morton_bits[k]:

use hurray_core::layout::{LayoutDescriptor, MortonLayout};
use hurray_core::Shape;

// 4×4 tensor: each dim needs 2 bits (4 <= 2^2).
let morton = LayoutDescriptor::Morton(MortonLayout::new(vec![2, 2]).unwrap());
let shape = Shape::new(vec![4, 4]).unwrap();
morton.validate_against_shape(&shape).unwrap();
import hurray

# 4x4 tensor: each dim needs 2 bits (4 <= 2^2).
morton = hurray.MortonLayout([2, 2])
morton.validate_against_shape([4, 4])

Hilbert curve — all dims must equal 2^hilbert_order; rank must be >= 2:

use hurray_core::layout::{HilbertLayout, LayoutDescriptor};
use hurray_core::Shape;

// 8×8×8 tensor: order=3 (8 = 2^3), rank=3.
let hilbert = LayoutDescriptor::Hilbert(HilbertLayout::new(3, 3).unwrap());
let shape = Shape::new(vec![8, 8, 8]).unwrap();
hilbert.validate_against_shape(&shape).unwrap();
import hurray

# 8x8x8 tensor: order=3 (8 = 2^3), rank=3.
hilbert = hurray.HilbertLayout(hilbert_order=3, hilbert_rank=3)
hilbert.validate_against_shape([8, 8, 8])

Tag introspection and validation

use hurray_core::layout::{
    validate_layout_tag_strict, is_invalid_tag, is_named_tag, is_reserved_tag,
    is_private_tag, LayoutDescriptor, UnknownLayout,
};
use hurray_core::Error;

// Check individual tag categories without constructing a descriptor.
// 0x10 is a genuinely unassigned tag in the Tier-1 reserved range.
assert!(is_invalid_tag(0x00));
assert!(is_named_tag(0x07));      // CSR — this crate knows how to check it
assert!(is_reserved_tag(0x10));
assert!(is_private_tag(0xF3));

// Strict-mode validation: rejects invalid, reserved, and private tags.
assert!(validate_layout_tag_strict(0x01).is_ok());
assert!(matches!(validate_layout_tag_strict(0x00), Err(Error::InvalidLayoutTag(0x00))));
assert!(matches!(validate_layout_tag_strict(0x10), Err(Error::ReservedLayoutTag(0x10))));
assert!(matches!(validate_layout_tag_strict(0xF0), Err(Error::PrivateLayoutTag(0xF0))));

// Permissive mode: wrap unrecognised tags in Unknown for passthrough.
// The reader must NOT dereference the tensor data buffer for Unknown layouts.
let unknown = LayoutDescriptor::Unknown(UnknownLayout::new(0x10, vec![]).unwrap());
assert_eq!(unknown.tag(), 0x10);
assert!(unknown.buffer_count().is_none());

// Only genuinely unrecognised tags: "unknown" is a claim, and it has to be true.
// A named tag wrapped this way would skip every check its own variant applies
// while still encoding to that tag on the wire.
assert!(matches!(UnknownLayout::new(0x07, vec![]), Err(Error::NamedLayoutTag(0x07))));
assert!(matches!(UnknownLayout::new(0xF0, vec![]), Err(Error::PrivateLayoutTag(0xF0))));
import hurray

# Classify a tag without constructing a descriptor. The four categories partition
# the byte space, so one call answers the question four predicates would.
assert hurray.layout_tag_kind(0x07) == "named"      # CSR
assert hurray.layout_tag_kind(0x10) == "reserved"
assert hurray.layout_tag_kind(0xF3) == "private"
assert hurray.layout_tag_kind(0x00) == "invalid"

# Permissive mode: wrap an unrecognised tag for passthrough.
# The reader must NOT dereference the tensor data buffer for an Unknown layout.
unknown = hurray.UnknownLayout(0x10, b"")
assert unknown.tag == 0x10
assert unknown.buffer_count is None

# Only genuinely unrecognised tags: "unknown" is a claim, and it has to be true.
# A named tag wrapped this way would skip every check its own class applies while
# still encoding to that tag on the wire.
for taken in (0x07, 0xF0):
    try:
        hurray.UnknownLayout(taken, b"")
        raise AssertionError(f"0x{taken:02X} is not unknown")
    except ValueError as exc:
        print(exc)

The four kinds call for different reactions, which is why the classification is worth having: reserved most likely means the producer is newer than this reader, so relaying the tensor on is reasonable while interpreting its bytes is not; private belongs to an out-of-band agreement; invalid means corruption or a framing error.

Validating a descriptor against a tensor shape

validate_against_shape is called by Layer 4 (tensor descriptor) to enforce layout-specific rank and dimension constraints. Call it explicitly when building descriptors to catch mismatches early:

use hurray_core::layout::{CsrLayout, LayoutDescriptor};
use hurray_core::Shape;

let csr = LayoutDescriptor::Csr(CsrLayout::new(5));

// Rank-2: valid.
assert!(csr.validate_against_shape(&Shape::new(vec![4, 5]).unwrap()).is_ok());

// Rank-3: rejected — CSR is only defined for rank-2 tensors.
assert!(csr.validate_against_shape(&Shape::new(vec![2, 3, 4]).unwrap()).is_err());
import hurray

csr = hurray.CsrLayout(nnz=5)

# Rank-2: valid.
csr.validate_against_shape([4, 5])

# Rank-3: rejected - CSR is only defined for rank-2 tensors.
try:
    csr.validate_against_shape([2, 3, 4])
    raise AssertionError("rank-3 should be refused")
except hurray.InvalidDescriptorError:
    pass

hurray.Tensor runs this for you at construction. Call it directly when you are choosing a layout for a shape you have not built a tensor for yet.

Private extension layouts

For hardware-specific panel/pack formats agreed out of band:

use hurray_core::layout::{LayoutDescriptor, PrivateExtensionLayout};

let private = LayoutDescriptor::PrivateExtension(
    PrivateExtensionLayout::new(
        0xF0,                    // tag: must be 0xF0–0xFE
        0xDEAD_BEEF_0000_0001,   // implementation-defined layout ID
        vec![0x01, 0x00, 0x04],  // opaque metadata
    ).unwrap(),
);
// buffer_count is None: the format doesn't know how many buffers this needs.
assert!(private.buffer_count().is_none());
import hurray

private = hurray.PrivateExtensionLayout(
    0xF0,                       # tag: must be 0xF0-0xFE
    0xDEAD_BEEF_0000_0001,      # implementation-defined layout ID
    b"\x01\x00\x04",            # opaque metadata
)
# buffer_count is None: the format doesn't know how many buffers this needs.
assert private.buffer_count is None

Layer 4: Tensor Descriptor Encoding

Purpose

A TensorDescriptor is the top-level carrier for all metadata required to interpret a tensor's data buffer: element type, rank, shape, memory layout, buffer handles, and optional quantization, shard, statistics, and extension-type annotations.

The binary format is defined in docs/spec/metadata.md. A 20-byte fixed header is followed by variable-length core fields, layout-specific payload, a buffer table, and up to four optional sections selected by a flags bitmask.

Runnable example: cargo run --example encode_decode_descriptor

Quick reference: wire format sections

SectionAlways present?Controlled by
Fixed header (20 bytes)YesAlways
Shape uint64[rank]Yesshape.rank()
byte_offset uint64YesAlways
Layout payloadYeslayout.tag()
Buffer tableYesbuffers.len()
QuantizationHAS_QUANTIZATION flagquantization.is_some()
ShardHAS_SHARD flagshard.is_some()
Extension typeHAS_EXTENSION_TYPE flagextension_type.is_some()
StatisticsHAS_STATISTICS flagstatistics.is_some()

Encoding and decoding

The spec's worked example — float32 [3, 4] row-major, one CPU buffer — encodes to exactly 61 bytes:

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
    descriptor::TensorDescriptor,
    layout::LayoutDescriptor,
};

let shape  = Shape::new(vec![3u64, 4]).unwrap();
// 192 = 3 × 64 bytes (one cache line per row) — the spec's worked example allocation.
// The tensor data itself needs only 3×4×4 = 48 bytes; byte_size records the physical
// allocation which may exceed the data footprint. In real code use buffer_size_bytes().
let buffer = BufferHandle::new(192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();

let desc = TensorDescriptor::new(
    1, 0,                      // version_major, version_minor
    ElementType::Float32,
    shape,
    0,                         // byte_offset
    LayoutDescriptor::RowMajor,
    vec![buffer],
    None,                      // quantization
    None,                      // shard
    None,                      // statistics
    None,                      // extension_type
).unwrap();

let bytes = desc.encode().unwrap();
assert_eq!(bytes.len(), 61);   // spec worked example

// Decode back — descriptor is byte-exact round-trip.
let decoded = TensorDescriptor::decode(&bytes).unwrap();
assert_eq!(decoded, desc);
import hurray

# A descriptor comes from a tensor rather than being built on its own: it is the
# half of a tensor that travels first, not a separate thing to assemble.
tensor = hurray.Tensor(bytes(192), hurray.float32, [3, 4])
descriptor = tensor.descriptor

wire = descriptor.encode()
assert len(wire) == 61          # spec worked example

# Decode back — byte-exact round trip.
assert hurray.Descriptor.decode(wire) == descriptor

Descriptor is not constructible from Python: a constructor would duplicate hurray.Tensor's whole parameter list to build the half of it that carries no data. Descriptors come from a tensor, from a composite head (Composite.descriptor), or from decode.

Advisory statistics

Attach pre-computed statistics (value range, NaN/Inf presence, etc.) using Statistics and StatisticsMask. Only the bits set in computed_mask carry valid values; all other fields are zero and MUST be ignored by readers:

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
    descriptor::{Statistics, StatisticsMask, TensorDescriptor},
    layout::LayoutDescriptor,
};

// Construct Statistics with all fields explicitly — only VALUE_RANGE_VALID and
// NAN_INF_VALID bits are set; unset-mask fields are zero (undefined by spec).
let stats = Statistics {
    computed_mask: StatisticsMask(
        StatisticsMask::VALUE_RANGE_VALID | StatisticsMask::NAN_INF_VALID,
    ),
    nnz: 0,
    sparsity_ratio: 0.0,
    value_min: -1.0,
    value_max:  1.0,
    value_abs_max: 1.0,
    value_mean: 0.0,
    value_stddev: 0.0,
    nm_n: 0,
    nm_m: 0,
    has_nan: false,
    has_inf: false,
};

let shape  = Shape::new(vec![8u64, 8]).unwrap();
let buffer = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();

let desc = TensorDescriptor::new(
    1, 0, ElementType::Float16, shape, 0,
    LayoutDescriptor::ColMajor, vec![buffer],
    None, None, Some(stats), None,
).unwrap();

// Statistics section appends 72 bytes; encode then decode.
let bytes   = desc.encode().unwrap();
let decoded = TensorDescriptor::decode(&bytes).unwrap();
let s = decoded.statistics.as_ref().unwrap();
assert!(s.computed_mask.value_range_valid());
assert!(!s.has_nan);
import hurray

# Python derives computed_mask from which arguments you pass, so a value can
# never be present with its validity bit unset.
# value_min, value_max and value_abs_max share one validity bit, so they are
# supplied together — Python enforces that rather than letting you set a bit
# for a value you did not compute.
stats = hurray.Statistics(
    value_min=-1.0, value_max=1.0, value_abs_max=1.0, has_nan=False, has_inf=False
)

tensor = hurray.Tensor(bytes(128), hurray.float16, [8, 8], statistics=stats)

decoded = hurray.Descriptor.decode(tensor.descriptor.encode())

assert decoded.statistics.value_min == -1.0
assert decoded.statistics.has_nan is False

Shard annotations

When a tensor is a rectangular sub-region of a larger logical tensor (e.g., a row shard of a matrix), attach a ShardDescriptor. The parent_shape rank must match the tensor's rank and shard_offset[k] + shape[k] <= parent_shape[k] must hold for every dimension k:

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
    descriptor::{ShardDescriptor, TensorDescriptor},
    layout::LayoutDescriptor,
};

// Shard: rows 2048..3071 of a 4096×1024 parent matrix.
let shape  = Shape::new(vec![1024u64, 1024]).unwrap();
let buffer = BufferHandle::new(524_288, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let shard  = ShardDescriptor::new(
    vec![4096u64, 1024], // parent_shape
    vec![2048u64, 0],    // shard_offset (origin in parent)
).unwrap();

let desc = TensorDescriptor::new(
    1, 0, ElementType::Int4, shape, 0,
    LayoutDescriptor::RowMajor, vec![buffer],
    None, Some(shard), None, None,
).unwrap();

let bytes   = desc.encode().unwrap();
let decoded = TensorDescriptor::decode(&bytes).unwrap();

let s = decoded.shard.as_ref().unwrap();
assert_eq!(s.parent_shape, [4096, 1024]);
assert_eq!(s.shard_offset, [2048, 0]);
import hurray

# Shard: rows 2048..3071 of a 4096x1024 parent matrix.
tensor = hurray.Tensor(
    bytes(524_288),
    hurray.dtype.int4,
    [1024, 1024],
    shard=hurray.Shard([4096, 1024], [2048, 0]),
)

decoded = hurray.Descriptor.decode(tensor.descriptor.encode())

assert decoded.shard.parent_shape == (4096, 1024)
assert decoded.shard.shard_offset == (2048, 0)

Validation errors

TensorDescriptor::new rejects invalid combinations:

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT, Error,
    descriptor::TensorDescriptor,
    layout::LayoutDescriptor,
};

let shape  = Shape::new(vec![4u64, 4]).unwrap();
let buffer = BufferHandle::new(64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();

// Empty buffer table is rejected.
let result = TensorDescriptor::new(
    1, 0, ElementType::Float32, shape.clone(), 0,
    LayoutDescriptor::RowMajor, vec![], // no buffers
    None, None, None, None,
);
assert!(matches!(result, Err(Error::EmptyBufferTable)));

// Extension type flag must be consistent with the element type tag.
// Float32 (tag 0x03) is not an extension type — providing ExtensionTypeDescriptor is an error.
use hurray_core::descriptor::ExtensionTypeDescriptor;
// An 8-bit float, 1-4-3. A float's sign is sign_bits; is_signed stays false.
let ext = ExtensionTypeDescriptor::new(8, 1, true, false, 1, 4, 3, 7, true, false).unwrap();
let result = TensorDescriptor::new(
    1, 0, ElementType::Float32, shape.clone(), 0,
    LayoutDescriptor::RowMajor, vec![buffer.clone()],
    None, None, None,
    Some(ext), // mismatch: Float32 is not an extension type
);
assert!(matches!(result, Err(Error::ExtensionTypeFlagMismatch { .. })));

Decode errors

TensorDescriptor::decode rejects malformed inputs:

use hurray_core::{descriptor::TensorDescriptor, Error};

// Truncated input.
let result = TensorDescriptor::decode(&[0x48, 0x52, 0x52, 0x59]);
assert!(matches!(
    result,
    Err(Error::DescriptorTooShort { .. } | Error::DescriptorTruncated { .. })
));

// Wrong magic bytes.
let mut bad = vec![0u8; 61];
bad[0..4].copy_from_slice(b"BAAD");
let result = TensorDescriptor::decode(&bad);
assert!(matches!(result, Err(Error::InvalidMagic { .. })));

Wire format anatomy (61-byte example)

Offset  Size  Field
──────  ────  ─────────────────────────────────────────────────────────────
0x00    4     magic "HRRY" (0x48 0x52 0x52 0x59)
0x04    1     version_major = 0x01
0x05    1     version_minor = 0x00
0x06    4     descriptor_length = 61 (0x3D 0x00 0x00 0x00, little-endian)
0x0A    4     flags = 0x00000000 (no optional sections)
0x0E    1     type_tag = 0x03 (float32)
0x0F    1     layout_tag = 0x01 (row-major)
0x10    4     rank = 2 (0x02 0x00 0x00 0x00)
0x14    8     shape[0] = 3 (0x03 0x00 0x00 0x00 0x00 0x00 0x00 0x00)
0x1C    8     shape[1] = 4 (0x04 0x00 0x00 0x00 0x00 0x00 0x00 0x00)
0x24    8     byte_offset = 0
              ── layout payload: RowMajor has no additional bytes ──
0x2C    1     buffer_count = 1
0x2D    8     buffer[0].size_bytes = 192
0x35    4     buffer[0].alignment = 64 (0x40 0x00 0x00 0x00, little-endian uint32)
0x39    1     buffer[0].device_tag = 0x00 (CPU)
0x3A    3     buffer[0]._reserved = 0 0 0
              ── no optional sections (flags == 0) ──

Layer 5 — Streaming Interchange

This cookbook shows how to stream tensors between producers and consumers using hurray-io's StreamWriter and StreamReader.

Feature flag

The streaming API requires the tokio feature:

# Cargo.toml
hurray-io = { path = "…/hurray-io", features = ["tokio"] }

Wire format

Tensors are written as bare concatenation — no outer framing, no padding:

[encoded TensorDescriptor][buffer 0 bytes][buffer 1 bytes]…
[encoded TensorDescriptor][buffer 0 bytes]…
…
EOF

The descriptor is self-delimiting: bytes 6–9 of every descriptor hold a little-endian uint32 descriptor_length that tells the reader exactly how many bytes to consume. Readers detect a clean EOF when zero bytes are available before the first byte of a descriptor.

Writing a stream

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, Shape,
    SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::stream::StreamWriter;

async fn write_tensors(sink: impl tokio::io::AsyncWrite + Unpin)
    -> hurray_io::Result<()>
{
    let handle = BufferHandle::new(
        192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    )?;
    let shape = Shape::new(vec![4u64, 6, 8]).unwrap();
    let desc = TensorDescriptor::new(
        1, 0,
        ElementType::Float32,
        shape,
        0,
        LayoutDescriptor::RowMajor,
        vec![handle],
        None, None, None, None,
    )?;
    let data = vec![0u8; 192];

    let mut writer = StreamWriter::new(sink);
    writer.write_tensor(&desc, &[&data]).await?;
    writer.finish().await?;          // flushes and returns the sink
    Ok(())
}
import hurray

tensor = hurray.Tensor(bytes(768), hurray.float32, [4, 6, 8])   # 192 float32

# A path, an object with fileno(), or nothing at all for an in-memory buffer.
with hurray.StreamWriter("tensors.hrry") as writer:
    writer.write(tensor)
# Leaving the block finishes the stream: the sink is flushed and closed.

The Python writer takes a hurray.Tensor, which already carries its descriptor and its buffers, so the two cannot disagree — the length checks the Rust API performs have nothing to check.

StreamWriter::write_tensor validates:

  • buffers.len() equals desc.buffers.len() — [Error::MultiBufferLengthMismatch]
  • each buffer's byte length equals its handle's byte_size — [Error::BufferSizeMismatch]

Reading a stream

use hurray_io::stream::StreamReader;

async fn read_tensors(source: impl tokio::io::AsyncRead + Unpin)
    -> hurray_io::Result<()>
{
    let mut reader = StreamReader::new(source);
    while let Some(tensor) = reader.next_tensor().await? {
        println!(
            "element_type={:?} buffers={}",
            tensor.descriptor.element_type,
            tensor.buffers.len(),
        );
        // tensor.buffers[i] is a `bytes::Bytes` — refcounted, zero-copy view
    }
    Ok(())
}
import hurray

for tensor in hurray.StreamReader("tensors.hrry"):
    print(tensor.dtype, tensor.buffer_count)

# Nothing buffers the whole input: a tensor is available as soon as its
# descriptor and buffers have arrived. A clean end of stream ends the loop;
# a truncated one raises hurray.StreamError.

next_tensor() returns:

  • Ok(Some(StreamTensor)) — a decoded tensor
  • Ok(None) — clean EOF (stream ended on a descriptor boundary)
  • Err(Error::UnexpectedEof) — stream truncated mid-descriptor or mid-buffer

Cross-machine transport

When a stream crosses machine boundaries, GPU and semaphore sync primitives are meaningless. Use cross_machine constructors to enforce ProducerSynced on every buffer handle:

use hurray_io::stream::{StreamReader, StreamWriter};

let mut wire: Vec<u8> = Vec::new();
// Writer rejects any buffer whose sync_mode != ProducerSynced.
let mut writer = StreamWriter::cross_machine(&mut wire);

// Reader rejects any decoded buffer whose sync_mode != ProducerSynced.
let mut reader = StreamReader::cross_machine(wire.as_slice());
import hurray

# Writer refuses any buffer whose sync_mode is not "producer_synced".
writer = hurray.StreamWriter("tensors.hrry", cross_machine=True)

# Reader refuses any decoded buffer whose sync_mode is not "producer_synced".
reader = hurray.StreamReader("tensors.hrry", cross_machine=True)

Everything hurray-python constructs is producer_synced already (ADR-037 § 7), so cross_machine=True costs a producer nothing — its value is on the reading end, and in saying at the call site what the transport assumes.

Both constructors are equivalent to constructing with StreamReaderOptions / checking manually, but they make the intent visible at the call site.

Configuring limits

Use StreamReaderOptions to protect against adversarial streams:

use hurray_io::stream::{StreamReader, StreamReaderOptions};

let source: &[u8] = &[];
let options = StreamReaderOptions {
    max_descriptor_bytes: 1024 * 1024,   // 1 MiB
    max_buffer_bytes: 512 * 1024 * 1024, // 512 MiB
    enforce_cross_machine_sync: true,
    // Remaining limits keep their defaults; new ones get added over time.
    ..Default::default()
};
let mut reader = StreamReader::with_options(source, options);
import hurray

source = b""    # any source: a path, an object with fileno(), or a buffer

reader = hurray.StreamReader(
    source,
    max_descriptor_bytes=1 << 20,     # 1 MiB
    max_buffer_bytes=512 << 20,       # 512 MiB
    cross_machine=True,
)

Keyword arguments rather than an options object. max_descriptor_bytes defaults to 16 MiB, max_buffer_bytes is unbounded by default because a legitimate tensor can be enormous, and max_composite_depth defaults to 64. Exceeding any of them raises hurray.StreamError.

These matter because a descriptor's length field is read before its contents: a hostile stream can ask a reader to allocate whatever it likes, and a Python service reading from a socket is exposed exactly as a Rust one is.

The default max_descriptor_bytes is 16 MiB. max_buffer_bytes defaults to u64::MAX (unbounded). Violations produce [Error::FrameTooLarge].

In-process pipe example

use tokio::io::duplex;
use hurray_io::stream::{StreamReader, StreamWriter};

#[tokio::main]
async fn main() {
let (mut client, mut server) = duplex(64 * 1024);

// Producer task
let producer = tokio::spawn(async move {
    let mut writer = StreamWriter::new(&mut client);
    // … write tensors …
    // finish() hands back the inner writer; drop it rather than returning a
    // borrow of `client`, which the spawned task would outlive.
    writer.finish().await?;
    hurray_io::Result::Ok(())
});

// Consumer task
let consumer = tokio::spawn(async move {
    let mut reader = StreamReader::new(&mut server);
    while let Some(tensor) = reader.next_tensor().await? {
        // … process tensor …
    }
    hurray_io::Result::Ok(())
});

producer.await.unwrap().unwrap();
consumer.await.unwrap().unwrap();
}
import os
import threading

import hurray

read_fd, write_fd = os.pipe()

def produce():
    with os.fdopen(write_fd, "wb") as sink:
        with hurray.StreamWriter(sink) as writer:
            writer.write(hurray.Tensor(bytes(16), hurray.float32, [4]))

producer = threading.Thread(target=produce)
producer.start()

with os.fdopen(read_fd, "rb") as source:
    for tensor in hurray.StreamReader(source):
        print(tensor.shape)

producer.join()

The reader releases the GIL while it waits on the pipe, so the producer thread runs.

Runnable example

cargo run --example stream_roundtrip --features tokio -p hurray-io
python hurray-python/examples/streaming.py

Sources: hurray-io/examples/stream_roundtrip.rs and hurray-python/examples/streaming.py.

Error reference

ErrorCause
UnexpectedEofStream ended mid-descriptor or mid-buffer
InvalidHeaderDescriptor prefix is malformed (e.g. descriptor_length < 10)
FrameTooLargeDescriptor or buffer exceeded configured limit
MultiBufferLengthMismatchbuffers slice length ≠ desc.buffers.len()
BufferSizeMismatchA buffer's byte length ≠ its handle's byte_size
InvalidCrossMachineSyncModeCross-machine mode + non-ProducerSynced buffer
Core(…)Descriptor encode/decode failed
Io(…)Underlying async I/O error

Cookbook: Layer 6 — HRRYFILE Container Format

This guide shows how to write and read tensors using the hurray-io file format (HRRYFILE). The file format adds random-access lookup, optional KV metadata, and CRC-32C index integrity on top of the raw tensor stream.

Prerequisites

[dependencies]
hurray-core = { path = "../hurray-core" }
hurray-io   = { path = "../hurray-io", features = ["tokio"] }
tokio       = { version = "1", features = ["full"] }

Writing a file

FileWriter writes tensors in a single forward pass with no seeks. KV metadata and the footer index are flushed when you call finish.

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor,
    Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::{FileWriter, FileWriterOptions, KvValue};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Build a descriptor for a 4×4 float32 tensor (64 bytes)
    let handle = BufferHandle::new(
        64, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    )?;
    let desc = TensorDescriptor::new(
        1, 0, ElementType::Float32, Shape::new(vec![4u64, 4])?,
        0, LayoutDescriptor::RowMajor, vec![handle],
        None, None, None, None,
    )?;
    let data: Vec<u8> = (0u8..64).collect();

    // Write to a file; sorted_index enables binary search by readers
    let file = tokio::fs::File::create("model.hrry").await?;
    let opts = FileWriterOptions { sorted_index: true, ..Default::default() };
    let mut writer = FileWriter::with_options(file, opts).await?;

    writer.write_tensor("layer0.weight", &desc, &[&data]).await?;

    writer.finish(vec![
        ("model".to_string(),  KvValue::String("demo-v1".to_string())),
        ("layers".to_string(), KvValue::Uint64(1)),
    ]).await?;

    println!("Wrote model.hrry");
    Ok(())
}
import hurray

tensor = hurray.Tensor(bytes(range(64)), hurray.float32, [4, 4])

hurray.save(
    "model.hrry",
    {"layer0.weight": tensor},
    kv={"model": "demo-v1", "layers": 1},
)
print("Wrote model.hrry")

One call rather than a writer object: save opens, writes every tensor in the dict, flushes the KV section and the index, and closes. The forward-pass, no-seek property is the writer's, not the caller's, so there is nothing to hold open.

Multi-buffer tensors

If a TensorDescriptor has multiple BufferHandles (e.g. quantized weight + scale), pass one &[u8] per buffer:

use hurray_core::{
    BufferHandle, DeviceTag, ElementType, LayoutDescriptor, PerChannelAffine,
    QuantizationDescriptor, Shape, SyncMode, TensorDescriptor, MIN_BUFFER_ALIGNMENT,
};
use hurray_io::file::FileWriter;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let handle = |len: u64| {
    BufferHandle::new(len, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced)
};
let quant = QuantizationDescriptor::PerChannelAffine(PerChannelAffine::new_symmetric(0, 1)?);
let desc = TensorDescriptor::new(
    1, 0, ElementType::Int8, Shape::new(vec![2u64, 4])?, 0, LayoutDescriptor::RowMajor,
    vec![handle(8)?, handle(8)?], Some(quant.encode_to_vec()), None, None, None,
)?;
let (weight_data, scale_data) = (vec![0u8; 8], vec![0u8; 8]);
let mut writer = FileWriter::new(tokio::fs::File::create("quantized.hrry").await?).await?;
writer.write_tensor("q_layer", &desc, &[&weight_data, &scale_data]).await?;
Ok(())
}
import struct
import hurray

weight_data = bytes(16)                            # 4x4 int8 weights
scale_data = struct.pack("4f", *[0.02] * 4)        # one float32 scale per row

# A tensor carries its own buffers, so a multi-buffer one saves like any other.
quantized = hurray.Tensor(
    weight_data,
    hurray.dtype.int8,
    [4, 4],
    aux_buffers=[scale_data],
    quantization=hurray.PerChannelAffine.symmetric(axis=0, scale_buffer_index=1),
)
hurray.save("quantized.hrry", {"q_layer": quantized})

Reading a file

FileReader requires a seekable source (AsyncRead + AsyncSeek). It reads the trailer on open, then seeks directly to each tensor on demand — no sequential scan.

use hurray_io::file::{FileReader, KvValue};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let file = tokio::fs::File::open("model.hrry").await?;
    let mut reader = FileReader::open(file).await?;

    // List all tensors (in index order — sorted if SORTED_INDEX was set)
    println!("tensors: {:?}", reader.tensor_names().collect::<Vec<_>>());

    // Read KV metadata
    for (key, value) in reader.kv() {
        println!("{key} = {value:?}");
    }

    // Load one tensor by name — seeks directly, skips others
    let tensor = reader.read_tensor("layer0.weight").await?;
    println!("shape: {:?}", tensor.descriptor.shape);
    println!("buffer: {} bytes", tensor.buffers[0].len());

    Ok(())
}
import hurray

# Every tensor in the file, by name.
tensors = hurray.load("model.hrry")
print("tensors:", sorted(tensors))

# The KV metadata section.
for key, value in hurray.load_kv("model.hrry").items():
    print(f"{key} = {value!r}")

# One tensor by name — seeks directly, skips the others.
weight = hurray.load("model.hrry", names=["layer0.weight"])["layer0.weight"]
print("shape:", weight.shape)
print("buffer:", weight.buffer_handles[0].byte_size, "bytes")

load_kv is a separate call rather than an argument to load because it answers a different question and returns a different thing. It costs a second open, which is a footer read rather than a scan.

Descriptor-only reads

When you only need metadata (shape, element type) without loading the buffer bytes:

use hurray_io::file::FileReader;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut reader = FileReader::open(tokio::fs::File::open("model.hrry").await?).await?;
let desc = reader.read_descriptor("layer0.weight").await?;
println!("element type: {:?}", desc.element_type);
Ok(())
}
import hurray

# Python has no descriptor-only read: `load` returns tensors, whose metadata is
# already on the object. Naming one tensor is how you avoid paying for the rest.
weight = hurray.load("model.hrry", names=["layer0.weight"])["layer0.weight"]
print("element type:", weight.dtype.name)

Note (non-normative): a descriptor-only read has no Python counterpart yet. Reading one tensor's metadata still transfers its buffers.

KV value types

VariantWire tagRust typePython type
KvValue::String(s)0x01UTF-8 stringstr
KvValue::Int64(v)0x02i64int
KvValue::Uint64(v)0x03u64int (read only — see below)
KvValue::Float64(v)0x04f64float
KvValue::Bool(v)0x05boolbool
KvValue::Bytes(v)0x06raw bytesbytes
KvValue::Array(elems)0x07homogeneous non-empty array of the abovelist

Array elements must all share the same type and cannot be nested arrays.

In Python the mapping runs both ways — a dict passed to save(kv=...) comes back equal from load_kv — with one asymmetry: Python's int writes as int64, so a value written from Python never uses the uint64 tag, while one written by a Rust producer reads back as an ordinary int. bool is checked before int, since Python's bool is a subclass of it.

File layout overview

[ 64-byte file header  ]  magic "HRRYFILE", version, flags, alignment
[ Tensor region        ]  per tensor: descriptor → pad → buffer(s) → pad
[ KV section           ]  optional; count + (key, value) pairs
[ Index section        ]  count + (name, offsets, lengths, flags) entries
[ 40-byte trailer      ]  index_offset, index_length, kv_offset, kv_length,
                          index_crc32c, _reserved, magic "HRRY"

The reader locates the trailer at file_size - 40, reads offsets, verifies the CRC-32C of the index, then seeks to individual tensors. No full-file scan is ever needed.

Error handling

All errors are variants of hurray_io::Error:

ErrorCause
InvalidFileMagicFirst 8 bytes are not HRRYFILE
InvalidTrailerMagicLast 4 bytes are not HRRY
IndexCrc32cMismatch { stored, computed }Index data is corrupt
UnsupportedContainerVersion { major }Future format version
TensorNotFound(name)No tensor with that name in the index
DuplicateTensorName(name)Writer received the same name twice
TensorNameEmptyWriter received an empty name string
DuplicateKvKey(key)finish() received duplicate KV keys

Running the example

cargo run --example file_roundtrip -p hurray-io

Layer 7 — C FFI Cookbook

The hurray-ffi crate exposes a stable C ABI over hurray-core types. All functions return a HurrayStatus integer (0 = OK, negative = error). All handles are opaque — never inspect their internals.

Note (non-normative): this page has no Python tabs, and should not. The C ABI is the layer underneath a language binding — hurray-python is one of its consumers, not a way to call it. A Python program that wants a Hurray tensor uses hurray.Tensor and the __hurray__ protocol; the function table here is for whoever is writing the next binding.

ABI version check

Always verify the ABI version at startup so mismatched builds are caught early.

C:

#include "hurray.h"
#include <assert.h>

void startup_check(void) {
    uint32_t v = hurray_c_abi_version();
    assert(v == 2 && "unexpected Hurray C ABI version");
}

Rust (via FFI):

use hurray_ffi::{hurray_c_abi_version, HURRAY_C_ABI_VERSION};

assert_eq!(unsafe { hurray_c_abi_version() }, HURRAY_C_ABI_VERSION);

Creating a buffer with a release callback

The release callback is called exactly once by hurray_buffer_destroy. Use it to free or unmap the underlying memory.

C:

#include "hurray.h"
#include <stdlib.h>
#include <stdio.h>

static void my_release(void *data, void *ctx) {
    (void)ctx;
    free(data);
    printf("buffer freed\n");
}

HurrayBuffer *create_cpu_buffer(size_t n_bytes) {
    void *data = aligned_alloc(64, n_bytes);
    if (!data) return NULL;

    HurrayBuffer *handle = NULL;
    HurrayStatus s = hurray_buffer_from_ptr(
        data, (uint64_t)n_bytes,
        /*alignment=*/64,
        /*device_tag=*/0x00,    /* CPU */
        /*sync_mode=*/0x00,     /* ProducerSynced */
        /*memory_class=*/0x00,  /* Standard */
        my_release, /*release_context=*/NULL,
        &handle
    );
    if (s != HURRAY_OK) { free(data); return NULL; }
    return handle;
}

Rust:

use hurray_ffi::{hurray_buffer_from_ptr, HurrayBuffer, HurrayReleaseCallback, HURRAY_OK};
use hurray_core::{DeviceTag, MemoryClass, SyncMode, MIN_BUFFER_ALIGNMENT};
use std::ffi::c_void;

unsafe extern "C" fn release(data: *mut c_void, _ctx: *mut c_void) {
    drop(Vec::from_raw_parts(data as *mut u8, 4096, 4096));
}

let mut storage = vec![0u8; 4096];
let ptr = storage.as_mut_ptr() as *mut c_void;
std::mem::forget(storage);

let mut handle: *mut HurrayBuffer = std::ptr::null_mut();
let status = unsafe {
    hurray_buffer_from_ptr(
        ptr, 4096, MIN_BUFFER_ALIGNMENT,
        DeviceTag::Cpu.to_byte(),
        SyncMode::ProducerSynced.to_byte(),
        MemoryClass::Standard.to_byte(),
        Some(release),
        std::ptr::null_mut(),
        &mut handle,
    )
};
assert_eq!(status, HURRAY_OK);

Destroying a buffer

HurrayStatus s = hurray_buffer_destroy(handle);
assert(s == HURRAY_OK);
/* handle is invalid after this point — do not dereference */

In debug builds, a second call to hurray_buffer_destroy on the same handle returns HURRAY_ERR_INTERNAL (sentinel-based double-free detection).

Decoding a tensor descriptor

#include "hurray.h"

HurrayDescriptor *decode_descriptor(const uint8_t *bytes, size_t len) {
    HurrayDescriptor *desc = NULL;
    HurrayStatus s = hurray_descriptor_decode(bytes, len, &desc);
    if (s != HURRAY_OK) return NULL; /* inspect s for the specific error */
    return desc;
}

void inspect(HurrayDescriptor *desc) {
    uint32_t rank;
    hurray_descriptor_rank(desc, &rank);

    uint64_t dims[64];
    size_t capacity = rank;
    hurray_descriptor_shape(desc, dims, &capacity);
    /* capacity now holds the true rank; dims[0..rank] are the dimension sizes */

    hurray_descriptor_destroy(desc);
}

Shape capacity/query pattern

hurray_descriptor_shape uses an in/out out_rank parameter:

  1. Set *out_rank to the number of uint64_t slots in out_dims.
  2. If the function returns HURRAY_ERR_BUFFER_TOO_SMALL, *out_rank has been updated to the true rank — allocate that many slots and retry.
size_t cap = 0;
/* Query-only call: out_dims=NULL forces BUFFER_TOO_SMALL, writes true rank */
hurray_descriptor_shape(desc, NULL, &cap);

uint64_t *dims = malloc(cap * sizeof(uint64_t));
hurray_descriptor_shape(desc, dims, &cap);

Sync mode handoff cross-check

Before consuming a GPU buffer, call the matching handoff function to verify that the producer's declared sync mode matches your payload.

/* Event mode: producer recorded a CUDA event */
HurraySyncEventPayload payload = {
    .sync_handle           = cuda_event,
    .sync_handle_device_tag = 0x01, /* CUDA */
    .event_release_fn      = my_event_release,
    .event_release_context = NULL,
};
HurrayStatus s = hurray_buffer_handoff_event(buffer, &payload);
if (s == HURRAY_ERR_SYNC_MODE_MISMATCH) { /* handle disagreement */ }
/* ConsumerStream mode: consumer declares its target stream */
HurraySyncConsumerStreamPayload sp = {
    .consumer_stream            = my_cuda_stream,
    .consumer_stream_device_tag = 0x01, /* CUDA */
};
HurrayStatus s = hurray_buffer_handoff_consumer_stream(buffer, &sp);
/* ProducerSynced mode: producer issued a host-side wait; no payload needed */
HurrayStatus s = hurray_buffer_handoff_producer_synced(buffer);

Reading a capsule from outside Python

A native-protocol capsule carries a HurrayBufferList as its pointer and a HurrayTensorContext as its context. The list holds the bytes; the context holds the descriptor that says what those bytes are (ADR-034). Without it a consumer gets element data with no element type, shape, or layout — which is what every non-Python consumer got before ADR-034.

Check the version first. It is the one accessor guaranteed to work across ABI versions, and every other accessor assumes a caller that has already checked:

uint32_t abi_version = 0;
if (hurray_tensor_context_abi_version(ctx, &abi_version) != HURRAY_OK) return -1;
if (abi_version != HURRAY_C_ABI_VERSION) {
    /* Producer and consumer disagree — refuse rather than dereference. */
    return -1;
}

Then borrow the descriptor and decode it:

const uint8_t *bytes = NULL;
uint64_t len = 0;
if (hurray_tensor_context_descriptor(ctx, &bytes, &len) != HURRAY_OK) return -1;

HurrayDescriptor *descriptor = NULL;
if (hurray_descriptor_decode(bytes, (uintptr_t)len, &descriptor) != HURRAY_OK) return -1;

uint8_t type_tag = 0;
uint32_t rank = 0;
hurray_descriptor_element_type_tag(descriptor, &type_tag);
hurray_descriptor_rank(descriptor, &rank);

The pointer bytes is borrowed — owned by the context, valid until the context is destroyed. Copy it if you need it longer. An empty descriptor reports a null pointer and a zero length.

Now the buffers mean something, because the descriptor said what they hold:

uint64_t count = 0;
hurray_buffer_list_len(list, &count);
for (uint64_t i = 0; i < count; i++) {
    HurrayBuffer *borrowed = NULL;          /* owned by the list — do not destroy */
    hurray_buffer_list_get(list, i, &borrowed);

    void *ptr = NULL;
    uint64_t size = 0;
    hurray_buffer_data_ptr(borrowed, &ptr);
    hurray_buffer_byte_size(borrowed, &size);
}

Destroying the context runs its owner_release callback, which is how the producer learns it can let the tensor go:

hurray_descriptor_destroy(descriptor);
hurray_tensor_context_destroy(&ctx);       /* runs owner_release exactly once */
hurray_buffer_list_destroy(&list);         /* destroys every handle it owns */

Runnable version, in Rust because that is what this repository builds — but the sequence is the one any language follows:

cargo run -p hurray-ffi --example tensor_context

Key takeaways

  • No panics cross the boundary. Every function returns HURRAY_OK or a negative error code. HURRAY_ERR_INTERNAL_PANIC means the library panicked internally — the handle is in an undefined state and MUST NOT be reused.
  • Opaque handles. HurrayBuffer, HurrayDescriptor, HurrayReader, and HurrayWriter are opaque; never dereference or cast their pointers.
  • HURRAY_ERR_NULL_POINTER for null required arguments. Every function checks its required pointer arguments and returns this code immediately if any is null. Optional context pointers (e.g., release_context) MAY be null.
  • Exactly one destroy per create. Each handle created by a *_from_ptr, *_decode, or *_new function MUST be destroyed exactly once.
  • Read the ABI version before anything else. For a HurrayTensorContext this is normative, not advisory: it is what lets later versions add accessors without breaking consumers compiled against an earlier header.

Block-Paged KV Cache

Purpose

The block-paged layout (tag 0x0A) is the interchange form of a PagedAttention-style KV cache — the central data structure moved between prefill and decode workers in disaggregated LLM inference. It is an indirect layout: every logical element exists, but the mapping from a logical index to a physical buffer position is resolved through a block table rather than an affine stride formula.

A block-paged descriptor is a static snapshot of one whole batch for one {kv_role, layer} pair. It carries no live allocator state; prefix sharing across sequences is expressed as static structure (two block-table entries naming the same physical page). See docs/spec/layouts/block-paged.md and ADR-024.

The three buffers

BufferNameContents
0page_poolThe flat pool of fixed-size pages (num_pages × page_size × num_heads × head_dim elements).
1block_tablePhysical page id of each logical page, concatenated across sequences.
2seq_ptrOffset array delimiting each sequence's slice of block_table (CSR-style).

The logical shape is [total_tokens, num_heads, head_dim] — a hyperrectangle. The ragged per-sequence structure lives in seq_ptr, not in the shape.

Building a descriptor

use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole, LayoutDescriptor};
use hurray_core::Shape;

// One snapshot: keys for layer 3, a batch of 2 sequences, 4 tokens per page.
let layout = LayoutDescriptor::BlockPaged(BlockPagedLayout::new(
    4,                        // page_size (tokens per page)
    5,                        // num_pages (pool capacity)
    0,                        // paged_axis (MUST be 0 in this version)
    2,                        // num_seqs
    KvRole::Key,              // this descriptor holds keys (Value / Fused also exist)
    Some(3),                  // layer_index (None = not layer-scoped, wire 0xFFFFFFFF)
    BlockTableIndexType::U32, // 32-bit block_table / seq_ptr (U64 for huge pools)
));

assert_eq!(layout.tag(), 0x0A);
assert_eq!(layout.buffer_count().map(|n| n.get()), Some(3));

// Block-paged is rank-3 only.
let shape = Shape::new(vec![9, 2, 8]).unwrap(); // [total_tokens, num_heads, head_dim]
assert!(layout.validate_against_shape(&shape).is_ok());
assert!(layout.validate_against_shape(&Shape::new(vec![9, 2]).unwrap()).is_err());
import hurray

# One snapshot: keys for layer 3, a batch of 2 sequences, 4 tokens per page.
layout = hurray.BlockPagedLayout(
    page_size=4,                        # tokens per page
    num_pages=5,                        # pool capacity
    paged_axis=0,                       # MUST be 0 in this version
    num_seqs=2,
    kv_role="key",                      # "value" and "fused" also exist
    layer_index=3,                      # None = not layer-scoped
    block_table_index_type="uint32",    # "uint64" for huge pools
)

assert layout.tag == 0x0A
assert layout.buffer_count == 3

# Block-paged is rank-3 only.
layout.validate_against_shape([9, 2, 8])   # [total_tokens, num_heads, head_dim]
try:
    layout.validate_against_shape([9, 2])
    raise AssertionError("rank-2 should be refused")
except hurray.InvalidDescriptorError:
    pass

Python names the enum-like parameters rather than importing them: kv_role="key", block_table_index_type="uint32".

Element lookup through the block table

Resolving a logical (sequence, token, head, dim) to a flat page_pool offset follows the spec formula:

page_in_seq    = token / page_size
offset_in_page = token % page_size
phys_page      = block_table[seq_ptr[seq] + page_in_seq]
flat           = ((phys_page * page_size + offset_in_page) * num_heads + head) * head_dim + dim
use hurray_core::layout::addressing::block_paged::element_offset_u32;

// seq 0 owns block_table[0..2] = [0, 1]; seq 1 owns block_table[2..3] = [0].
let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0];

// seq 0, token 4 → page 1 (4/4), offset 0 → phys_page = block_table[1] = 1.
// flat = ((1*4 + 0)*2 + 0)*8 + 0 = 64.
let flat = element_offset_u32(
    /*s=*/ 0, /*t=*/ 4, /*h=*/ 0, /*d=*/ 0,
    /*page_size=*/ 4, /*num_pages=*/ 5, /*num_heads=*/ 2, /*head_dim=*/ 8,
    block_table, seq_ptr,
)
.unwrap();
assert_eq!(flat, 64);
import hurray

# Python has no element_offset: resolving one logical coordinate at a time is
# indexing, and Hurray is an interchange format rather than a compute library
# (and a Python loop over elements would be the wrong tool regardless). The
# formula above is what a consumer implements in its own kernel; what Python
# gives you is the descriptor that says how to read it.
layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)
print(layout.page_size, layout.num_pages, layout.num_seqs)

Prefix sharing (copy-on-write, zero copy)

Two sequences share a prefix when their block tables name the same physical page. The aliasing is internal to block_table: both references resolve to the same page_pool offset, so nothing is copied.

use hurray_core::layout::addressing::block_paged::element_offset_u32;

let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0]; // seq 1's page 0 aliases seq 0's page 0

let seq0 = element_offset_u32(0, 0, 0, 0, 4, 5, 2, 8, block_table, seq_ptr).unwrap();
let seq1 = element_offset_u32(1, 0, 0, 0, 4, 5, 2, 8, block_table, seq_ptr).unwrap();
assert_eq!(seq0, seq1); // shared prefix → identical physical slot
import hurray

layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)

seq_ptr = [0, 2, 3]
block_table = [0, 1, 0]     # seq 1's page 0 aliases seq 0's page 0

# Aliasing is not an error — it is the point. The validator accepts it.
layout.validate_index_buffers(seq_ptr=seq_ptr, block_table=block_table)

Validating the storage invariants

validate_index_buffers_u32 / _u64 check the four storage invariants: seq_ptr[0] == 0, seq_ptr non-decreasing, seq_ptr[num_seqs] == block_table.len(), and every block_table[k] < num_pages. The empty batch (num_seqs == 0, seq_ptr == [0]) is valid.

use hurray_core::layout::addressing::block_paged::validate_index_buffers_u32;

let seq_ptr: &[u32] = &[0, 2, 3];
let block_table: &[u32] = &[0, 1, 0];
assert!(validate_index_buffers_u32(/*num_pages=*/ 5, /*num_seqs=*/ 2, seq_ptr, block_table).is_ok());

// A page id outside [0, num_pages) is rejected.
let bad: &[u32] = &[0, 5, 0]; // page 5 == num_pages
assert!(validate_index_buffers_u32(5, 2, seq_ptr, bad).is_err());
import hurray

layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)

layout.validate_index_buffers(seq_ptr=[0, 2, 3], block_table=[0, 1, 0])

# A page id outside [0, num_pages) is rejected.
try:
    layout.validate_index_buffers(seq_ptr=[0, 2, 3], block_table=[0, 5, 0])
    raise AssertionError("page 5 == num_pages should be refused")
except hurray.InvalidDescriptorError as exc:
    print(exc)

num_pages and num_seqs come from the layout, so only the two buffers are passed. This is worth calling before shipping a descriptor: the buffers' contents are not checked when the descriptor is built — the descriptor describes them, it does not contain them — so they are the only thing standing between a consumer and an out-of-bounds read.

Quantization compatibility

KV caches are often fp8/int8. Block-paged reuses the existing quantization schemes, with one rule: per-block-affine (scheme_tag = 0x03) requires the quantization axis to be 0 and block_size to equal page_size, so scales stay per-page-slot and a shared page carries its own scales.

use hurray_core::layout::{BlockPagedLayout, BlockTableIndexType, KvRole};

let bp = BlockPagedLayout::new(4, 5, 0, 2, KvRole::Key, Some(3), BlockTableIndexType::U32);

// per-block-affine: valid only when axis == 0 and block_size == page_size.
assert!(bp.validate_quantization_compatibility(0x03, 0, 4).is_ok());
assert!(bp.validate_quantization_compatibility(0x03, 0, 8).is_err()); // block_size != page_size
assert!(bp.validate_quantization_compatibility(0x02, 0, 4).is_err()); // per-channel on the paged axis
import hurray

layout = hurray.BlockPagedLayout(page_size=4, num_pages=5, paged_axis=0, num_seqs=2)

# per-block-affine: valid only when axis == 0 and block_size == page_size.
layout.validate_quantization_compatibility(
    hurray.PerBlockAffine.symmetric(
        axis=0, block_size=4, scale_buffer_index=3, scale_type=hurray.float32
    )
)

for incompatible in (
    hurray.PerBlockAffine.symmetric(          # block_size != page_size
        axis=0, block_size=8, scale_buffer_index=3, scale_type=hurray.float32
    ),
    hurray.PerChannelAffine.symmetric(        # per-channel on the paged axis
        axis=0, scale_buffer_index=3
    ),
):
    try:
        layout.validate_quantization_compatibility(incompatible)
        raise AssertionError("should be refused")
    except hurray.InvalidDescriptorError:
        pass

The Python check takes the quantization object rather than a scheme tag: the caller already has one, and 0x03 is a byte to look up rather than a thing to pass.

Sharding

A block-paged descriptor MUST NOT carry a shard descriptor in this version; TensorDescriptor::new rejects the combination. Tensor-parallel / multi-GPU sharding of a paged KV cache is a deferred open question (see block-paged.md § Sharding).

Runnable example

cargo run --example block_paged_kv_cache
python hurray-python/examples/block_paged.py

Composite Tensors

Purpose

A composite tensor is a virtual descriptor (head) that owns no data, plus an ordered set of member tensors, combined under a declared composition rule. This unifies three previously distinct capabilities: partitioning an index space across heterogeneous regions (sharding), overlaying a base tensor with scattered corrections (SpQR-style outlier quantization), and grouping independent tensors under one logical identity.

The head presents one logical view — one shape and one type_tag — while the members supply the actual data. Every member is an ordinary TensorDescriptor with its own layout, buffers, quantization, and device placement. See Composite / Virtual Tensor for the full normative specification (ADR-027).

Quick reference

RulePurposeBase shapeOverlapCorrections
PartitionExact-cover tilingRequiredNot allowedNot applicable
OverlayBase + correctionsWhole spaceAllowedYes, ordered
GroupIndependent multi-outputAdvisoryN/AN/A

Partition: Zero-Copy Tiling

Members' shard boxes MUST exactly cover the head's index space with no gap and no overlap. Logical index lookup is zero-copy: select the member whose box contains the index, compute the local offset, apply that member's addressing.

Build a [8, 8] head split into two [8, 4] members:

use hurray_core::{
    composite::CompositeTensor,
    descriptor::TensorDescriptor,
    layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
    BufferHandle, DeviceTag, ElementType, Shape, ShardDescriptor, SyncMode,
    MIN_BUFFER_ALIGNMENT,
};

// Head: float32 logical view, partition of 2 members.
let head_shape = Shape::new(vec![8u64, 8]).unwrap();
let head_layout = LayoutDescriptor::Composite(
    CompositeLayout::new(CompositionRule::Partition, 2).unwrap(),
);
let head = TensorDescriptor::new(
    1, 0, ElementType::Float32, head_shape, 0,
    head_layout, vec![], None, None, None, None,
).unwrap();

// Helper to build a member at a given shard offset.
let member = |offset: u64| {
    let buf = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
    let shard = ShardDescriptor::new(vec![8, 8], vec![0, offset]).unwrap();
    TensorDescriptor::new(
        1, 0, ElementType::Float32, Shape::new(vec![8u64, 4]).unwrap(), 0,
        LayoutDescriptor::RowMajor, vec![buf],
        None, Some(shard), None, None,
    ).unwrap()
};

// Two [8, 4] members at columns 0..4 and 4..8 exactly tile the head.
let composite = CompositeTensor::new(head, vec![member(0), member(4)]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray

def tile(offset, width):
    return hurray.Tensor(
        bytes(4 * 8 * width),
        hurray.float32,
        [8, width],
        shard=hurray.Shard([8, 8], [0, offset]),
    )

# Two 8x3 tiles cannot cover an 8x8 head: columns 6 and 7 are left out.
try:
    hurray.Composite(
        "partition",
        shape=[8, 8],
        dtype=hurray.float32,
        members=[tile(0, 3), tile(3, 3)],
    )
    raise AssertionError("a coverage gap should be refused")
except hurray.InvalidDescriptorError as exc:
    print(exc)

Validation is core's, not the binding's: the same checks run whichever language builds the composite, and the head is a declaration checked against its members rather than derived from them.

import hurray

# A member is an ordinary tensor with a shard box saying where it sits in the head.
def tile(offset):
    return hurray.Tensor(
        bytes(128), hurray.float32, [8, 4], shard=hurray.Shard([8, 8], [0, offset])
    )

# The head is stated — shape and dtype — and the members are checked against it.
composite = hurray.Composite(
    "partition",
    shape=[8, 8],
    dtype=hurray.float32,
    members=[tile(0), tile(4)],
)
assert composite.member_count == 2
assert composite.layout.composition_rule == "partition"

hurray.Composite is not a hurray.Tensor: the head owns no buffers, so there is no .values and no __dlpack__. The data belongs to the members, each an ordinary tensor (ADR-036).

Element [3, 6] (row 3, column 6) resolves to member 1 at local index [3, 2] (column 2 within that member's [8, 4] box). The read is zero-copy once the member is selected.

Sealed Overlay: SpQR-Style Quantization

A base member spanning the whole index space plus corrections (outlier values) that may overlap one another and the base. Precedence is stream order — later corrections win. Two combine operations are supported:

  • Replace (0x01): within a correction's box, the topmost member covering an index wins; outside every correction's box, the base shows through.
  • Add (0x02): the value at an index is the base value plus the sum of all covering corrections' values at that index.

Example: float16 logical view with int4 per-block-affine quantized base and float16 COO sparse outlier correction:

use hurray_core::{
    composite::CompositeTensor,
    descriptor::{CompositeMemberDescriptor, MemberRole, TensorDescriptor},
    layout::{CombineOp, CompositeLayout, CompositionRule, CooLayout, LayoutDescriptor},
    BufferHandle, DeviceTag, ElementType, PerBlockAffine, QuantizationDescriptor,
    Shape, ShardDescriptor, SyncMode, MIN_BUFFER_ALIGNMENT, buffer_size_bytes,
};

let shape = Shape::new(vec![4096u64, 4096]).unwrap();

// Head: float16 logical view, overlay with replace combine, 2 members.
let head = TensorDescriptor::new(
    1, 0, ElementType::Float16, shape.clone(), 0,
    LayoutDescriptor::Composite(
        CompositeLayout::new(CompositionRule::Overlay(CombineOp::Replace), 2).unwrap()
    ),
    vec![], None, None, None, None,
).unwrap();

// Member 0 (base): int4 storage with per-block-affine quantization.
let pba = PerBlockAffine::new_symmetric(1, 128, 1, ElementType::Float16).unwrap();
let num_blocks = pba.num_blocks_per_axis(4096) * 4096;
let quant = QuantizationDescriptor::PerBlockAffine(pba);
let base_data = BufferHandle::new(
    buffer_size_bytes(ElementType::Int4, 4096 * 4096),
    MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let base_scales = BufferHandle::new(
    buffer_size_bytes(ElementType::Float16, num_blocks),
    MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let base_shard = ShardDescriptor::new(vec![4096, 4096], vec![0, 0]).unwrap();
let base = TensorDescriptor::new(
    1, 0, ElementType::Int4, shape.clone(), 0,
    LayoutDescriptor::RowMajor, vec![base_data, base_scales],
    Some(quant.encode_to_vec()),
    Some(base_shard),
    None, None,
).unwrap()
.with_composite_member(CompositeMemberDescriptor::new(MemberRole::Base));

// Member 1 (correction): float16 COO sparse outliers.
let nnz = 128u64;
let coo_values = BufferHandle::new(
    buffer_size_bytes(ElementType::Float16, nnz),
    MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let coo_indices = BufferHandle::new(
    nnz * 2 /* rank */ * 8 /* uint64 */,
    MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced
).unwrap();
let correction_shard = ShardDescriptor::new(vec![4096, 4096], vec![0, 0]).unwrap();
let correction = TensorDescriptor::new(
    1, 0, ElementType::Float16, shape, 0,
    LayoutDescriptor::Coo(CooLayout::new(nnz, false)),
    vec![coo_values, coo_indices],
    None, Some(correction_shard),
    None, None,
).unwrap()
.with_composite_member(CompositeMemberDescriptor::new(MemberRole::Correction));

let composite = CompositeTensor::new(head, vec![base, correction]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray

# The base spans the whole index space; the correction is a sparse overlay.
base = hurray.Tensor(
    bytes(4096 * 4096 // 2),                # int4: two elements per byte
    hurray.dtype.int4,
    [4096, 4096],
    aux_buffers=[bytes(4096 * 4096 // 64 * 4)],
    quantization=hurray.PerBlockAffine.symmetric(
        axis=0, block_size=64, scale_buffer_index=1, scale_type=hurray.float32
    ),
    shard=hurray.Shard([4096, 4096], [0, 0]),
)

correction = hurray.Tensor(
    bytes(2 * 16),                          # 16 float16 outlier values
    hurray.float16,
    [4096, 4096],
    aux_buffers=[bytes(16 * 2 * 8)],        # packed [nnz, rank] uint64 indices
    layout=hurray.CooLayout(nnz=16, is_sorted=True),
    shard=hurray.Shard([4096, 4096], [0, 0]),
)

weights = hurray.Composite(
    "overlay",
    shape=[4096, 4096],
    dtype=hurray.float16,
    members=[base, correction],
    combine_op="replace",
)

assert weights.member_roles == ("base", "correction")

Python does not ask which member is the base: the format fixes the roles by position — member 0 is the base and must span the index space, the rest are corrections — so the constructor attaches them. They are read back from the composite rather than from a member, because a tensor has no role; a tensor inside an overlay does.

Python states the combine operation as combine_op="replace" on the composite rather than inside the rule. Member roles are positional: the first member of an overlay is the base.

The merged logical view is: for each index, the correction's outlier value if present (within its COO sparse structure), otherwise the dequantized base value. The consumer computes this merge; it is not zero-copy at the composite level.

Contrast with Add combine for residual-correction overlays:

use hurray_core::{
    descriptor::TensorDescriptor,
    layout::{CombineOp, CompositeLayout, CompositionRule, LayoutDescriptor},
    ElementType, Shape,
};
let shape = Shape::new(vec![4096u64, 4096]).unwrap();
// Head with add combine instead of replace.
let head = TensorDescriptor::new(
    1, 0, ElementType::Float16, shape.clone(), 0,
    LayoutDescriptor::Composite(
        CompositeLayout::new(CompositionRule::Overlay(CombineOp::Add), 2).unwrap()
    ),
    vec![], None, None, None, None,
).unwrap();

// ... base and correction members as above ...
// The logical value is: base_value + correction_value (at indices
// where the correction is present; outside it, just the base).
import hurray

# The same members, combined by addition instead of replacement.
weights = hurray.Composite(
    "overlay",
    shape=[4096, 4096],
    dtype=hurray.float16,
    members=[base, correction],
    combine_op="add",
)

# The logical value is base + correction where the correction is present,
# and just the base outside it.
assert weights.layout.combine_op == "add"

Group: Heterogeneous Multi-Output

Members are independent tensors under one head identity, with no spatial or ordering semantics. Members MAY differ arbitrarily in rank, shape, element type, layout, and device. Useful for weight collections, multi-head attention outputs, and other use cases where multiple tensors are delivered together.

use hurray_core::{
    composite::CompositeTensor,
    descriptor::TensorDescriptor,
    layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
};

// Head: shape and type_tag are advisory (members may differ).
let head = TensorDescriptor::new(
    1, 0, ElementType::Float32, Shape::new(vec![1u64]).unwrap(), 0,
    LayoutDescriptor::Composite(
        CompositeLayout::new(CompositionRule::Group, 2).unwrap()
    ),
    vec![], None, None, None, None,
).unwrap();

// Member 0: int8 vector, 100 elements.
let buf0 = BufferHandle::new(100, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let member0 = TensorDescriptor::new(
    1, 0, ElementType::Int8, Shape::new(vec![100u64]).unwrap(), 0,
    LayoutDescriptor::RowMajor, vec![buf0],
    None, None, None, None,
).unwrap();

// Member 1: float64 3×3×3 tensor (completely different shape and type).
let buf1 = BufferHandle::new(216 * 8, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
let member1 = TensorDescriptor::new(
    1, 0, ElementType::Float64, Shape::new(vec![3u64, 3, 3]).unwrap(), 0,
    LayoutDescriptor::RowMajor, vec![buf1],
    None, None, None, None,
).unwrap();

// Both members grouped under one head.
let composite = CompositeTensor::new(head, vec![member0, member1]).unwrap();
assert_eq!(composite.member_count(), 2);
import hurray

# A group makes no claim about coverage, so its members need not agree on
# anything — different shapes, different element types.
tokens = hurray.Tensor(bytes(100), hurray.int8, [100])
logits = hurray.Tensor(bytes(216 * 8), hurray.float64, [3, 3, 3])

composite = hurray.Composite(
    "group", shape=[1], dtype=hurray.int8, members=[tokens, logits]
)
assert composite.member_count == 2
assert composite.layout.combine_op is None      # only overlays have one

Validation

[CompositeTensor::new] constructs a head + members set, driving a [CompositeValidator] internally to perform per-member and close-time checks per the spec (§ Validation). Any violation — shard coverage gap, overlap, type mismatch, missing/misplaced base in overlay, member count mismatch — returns an error.

Example: a partition with a coverage gap is rejected:

use hurray_core::{
    composite::CompositeTensor,
    descriptor::TensorDescriptor,
    layout::{CompositeLayout, CompositionRule, LayoutDescriptor},
    BufferHandle, DeviceTag, ElementType, Error, Shape, ShardDescriptor, SyncMode,
    MIN_BUFFER_ALIGNMENT,
};

let head_shape = Shape::new(vec![8u64, 8]).unwrap();
let head = TensorDescriptor::new(
    1, 0, ElementType::Float32, head_shape, 0,
    LayoutDescriptor::Composite(
        CompositeLayout::new(CompositionRule::Partition, 2).unwrap()
    ),
    vec![], None, None, None, None,
).unwrap();

// Both members start at column 0: they overlap.
let member_at = |offset: u64| {
    let buf = BufferHandle::new(128, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced).unwrap();
    let shard = ShardDescriptor::new(vec![8, 8], vec![0, offset]).unwrap();
    TensorDescriptor::new(
        1, 0, ElementType::Float32, Shape::new(vec![8u64, 4]).unwrap(), 0,
        LayoutDescriptor::RowMajor, vec![buf],
        None, Some(shard), None, None,
    ).unwrap()
};

let err = CompositeTensor::new(head, vec![member_at(0), member_at(0)]).unwrap_err();
assert!(matches!(err, Error::CompositePartitionOverlap { a: 0, b: 1 }));

The validator also returns [Error::CompositePartitionGap] when members leave an uncovered region, and [Error::CompositeOverlayBaseNotFirst] / [Error::CompositeOverlayBaseNotSpanning] when overlay base rules are violated.

Deferred features

This implementation covers v1.0 scope: partition, group, and sealed overlay only. Reserved for a future ADR (versioned / open overlay and related work):

  • Versioned overlay — an appendable, time-travel-capable overlay identified by an open-composite member_count sentinel (0xFFFFFFFF) and per-member version fields.
  • Cross-descriptor streaming and file binding — Layers 5/6 will define how composites flow through IPC framing and file format sections; this pass is hurray-core type/codec/validator only.
  • Heterogeneous per-member device placement — currently deferred; the spec reserves room for it.

See docs/spec/layouts/composite.md § Deferred for the full reserved wire-format list.

Runnable example

cargo run --example composite_tensors

Streaming Composite Tensors

Purpose

A composite tensor — a data-less head plus an ordered set of member tensors — is streamed as its head followed by each member's descriptor and data, in order. This "head precedes its members precede their data" rule is a forward promise: no back-references and no end-of-file index, so a composite stays streamable exactly like an ordinary tensor (ADR-027 § Binding; docs/spec/interchange.md).

hurray-io gives you a matched pair:

  • StreamWriter::write_composite — validates the whole group up front (member count, partition exact-cover, overlay ordering), then writes the head and members. A torn or invalid composite never reaches the wire.
  • StreamReader::next_item — reads the next item and, when it is a composite head, reassembles the head with its declared members (validating as it goes), returning a StreamItem::Composite. Members that are themselves composites are assembled recursively.

Writing a composite

Build the head (a descriptor with a Composite layout and no buffers) and the members (ordinary descriptors), then hand them to write_composite as CompositeNodes:

use hurray_core::{
    buffer_size_bytes,
    layout::{CompositeLayout, CompositionRule},
    ElementType, LayoutDescriptor, Shape, ShardDescriptor, TensorDescriptor,
};
use hurray_io::stream::{CompositeNode, StreamWriter};

async fn run(left: TensorDescriptor, right: TensorDescriptor,
             left_data: Vec<u8>, right_data: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
// A partition head presenting one logical [8, 8] float32 view over two [8, 4] tiles.
let head = TensorDescriptor::new(
    1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
    LayoutDescriptor::Composite(CompositeLayout::new(CompositionRule::Partition, 2)?),
    vec![], None, None, None, None,
)?;

let left_buffers: [&[u8]; 1] = [left_data.as_slice()];
let right_buffers: [&[u8]; 1] = [right_data.as_slice()];
let members = vec![
    CompositeNode::Tensor { descriptor: &left,  buffers: &left_buffers },
    CompositeNode::Tensor { descriptor: &right, buffers: &right_buffers },
];

let mut wire = Vec::<u8>::new();
let mut writer = StreamWriter::new(&mut wire);
writer.write_composite(&head, &members).await?; // validated before any byte is written
writer.finish().await?;
Ok(())
}
import hurray

def tile(offset):
    return hurray.Tensor(
        bytes(128), hurray.float32, [8, 4], shard=hurray.Shard([8, 8], [0, offset])
    )

weight = hurray.Composite(
    "partition", shape=[8, 8], dtype=hurray.float32, members=[tile(0), tile(4)]
)
plain = hurray.Tensor(bytes(16), hurray.float32, [4])

with hurray.StreamWriter(destination) as writer:
    writer.write(plain)
    writer.write(weight)     # takes a composite exactly as it takes a tensor

Reading a composite

next_item returns a StreamItem — either a plain Tensor or a Composite with its members already grouped and validated:

use hurray_io::stream::{StreamItem, StreamReader};

async fn run(wire: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut reader = StreamReader::new(wire);
while let Some(item) = reader.next_item().await? {
    match item {
        StreamItem::Tensor(t) => {
            println!("tensor: {} buffer(s)", t.buffers.len());
        }
        StreamItem::Composite(c) => {
            println!("composite: head {:?}, {} member(s)", c.head.shape.dims(), c.members.len());
            for member in &c.members {
                // Each member is itself a StreamItem — a nested composite recurses here.
                println!("  member governed by {:?}", member.descriptor().layout);
            }
        }
    }
}
Ok(())
}
import hurray

for item in hurray.StreamReader(source):
    if isinstance(item, hurray.Composite):
        print(item.layout.composition_rule, item.member_count)
    else:
        print(item.shape)

A stream carrying one composite of two members yields one item, not three. The Python reader uses next_item for exactly the reason this page gives: next_tensor would hand back the head as an empty tensor and its members as top-level ones, losing the composition without raising.

Nested composites

A member may itself be a composite. On the wire this is just more heads and members in forward order; next_item assembles the tree recursively. Reads are bounded by StreamReaderOptions::max_composite_depth (default 64) so a maliciously deep composite on an untrusted stream cannot exhaust the stack.

Choosing the API

You want…Use
Composites grouped and validated for younext_item → StreamItem
Every descriptor flat, composition-agnosticnext_tensor → StreamTensor

next_tensor is unchanged: it still yields the head (with no buffers) and then each member as individual tensors, leaving composition to the caller. next_item is the composite-aware layer on top.

Errors

  • Error::TornComposite — the stream ended before the head's declared member_count members were read.
  • Error::CompositeNestingTooDeep — nesting exceeded max_composite_depth.
  • Error::Core — composite validation failed (member-count mismatch, partition does not cover the index space, overlay ordering).

Runnable example

cargo run --example composite_stream --features tokio -p hurray-io
python hurray-python/examples/composites.py

See hurray-io/examples/composite_stream.rs and hurray-python/examples/composites.py for the full programs.

Composite Tensors in Files

Purpose

A composite tensor — a data-less head plus an ordered set of member tensors — is stored in a Hurray file as its head followed by each member's descriptor and data, written contiguously and in order. Every tensor — the head and each member — gets its own footer-index entry, so all are individually addressable by name. Membership is recovered from the head's member_count plus file-offset adjacency: the members are the tensors written immediately after the head (ADR-027 § Binding; docs/spec/file-format.md).

hurray-io provides a matched pair:

  • FileWriter::write_composite — validates the whole group up front (member count, partition exact-cover, overlay ordering) via hurray-core's CompositeValidator, then writes the head and members. Nested composites are written recursively.
  • FileReader::read_composite — reassembles a head with its members (recursively for nesting) and validates the group, returning a FileComposite.

Writing a composite to a file

Each node carries a name because every tensor gets its own index entry:

use hurray_core::{
    layout::{CompositeLayout, CompositionRule},
    ElementType, LayoutDescriptor, Shape, TensorDescriptor,
};
use hurray_io::file::{FileCompositeNode, FileWriter};

async fn run(left: TensorDescriptor, right: TensorDescriptor,
             left_data: Vec<u8>, right_data: Vec<u8>) -> Result<(), Box<dyn std::error::Error>> {
let head = TensorDescriptor::new(
    1, 0, ElementType::Float32, Shape::new(vec![8u64, 8])?, 0,
    LayoutDescriptor::Composite(CompositeLayout::new(CompositionRule::Partition, 2)?),
    vec![], None, None, None, None,
)?;

let left_buffers: [&[u8]; 1] = [left_data.as_slice()];
let right_buffers: [&[u8]; 1] = [right_data.as_slice()];
let members = vec![
    FileCompositeNode::Tensor { name: "weight.left",  descriptor: &left,  buffers: &left_buffers },
    FileCompositeNode::Tensor { name: "weight.right", descriptor: &right, buffers: &right_buffers },
];

let file = tokio::fs::File::create("model.hrry").await?;
let mut writer = FileWriter::new(file).await?;
writer.write_composite("weight", &head, &members).await?; // validated before any tensor is written
writer.finish(vec![]).await?;
Ok(())
}
import hurray

# A composite is one named entry, like any other tensor.
hurray.save("model.hrry", {"weight": weight, "bias": bias})

Every tensor in a file gets an index entry, head and member alike, so a composite's members are named "{head}.{index}" — weight.0, weight.1. Those names are an artifact of the container rather than of the composite, so Python generates them for you.

Reading a composite from a file

read_composite takes the head's name and returns the reassembled group. Members remain individually readable by name with read_tensor:

use hurray_io::file::{FileItem, FileReader};

async fn run(file: tokio::fs::File) -> Result<(), Box<dyn std::error::Error>> {
let mut reader = FileReader::open(file).await?;

// Whole composite, grouped and validated:
let composite = reader.read_composite("weight").await?;
println!("head {:?}, {} member(s)", composite.head.shape.dims(), composite.members.len());
for member in &composite.members {
    match member {
        FileItem::Tensor(t) => println!("  member {}: {} buffer(s)", t.name, t.buffers.len()),
        FileItem::Composite(c) => println!("  nested composite {}", c.name), // recurses
    }
}

// Or just one member, by name:
let left = reader.read_tensor("weight.left").await?;
Ok(())
}
import hurray

loaded = hurray.load("model.hrry")

loaded["weight"]            # a hurray.Composite
loaded["weight"].members    # its tiles, in write order

# The head's name is the only top-level entry: on the wire the members belong
# to it, so they do not also come back on their own. Asking still works:
tile = hurray.load("model.hrry", names=["weight.0"])["weight.0"]

Recovery is independent of index sort order

The file writer's sorted_index option sorts the index array by name for binary search, but the tensors' positions in the file are unchanged. read_composite recovers membership by descriptor offset, so it returns the members in file (write) order regardless of how the index is sorted.

Nested composites and the depth guard

A member may itself be a composite; read_composite reassembles the tree recursively. The recursion is bounded by FileReader::with_max_composite_depth (default 64) to guard against a maliciously deep composite.

Errors

  • Error::NotAComposite — the named tensor exists but its head is not a composite.
  • Error::TornComposite — fewer tensors follow the head than its member_count declares.
  • Error::CompositeNestingTooDeep — nesting exceeded the configured maximum.
  • Error::Core — composite validation failed (member-count mismatch, partition coverage, overlay ordering).

Runnable example

cargo run --example composite_file --features tokio -p hurray-io
python hurray-python/examples/composites.py

See hurray-io/examples/composite_file.rs and hurray-python/examples/composites.py for the full programs.

hurray-inspect CLI

Purpose

hurray-inspect is a diagnostic CLI tool that decodes a Hurray binary tensor descriptor and prints a 3-column hex table showing the byte offset, raw hex value, and field name of every field in the descriptor.

It is useful for:

  • Verifying that a hand-crafted or encoded descriptor matches the spec.
  • Debugging format mismatches between implementations.
  • Learning the wire format interactively.

Parsing is delegated entirely to hurray-core's TensorDescriptor::decode() — the tool never implements its own format logic.

Building

cargo build -p hurray-inspect
# binary lands at target/debug/hurray-inspect

For a release build:

cargo build --release -p hurray-inspect

Usage

hurray-inspect <file>      # inspect a file on disk
hurray-inspect -           # read from stdin

Inspecting a file

Produce a binary descriptor, save it to disk, then pass it to hurray-inspect:

// src/bin/write_example.rs (or any scratch binary)
use hurray_core::{
    BufferHandle, DeviceTag, ElementType, Shape, SyncMode, MIN_BUFFER_ALIGNMENT,
    descriptor::TensorDescriptor,
    layout::LayoutDescriptor,
};
use std::fs;

fn main() {
    let shape  = Shape::new(vec![3u64, 4]).unwrap();
    let buffer = BufferHandle::new(
        192, MIN_BUFFER_ALIGNMENT, DeviceTag::Cpu, SyncMode::ProducerSynced,
    ).unwrap();
    let desc = TensorDescriptor::new(
        1, 0, ElementType::Float32, shape, 0,
        LayoutDescriptor::RowMajor, vec![buffer],
        None, None, None, None,
    ).unwrap();

    fs::write("example.hrry", desc.encode().unwrap()).unwrap();
    println!("wrote example.hrry ({} bytes)", desc.encode().unwrap().len());
}
import hurray

# No scratch binary needed: a tensor's descriptor encodes on its own.
tensor = hurray.Tensor(bytes(192), hurray.float32, [3, 4])
wire = tensor.descriptor.encode()

with open("example.hrry", "wb") as sink:
    sink.write(wire)

print(f"wrote example.hrry ({len(wire)} bytes)")

Python can also do what hurray-inspect does — hurray.Descriptor.decode(wire) gives back every field the CLI prints, as objects rather than as text. The CLI's advantage is that it works on a file you cannot import a library to read, and that it renders the byte offsets alongside.

Then inspect it:

hurray-inspect example.hrry

Output:

Offset  Value (hex)                     Field
------  ------------------------------  -----
     0  48 52 52 59                     magic = "HRRY"
     4  01                              version_major = 1
     5  00                              version_minor = 0
     6  3D 00 00 00                     descriptor_length = 61
    10  00 00 00 00                     flags = 0x00000000
    14  03                              type_tag = 0x03 (float32)
    15  01                              layout_tag = 0x01 (row-major)
    16  02 00 00 00                     rank = 2
    20  03 00 00 00 00 00 00 00         shape[0] = 3
    28  04 00 00 00 00 00 00 00         shape[1] = 4
    36  00 00 00 00 00 00 00 00         byte_offset = 0
    44  01                              buffer_count = 1
    45  C0 00 00 00 00 00 00 00         buffer[0].byte_size = 192
    53  40 00 00 00                     buffer[0].alignment = 64
    57  00                              buffer[0].device_tag = 0x00 (cpu)
    58  00                              buffer[0].sync_mode = 0x00 (producer_synced)
    59  00 00                           buffer[0]._reserved

Reading from stdin

Pipe raw bytes directly — useful for scripting or combining with other tools:

# Pipe the spec's 61-byte worked example (little-endian hex literals)
printf '\x48\x52\x52\x59\x01\x00\x3D\x00\x00\x00\x00\x00\x00\x00\x03\x01' \
       '\x02\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00' \
       '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\xC0\x00\x00' \
       '\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00\x00\x00\x00' \
  | hurray-inspect -

Optional sections

When a descriptor carries optional sections (quantization, shard, statistics, or extension type), those fields appear after the buffer table in spec-mandated order.

Example with statistics attached (flags bit 3 set):

    ...
    44  01                              buffer_count = 1
    45  C0 00 00 00 00 00 00 00         buffer[0].byte_size = 192
    53  40 00 00 00                     buffer[0].alignment = 64
    57  00                              buffer[0].device_tag = 0x00 (cpu)
    58  00                              buffer[0].sync_mode = 0x00 (producer_synced)
    59  00 00                           buffer[0]._reserved
    61  08 00 00 00                     flags = 0x00000008     ← HAS_STATISTICS
    ...
    XX  04 00 00 00                     stats.computed_mask = 0x00000004
    XX  00 00 00 00                     stats._reserved
    XX  ...                             stats.nnz, sparsity_ratio, value_min/max, ...

Error output

When a descriptor is malformed, hurray-inspect prints whatever it could read before the failure, then an error row and a message on stderr:

echo -n "BAAD" | hurray-inspect -
Offset  Value (hex)                     Field
------  ------------------------------  -----
     0  42 41 41 44                     magic = "BAAD"
                                        ERROR: invalid magic bytes
error: invalid magic bytes

Exit code is 1 on any parse or I/O error, 0 on success.

Relationship to hurray-core

hurray-inspect contains no format parsing logic of its own. It calls TensorDescriptor::decode() from hurray-core and then walks the original byte slice a second time — guided by the decoded struct's field values — to annotate each byte range for display. This means:

  • Any format change in hurray-core is automatically reflected in hurray-inspect.
  • An unrecognised layout tag produces an Unknown variant; its raw bytes are shown as a single opaque hex block.
  • Future minor-version additions (bytes beyond the fields this version understands) appear as a (unknown / padding bytes) row at the end.

hurray-python: Dtype, Device, and Tensor

This entry covers three foundational Python types:

  • hurray.Dtype — element type descriptor class + hurray.dtype submodule
  • hurray.Device — device descriptor class + hurray.device submodule
  • hurray.Tensor — tensor constructor and core properties

Dtype system

Tier 1 vs Tier 2

The Hurray element type system is split into two tiers, matching the spec:

TierCriterionPython location
1Array API-compatiblehurray.<name> and hurray.dtype.<name>
2Extended / sub-bytehurray.dtype.<name> only

Tier 1 types (accessible at the top level):

NameBit widthKind
bool1boolean
int8, uint88integer
int16, uint1616integer
int32, uint3232integer
int64, uint6464integer
float16, bfloat1616float
float3232float
float6464float

Tier 2 types (only on hurray.dtype.*):

NameBit widthKind
int4, uint44integer
int2, uint22integer
float8_e4m3, float8_e5m2, float8_e8m08float
float4_e2m14float
float6_e2m3, float6_e3m26float
float128128float
complex64, complex12864 / 128complex

Singleton identity

Tier 1 dtype constants are the same Python object on the top-level module and on hurray.dtype:

import hurray

assert hurray.float32 is hurray.dtype.float32   # same object, not just ==
assert hurray.int8 is hurray.dtype.int8

This ensures that set membership and dict key lookups work correctly regardless of which path is used to import the constant.

Using Dtype as a dict key

Dtype is frozen (immutable) and implements __hash__ via the wire tag byte:

import hurray

lookup = {hurray.float32: "fp32 weights", hurray.dtype.int4: "quantized"}
assert lookup[hurray.float32] == "fp32 weights"
assert lookup[hurray.dtype.int4] == "quantized"

from_name round-trip

import hurray

d = hurray.Dtype.from_name("float32")
assert d == hurray.float32
assert d.name == "float32"
assert d.bit_width == 32
assert d.is_float
assert d.is_array_api

# Unknown name raises hurray.InvalidDescriptorError
try:
    hurray.Dtype.from_name("not_a_type")
except hurray.InvalidDescriptorError as e:
    print(f"rejected: {e}")

Submodule import

The hurray.dtype submodule is registered in sys.modules, so the following import forms all work:

import hurray.dtype
from hurray.dtype import int4, float8_e4m3

Device system

A Device is a (kind, device_id, memory_class) triple. It is frozen and hashable.

Well-known constants

hurray.device exposes a constant for each supported device kind, all at device_id=0 and memory_class="standard":

import hurray

hurray.device.cpu         # CPU host memory
hurray.device.cuda        # CUDA device 0
hurray.device.rocm        # ROCm device 0
hurray.device.metal       # Metal (Apple Silicon)
hurray.device.vulkan
hurray.device.webgpu
hurray.device.hexagon
hurray.device.level_zero
hurray.device.opencl

Constructor

import hurray

# Default: device_id=0, memory_class="standard"
cpu = hurray.Device("cpu")

# Specific GPU
gpu1 = hurray.Device("cuda", 1)

# Unified memory (hardware-coherent CPU+GPU access)
gpu_um = hurray.Device("cuda", 0, "unified")

# Pinned host memory (CPU-accessible, device-mapped)
pinned = hurray.Device("cuda", 0, "host_pinned")

Equality and hashing

import hurray

assert hurray.Device("cpu") == hurray.device.cpu      # same triple
assert hurray.Device("cuda", 0) != hurray.Device("cuda", 1)  # different id

# Usable as dict key
device_names = {hurray.device.cpu: "host", hurray.device.cuda: "gpu0"}
assert device_names[hurray.Device("cpu")] == "host"

Submodule import

import hurray.device
from hurray.device import cpu, cuda

Constructing a Tensor

import struct
import hurray

# Pack six float32 values as raw bytes (little-endian)
buf = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)

# Construct a 2×3 float32 tensor on the CPU
t = hurray.Tensor(buf, hurray.float32, [2, 3])

assert t.shape == (2, 3)    # tuple[int | None, ...]
assert t.ndim == 2
assert t.size == 6          # total element count (None if any dim is dynamic)
assert t.dtype == hurray.float32
assert t.device == hurray.device.cpu

Explicit device

import hurray, struct

buf = struct.pack("4f", 1.0, 2.0, 3.0, 4.0)
t_gpu = hurray.Tensor(buf, hurray.float32, [4], hurray.Device("cuda", 0))
assert t_gpu.device.kind == "cuda"

Sub-byte types

Buffer sizes for sub-byte types are computed with ceiling division over the bit width. For int4, two elements pack into one byte:

import hurray

buf = bytes([0xAB, 0xCD])           # 2 bytes = 4 nibbles = 4 int4 elements
t = hurray.Tensor(buf, hurray.dtype.int4, [4])
assert t.size == 4
assert t.dtype.is_sub_byte

Dynamic dimensions

If the tensor shape contains a DYNAMIC dimension (wire value u64::MAX), that dimension maps to None in the Python shape tuple and size returns None:

# shape tuple with a dynamic dim:  (1, None, 768)
# t.size == None

Running the examples

Build the wheel with maturin, then run:

cd hurray-python
maturin develop           # build + install in-place (requires a venv)

python examples/dtype.py
python examples/device.py
python examples/tensor.py

All three examples print a confirmation line and exit with code 0 on success.

Spec references

  • docs/spec/element-types.md — Tier 1 / Tier 2 partition and type properties
  • docs/spec/buffer-protocol.md — DeviceTag and MemoryClass wire tables
  • docs/spec/data-model.md — DYNAMIC dimension sentinel (u64::MAX)
  • docs/impl/python-bindings.md — Python binding implementation guide

Tensor Construction Functions

hurray-python provides a set of functions for building hurray.Tensor objects in Python: zeros, ones, full, empty, their *_like variants, arange, linspace, eye, asarray, and from_dlpack. These constructors produce Tier 1 (standard numeric) tensors, which you then serialize with save or hand off zero-copy to NumPy/PyTorch. Tier 2 / quantized / sparse tensors are not built here — they arrive via the decode and interop paths.

hurray.Tensor is an interchange object, not an Array API array: it exposes an inspection and interop surface (shape, dtype, device, __dlpack__, __hurray__, …), not array computation. See ADR-029.

Creation functions

All creation functions default to dtype=float64 when a dtype is not specified.

zeros and ones

import hurray

z = hurray.zeros([3, 4])
assert z.shape == (3, 4)
assert z.dtype == hurray.float64

o = hurray.ones([2, 3], dtype=hurray.float32)
assert o.shape == (2, 3)
assert o.dtype == hurray.float32

full and empty

full infers the dtype from the fill value when dtype is omitted:

f = hurray.full([4], 7.0)          # float64 inferred
fi = hurray.full([4], 7, dtype=hurray.int32)  # explicit int32

e = hurray.empty([5, 5], dtype=hurray.float64)

empty zero-initialises the buffer; values must not be relied upon.

*_like variants

Each creation function has a _like counterpart that inherits shape and dtype from a source tensor:

src = hurray.ones([3, 3], dtype=hurray.float32)

z = hurray.zeros_like(src)      # shape=(3,3), dtype=float32
o = hurray.ones_like(src)
f = hurray.full_like(src, -1.0)
e = hurray.empty_like(src)

# Override dtype or device:
z64 = hurray.zeros_like(src, dtype=hurray.float64)

arange

Generates integer or float sequences. Dtype is inferred as int64 when all arguments are Python integers, float64 otherwise:

t = hurray.arange(5)               # [0, 1, 2, 3, 4], int64
t2 = hurray.arange(0, 10, 2)      # [0, 2, 4, 6, 8], int64
t3 = hurray.arange(0.0, 1.0, 0.25) # [0.0, 0.25, 0.5, 0.75], float64

linspace

Generates num evenly spaced values in [start, stop]:

t = hurray.linspace(0.0, 1.0, 5)
# [0.0, 0.25, 0.5, 0.75, 1.0]

# Exclude stop:
t2 = hurray.linspace(0.0, 1.0, 4, endpoint=False)
# [0.0, 0.25, 0.5, 0.75]

eye

Creates a 2-D identity matrix. k offsets the diagonal:

identity = hurray.eye(3)                # 3×3 float64 identity
rect     = hurray.eye(2, 4)             # 2×4 float64 with 1s on main diagonal
upper    = hurray.eye(3, k=1, dtype=hurray.int32)  # k=1 super-diagonal
lower    = hurray.eye(4, k=-1)          # k=-1 sub-diagonal

asarray — generic conversion

asarray converts Python lists, NumPy arrays, and other array objects to hurray.Tensor. For NumPy arrays and hurray.Tensor inputs the data buffer is shared zero-copy where possible.

import numpy as np

# From a Python list
t = hurray.asarray([1.0, 2.0, 3.0])
assert t.dtype == hurray.float64

# With explicit dtype
t2 = hurray.asarray([[1, 2], [3, 4]], dtype=hurray.int32)
assert t2.shape == (2, 2)

# From NumPy (zero-copy)
np_arr = np.array([10.0, 20.0], dtype=np.float32)
t3 = hurray.asarray(np_arr)
assert t3.dtype == hurray.float32

# From another hurray tensor (zero-copy via DLPack)
src = hurray.zeros([4])
t4 = hurray.asarray(src)

bfloat16 limitation: NumPy has no native bfloat16 dtype. Passing dtype=hurray.bfloat16 to asarray raises UnsupportedError. Use hurray.from_numpy on a bfloat16 array from PyTorch or a custom converter instead.

from_dlpack — DLPack zero-copy

from_dlpack accepts any object with __dlpack__() and wraps it zero-copy:

import numpy as np

arr = np.array([1.0, 2.0, 3.0], dtype=np.float64)
t = hurray.from_dlpack(arr)
assert t.shape == (3,)
assert t.dtype == hurray.float64

DLPack is an independent zero-copy interchange protocol (not the Array API); see Python: DLPack and NumPy Interop. For NumPy arrays you can also use hurray.from_numpy, which shares the array's buffer when its alignment allows and copies when it does not.

Tier 2 types are not constructible here

The construction functions are Tier 1 only. Passing a Tier 2 dtype (e.g. hurray.dtype.int4) raises UnsupportedError — there are no meaningful fill/step semantics for sub-byte or micro-float types in these helpers:

try:
    t = hurray.zeros([4], dtype=hurray.dtype.int4)
except hurray.UnsupportedError as e:
    print(f"Tier 2 dtype rejected: {e}")

Tier 2 / quantized tensors are produced by decoding Hurray data (hurray.load) or by the interop paths, not by these constructors.

Runnable example

# From the repo root:
cd hurray-python
maturin develop          # build the extension
python examples/construction.py

DLPack and NumPy Zero-Copy Interop

This guide covers zero-copy data exchange between hurray.Tensor and NumPy / PyTorch via the DLPack v1.0 protocol.

DLPack export

hurray.Tensor implements __dlpack__() and __dlpack_device__(), so any DLPack v1.0 consumer (NumPy 2.0, PyTorch 2.1+, JAX, CuPy) can consume it without copying:

import numpy as np, struct, hurray

buf = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
t = hurray.Tensor(buf, hurray.float32, [2, 3])

# Emit a DLPack v1.0 capsule ("dltensor_versioned").
# The capsule holds a strong reference to `t`, keeping the buffer alive.
arr = np.from_dlpack(t)
assert arr.shape == (2, 3)
assert arr.dtype == np.float32

__dlpack_device__() returns a (DLDeviceType, device_id) tuple:

device_type, device_id = t.__dlpack_device__()
assert device_type == 1   # kDLCPU
assert device_id == 0

Supported dtypes

All Tier 1 Hurray types map to DLPack: int8/16/32/64, uint8/16/32/64, float16, bfloat16, float32, float64, complex64, complex128.

bool raises builtins.BufferError — Hurray packs 8 booleans per byte while DLPack uses 1 byte per element; there is no lossless zero-copy mapping.

Tier 2 / quantized types (int4, float8 variants) also raise builtins.BufferError.

Forward-compatibility kwargs

__dlpack__ accepts stream, max_version, dl_device, and copy keyword arguments for compatibility with DLPack v1.0 consumers. Only stream has defined semantics (all tensors are ProducerSynced; GPU stream handling is deferred to a future pass).

NumPy interop

Import: hurray.from_numpy

import numpy as np, hurray

arr = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32)
t = hurray.from_numpy(arr)
assert t.shape == (2, 3)
assert t.dtype == hurray.float32

from_numpy stores a raw pointer into NumPy's buffer and holds a strong Python reference to arr — provided the buffer's base address satisfies the format's 64-byte alignment floor. NumPy does not promise one, and a large array served by a fresh mmap never has one, so most arrays are copied into an aligned allocation instead. Pass copy=False to get a hurray.CopyRequiredError rather than a silent copy; see Buffer Protocol.

The NumPy array must be C-contiguous (row-major). For Fortran-order or strided arrays, call numpy.ascontiguousarray first:

f_arr = np.asfortranarray(np.zeros((3, 4), dtype=np.float32))
t = hurray.from_numpy(np.ascontiguousarray(f_arr))

Attempting to pass a non-C-contiguous array raises hurray.UnsupportedError.

Zero-copy export: Tensor.__array__

t = hurray.Tensor(bytes(24), hurray.float32, [2, 3])
arr = t.__array__()
assert arr.shape == (2, 3)
assert arr.dtype == np.float32

Pass a target dtype to cast (a copy is made when casting):

arr_f64 = t.__array__(dtype=np.float64)
assert arr_f64.dtype == np.float64

Pass copy=False if you want to assert that no copy will occur — raises hurray.CopyRequiredError if a dtype cast is needed:

import hurray

try:
    arr = t.__array__(dtype=np.float64, copy=False)
except hurray.CopyRequiredError:
    print("A copy would be required for the dtype cast")

__array__ is only supported for CPU tensors with Tier 1 element types. Non-CPU tensors and Tier 2 / quantized dtypes raise hurray.UnsupportedError.

PyTorch interop

Zero-copy export: Tensor.to_torch

import hurray

t = hurray.Tensor(bytes(16), hurray.float32, [4])
torch_t = t.to_torch()  # raises ImportError if torch is not installed

Zero-copy import: hurray.from_torch

import torch, hurray

torch_t = torch.zeros(2, 3, dtype=torch.float32)
t = hurray.from_torch(torch_t)
assert t.shape == (2, 3)
assert t.dtype == hurray.float32

torch is imported at call time — import hurray does not require PyTorch to be installed.

Error reference

ErrorWhen raised
builtins.BufferError__dlpack__ on bool, int4, float8, or quantized types
hurray.UnsupportedErrornon-CPU __array__, Tier 2 dtype __array__, non-C-contiguous from_numpy
hurray.CopyRequiredError__array__(copy=False) when a dtype cast is needed
hurray.UnsupportedErrorDLPack device/layout not representable (PEER memory, tiled layout)
ImportErrorto_torch or from_torch when PyTorch is not installed

Native Interchange Protocol

hurray.Tensor exposes a __hurray__() method and a matching hurray.from_hurray() constructor for in-process zero-copy tensor exchange between Hurray-aware Python extensions.

Unlike DLPack, the native protocol preserves the full Hurray descriptor — device tag, memory class, sync mode, element type, shape, and layout — without flattening to DLPack's DLDeviceType enum. It is available on all dtypes (including Tier 2 / quantized) and in both strict and relaxed modes.

Quick start

import struct, hurray

# Create a source tensor.
raw = struct.pack("6f", 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)
source = hurray.Tensor(raw, hurray.float32, [2, 3])

# Zero-copy transfer via the native protocol.
target = hurray.from_hurray(source)

assert target.shape == source.shape   # (2, 3)
assert target.dtype == source.dtype   # hurray.float32

No data is copied. target borrows source's buffer; source is kept alive for as long as target exists.

Discovery

Probe support with hasattr before calling:

obj = source   # whatever you were handed

if hasattr(obj, "__hurray__"):
    tensor = hurray.from_hurray(obj)
else:
    # Fall back to DLPack or another protocol.
    tensor = hurray.from_dlpack(obj)

Why not DLPack?

DLPack is the right tool for interoperating with external libraries (PyTorch, JAX, NumPy). Use it when your consumers do not link hurray-ffi. The native protocol fills three gaps that DLPack v1.0 cannot express:

SituationDLPackNative protocol
ROCm UNIFIED memoryUnsupportedErrorSupported
PEER memory (any device)UnsupportedErrorSupported
Private device tags (0xF0–0xFE)UnsupportedErrorSupported
Tier 2 / quantized dtypesBufferErrorSupported

Tier 2 and quantized tensors

__hurray__ is available unconditionally — it is not gated on strict or relaxed mode and does not require the dtype to be an Array API Tier 1 type:

import hurray

q_tensor = hurray.Tensor(bytes(64), hurray.dtype.int4, [128])
q_copy = hurray.from_hurray(q_tensor)

assert q_copy.dtype == hurray.dtype.int4   # works in strict mode

Capsule lifecycle

__hurray__() returns a PyCapsule named "hurray_tensor". The capsule holds a HurrayBuffer pointer (from hurray-ffi) and a strong Python reference to the source Tensor.

hurray.from_hurray() renames the capsule to "used_hurray_tensor" before taking ownership — preventing double-free if the capsule is later GC'd. Attempting to consume the same capsule twice raises hurray.BufferError.

t = hurray.Tensor(bytes(8), hurray.float32, [2])
cap = t.__hurray__()          # fresh capsule

t2 = hurray.from_hurray(t)    # OK: calls __hurray__() internally
cap2 = t.__hurray__()         # OK: each call produces a new capsule

Error handling

import hurray

try:
    hurray.from_hurray(42)
    raise AssertionError("42 exposes no __hurray__")
except TypeError as exc:
    print(exc)

The other failure needs two builds to reproduce, so it cannot be shown here: a tensor produced by an extension built against a different hurray-ffi ABI version raises hurray.UnsupportedError, naming both versions. The capsule carries the producer's version precisely so the mismatch is caught at the boundary rather than read as whatever the consumer's layout happens to be.

Python: Layout Descriptors

t.layout returns a hurray.Layout object, not a string (ADR-032). The wire format models a layout as a tag plus that layout's parameters — nnz, strides, page_size, mode_order — and a string carries the tag while throwing the rest away. The object carries all of it, compares by value, and can be handed straight back to the hurray.Tensor constructor.

The string is still there, as layout.name.

Reading a layout

import hurray

t = hurray.Tensor(bytes(16), hurray.float32, [4])

print(repr(t.layout))            # RowMajorLayout()
print(t.layout.name)             # row_major
print(hex(t.layout.tag))         # 0x1
print(t.layout.buffer_count)     # 1
print(t.layout.is_dense)         # True

isinstance(t.layout, hurray.RowMajorLayout)   # True
isinstance(t.layout, hurray.Layout)           # True — every layout shares a base

isinstance is the discriminator, and it encodes a distinction the wire format genuinely makes: the layout tag.

The class hierarchy

hurray.Layout                        # base: tag, name, buffer_count, is_dense, is_virtual
├── RowMajorLayout   ColMajorLayout
├── StridedLayout    TiledLayout     MortonLayout    HilbertLayout
├── CooLayout        CsrLayout       CscLayout       CsfLayout
├── BlockPagedLayout
├── CompositeLayout
├── PrivateExtensionLayout
└── UnknownLayout

hurray.Layout itself is not constructible — there is no layout that is only "a layout". It is returned directly in exactly one case: a layout tag this build of hurray does not yet bind, where tag and name still work. That is deliberately not UnknownLayout, which means "the tag was unrecognised" — a different and load-bearing fact for a permissive reader.

Authoring: layout=

import struct
import hurray

# A 2x2 CSR matrix holding [[5.0, 0.0], [0.0, 7.0]].
csr = hurray.Tensor(
    struct.pack("2f", 5.0, 7.0),          # buffer 0 — values
    hurray.float32,
    [2, 2],
    aux_buffers=[
        struct.pack("2Q", 0, 1),           # buffer 1 — col_indices
        struct.pack("3Q", 0, 1, 2),        # buffer 2 — row_ptr
    ],
    layout=hurray.CsrLayout(nnz=2),
)

csr.layout == hurray.CsrLayout(nnz=2)     # True
csr.nnz                                   # 2

Omitting layout means row-major, as before.

The layout is a declaration; the buffers are evidence

They must agree. The constructor checks three tiers:

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

Nothing is inferred and nothing is reinterpreted:

try:
    hurray.Tensor(
        struct.pack("2f", 5.0, 7.0),          # two values...
        hurray.float32,
        [2, 2],
        aux_buffers=[struct.pack("8Q", *range(8))],
        layout=hurray.CooLayout(nnz=4),       # ...but the layout declares four
    )
    raise AssertionError("the values buffer holds two, not four")
except hurray.BufferError as exc:
    print(exc)   # buffer 0 (values) is 8 bytes, but this coo layout implies at least 16

The descriptor is not quietly corrected to nnz=2, and it is not accepted as given — it would encode and decode cleanly and hand the consumer an out-of-bounds read. Over-sized buffers are allowed: alignment and padding slack are legitimate.

This is why nnz is 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 honestly.

A layout string is not accepted

try:
    hurray.Tensor(bytes(16), hurray.float32, [4], layout="csr")
    raise AssertionError("a layout is an object, not a name")
except TypeError as exc:
    print(exc)   # layout must be a hurray.Layout instance
                 # (e.g. hurray.CsrLayout(nnz=4)), got str

A string cannot carry nnz or strides, so layout="csr" is a request that cannot be honoured. Accepting it would open a second, lossy authoring path.

Value semantics

Layout objects are immutable, compare by value, and hash:

hurray.CsrLayout(nnz=4) == hurray.CsrLayout(nnz=4)     # True
hurray.CsrLayout(nnz=4) == hurray.CsrLayout(nnz=5)     # False
len({hurray.CooLayout(nnz=1), hurray.CooLayout(nnz=1)})  # 1

t.layout is t.layout                                    # False — a fresh object
t.layout == t.layout                                    # True
t.layout == "row_major"                                 # False — always

t.layout is read-only: assigning one would silently reinterpret the buffers the tensor already holds. And a layout never equals a string — keeping that comparison alive as a special case would break the hash/equality contract and leave the lossy path open indefinitely.

The parameters a string could not carry

hurray.StridedLayout([4, 1]).strides            # (4, 1)
hurray.MortonLayout([3, 3]).morton_bits         # (3, 3)
hurray.HilbertLayout(3, 2).hilbert_order        # 3
hurray.CooLayout(nnz=7, is_sorted=True).is_sorted   # True
hurray.CsfLayout(nnz=5, mode_order=[2, 0, 1]).mode_order   # (2, 0, 1)

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

Small closed enumerations are lowercase strings, matching device.kind and layout.name:

paged = hurray.BlockPagedLayout(
    page_size=16, num_pages=64, paged_axis=0, num_seqs=2,
    kv_role="key", layer_index=3, block_table_index_type="uint32",
)
paged.kv_role                    # 'key'
paged.block_table_index_type     # 'uint32'

A composite head keeps its rule and its combine operation as two properties:

overlay = hurray.CompositeLayout("overlay", member_count=3, combine_op="add")
overlay.composition_rule                            # 'overlay'
overlay.combine_op                                  # 'add'
hurray.CompositeLayout("partition", 2).combine_op    # None — it does not apply

They are not flattened into one string, because for a partition or a group the operation is not merely unset: it has no meaning.

Reaching buffers that have no named accessor

values, indices, row_ptr, col_indices, row_indices and col_ptr cover COO, CSR and CSC. CSF has 2 * rank + 1 buffers and block-paged has three, none of them named. t.buffer(index) reaches any of them, and the layout object says what each index holds:

csf = hurray.Tensor(
    struct.pack("4f", 1.0, 2.0, 3.0, 4.0),
    hurray.float32,
    [2, 3, 4],
    aux_buffers=[
        struct.pack("2Q", 0, 2),        # pos_0
        struct.pack("2Q", 0, 1),        # crd_0
        struct.pack("3Q", 0, 2, 3),     # pos_1
        struct.pack("3Q", 0, 2, 1),     # crd_1
        struct.pack("4Q", 0, 1, 2, 4),  # pos_2
        struct.pack("4Q", 1, 3, 0, 2),  # crd_2
    ],
    layout=hurray.CsfLayout(nnz=4, mode_order=[0, 1, 2]),
)

csf.buffer_count          # 7  (2 * rank + 1)
csf.buffer(6).shape       # (32,) — the leaf crd, 4 uint64 entries
csf.buffer(6).dtype       # hurray.uint8

The view is 1-D uint8 covering exactly the buffer's declared byte size. uint8 is the only honest element type for a generic view: the buffers of one tensor do not share a dtype — values take the tensor's dtype, index buffers are uint64, MXFP scales are e8m0 — and uint8 cannot misreport any of them.

Private, unknown, and composite

PrivateExtensionLayout and UnknownLayout are separate classes. "A private layout I can identify by its extension id" and "a tag from a newer spec version I could not parse" are different facts, and a permissive relay needs both.

private = hurray.PrivateExtensionLayout(0xF0, extension_layout_id=7, extension_data=b"\x01")
unknown = hurray.UnknownLayout(0x0C, b"\x01\x02")

private.name == unknown.name == "extension"   # True — isinstance separates them
private.extension_layout_id                   # 7
unknown.raw_bytes                             # b'\x01\x02'

UnknownLayout is constructible so a relay can rebuild what it decoded and write it back out. Its constructor rejects any tag that has a named class:

try:
    hurray.UnknownLayout(0x07)
    raise AssertionError("0x07 is a tag this implementation knows")
except ValueError as exc:
    print(exc)   # tag 0x07 is the csr layout, not an unknown one;
                 # use hurray.CsrLayout instead

Calling a known tag "unknown" would smuggle a descriptor past every rank and buffer check the named class applies.

Known gap: because a private or unknown layout's buffer count is not knowable, the buffer-count and buffer-size tiers cannot run for it. Nothing in such a descriptor says how many buffers it needs or how large they should be.

A CompositeLayout is readable in full, so a composite head decoded from a stream reports its own layout truthfully. Building a hurray.Tensor with one raises, because a composite head owns no buffers:

try:
    hurray.Tensor(bytes(16), hurray.float32, [4],
                  layout=hurray.CompositeLayout("group", 2))
    raise AssertionError("use hurray.Composite for a composite head")
except hurray.UnsupportedError as exc:
    print(exc)   # a composite layout cannot be given to hurray.Tensor:
                 # a composite head owns no buffers, which this class cannot represent

Round-tripping a descriptor

A tensor's own layout goes straight back into the constructor, which is what lets a relay read a descriptor and write an equal one:

values_bytes = struct.pack("4f", 1.0, 2.0, 3.0, 4.0)
col_indices_bytes = struct.pack("4Q", 0, 2, 1, 0)
row_ptr_bytes = struct.pack("4Q", 0, 2, 3, 4)

original = hurray.Tensor(
    values_bytes,
    hurray.float32,
    [3, 3],
    aux_buffers=[col_indices_bytes, row_ptr_bytes],
    layout=hurray.CsrLayout(nnz=4),
)

rebuilt = hurray.Tensor(
    values_bytes,
    original.dtype,
    list(original.shape),
    aux_buffers=[col_indices_bytes, row_ptr_bytes],
    layout=original.layout,
    quantization=original.quantization,
    statistics=original.statistics,
    shard=original.shard,
)
rebuilt.layout == original.layout    # True

Runnable example

python hurray-python/examples/layouts.py

See also

Sparse Tensors and SciPy Interop

Hurray exposes COO, CSR, and CSC sparse tensors as ordinary hurray.Tensor objects whose .layout is a CooLayout, CsrLayout, or CscLayout (ADR-031, ADR-032). There is no separate sparse class — sparse is a layout, not a different kind of object. For CSR and CSC, buffers are shared zero-copy with SciPy sparse matrices via hurray.from_scipy and Tensor.to_scipy().

Constructing a CSR tensor from SciPy

SciPy's csr_matrix stores three NumPy arrays: .data (values), .indices (column indices), and .indptr (row pointers). hurray.from_scipy wraps all three — the resulting Tensor holds a strong reference to the original SciPy matrix so the buffers remain valid.

Each component is shared or copied on its own merits: three arrays are three allocations with three addresses, and only an address that meets the format's 64-byte alignment floor can be shared. Pass copy=False to be told which one fell short instead of paying for a silent copy — see Buffer Protocol.

Index dtype requirement: Hurray's wire format requires uint64 index arrays. SciPy defaults to int32. Cast before calling from_scipy:

import numpy as np
import scipy.sparse as sp
import hurray

dense = np.array(
    [[1.0, 0.0, 2.0],
     [0.0, 3.0, 0.0],
     [4.0, 0.0, 5.0]],
    dtype=np.float32,
)
m = sp.csr_matrix(dense)

# Cast index arrays to uint64 (required by Hurray's spec).
m.indices = m.indices.astype(np.uint64)
m.indptr  = m.indptr.astype(np.uint64)

sparse = hurray.from_scipy(m)
print(sparse)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=5, dtype=float32)

Accessing component views

Each component buffer is accessible as a zero-copy hurray.Tensor view. The view borrows the parent tensor's buffer — the parent is kept alive for as long as any view is alive.

FormatAttributeShapedtype
CSR.values(nnz,)values dtype
CSR.col_indices(nnz,)uint64
CSR.row_ptr(nrows+1,)uint64
CSC.values(nnz,)values dtype
CSC.row_indices(nnz,)uint64
CSC.col_ptr(ncols+1,)uint64
COO.values(nnz,)values dtype
COO.indices(nnz, rank)uint64

Accessing a format-specific attribute on the wrong format raises AttributeError:

try:
    sparse.indices          # COO's attribute, on a CSR tensor
    raise AssertionError("a csr tensor has col_indices, not indices")
except AttributeError as exc:
    print(exc)              # 'Tensor' object has no attribute 'indices';
                            # this is a csr tensor

To read values into a NumPy array (zero-copy for Tier 1 types):

vals_np = np.array(sparse.values)   # zero-copy via DLPack
col_idx_np = np.array(sparse.col_indices)
row_ptr_np = np.array(sparse.row_ptr)

SciPy zero-copy export

Tensor.to_scipy() returns the matching scipy.sparse matrix type. copy=False is passed to the SciPy constructor; SciPy may copy internally if it cannot accept uint64 index arrays (version-dependent).

m2 = sparse.to_scipy()
assert isinstance(m2, sp.csr_matrix)
assert (m2.toarray() == dense).all()

CSC tensors return csc_matrix. COO tensors raise hurray.UnsupportedError (see below).

COO format caveats

hurray.from_scipy does not support COO format zero-copy. SciPy stores COO row/col coordinates as two separate arrays, while Hurray's spec requires a single packed [nnz, rank] uint64 buffer. Passing a coo_matrix raises hurray.UnsupportedError with instructions.

Workaround — convert to CSR first (zero-copy from Hurray's perspective):

m_coo = sp.coo_matrix(np.eye(5, dtype=np.float32))
m_csr = m_coo.tocsr()   # SciPy makes one copy here
m_csr.indices = m_csr.indices.astype(np.uint64)
m_csr.indptr  = m_csr.indptr.astype(np.uint64)

sparse = hurray.from_scipy(m_csr)

Preferred — hurray.sparse_coo from packed arrays:

hurray.sparse_coo(values, indices, shape) builds a COO Tensor directly, sharing each array whose alignment allows it and copying the rest (copy= works here too). indices is a 2-D uint64 array of shape [nnz, rank] (Hurray's packed layout); values is 1-D of length nnz.

import numpy as np, hurray

# Straight from packed arrays:
values = np.array([5.0, 7.0], dtype=np.float32)
indices = np.array([[0, 0], [1, 1]], dtype=np.uint64)   # [nnz, rank]
t = hurray.sparse_coo(values, indices, [2, 2])
assert t.layout == hurray.CooLayout(nnz=2) and t.nnz == 2

# Repacking a SciPy coo_matrix (one copy to interleave row/col, then zero-copy):
indices = np.stack([m_coo.row, m_coo.col], axis=1).astype(np.uint64)
t = hurray.sparse_coo(m_coo.data, indices, m_coo.shape)

Only the np.stack interleave copies; sparse_coo itself borrows both arrays and holds strong references so the buffers stay alive for the tensor's lifetime.

Tensor.to_scipy() on a COO tensor raises hurray.UnsupportedError. Access .values and .indices directly and construct scipy.sparse.coo_matrix manually if needed.

Strict mode and Tier 2 / quantized types

to_scipy() raises hurray.UnsupportedError for tensors with Tier 2 or quantized values dtypes (int4, float8 variants, etc.) because SciPy has no equivalent dtype. The index arrays are always uint64 and are unaffected.

SciPy as an optional dependency

import hurray does not require SciPy. from_scipy and to_scipy import scipy.sparse lazily at call time and raise ImportError if it is not installed:

try:
    sparse = hurray.from_scipy(m)
except ImportError:
    print("scipy not installed")

Runnable example

# From the repo root:
cd hurray-python
maturin develop          # build the extension
python examples/sparse_scipy.py

Python: Streaming

A file is written once and read back whole. A stream is different: the writer emits tensors one at a time without buffering the output, and the reader gets each tensor as soon as its bytes arrive. That is what the streaming format is for, and ADR-035 brings it to Python.

import hurray

with hurray.StreamWriter("tensors.hrry") as writer:
    for tensor in tensors:
        writer.write(tensor)

for tensor in hurray.StreamReader("tensors.hrry"):
    print(tensor.shape, tensor.dtype)

The reader is an iterator and the writer is a context manager. Both are blocking, and both release the GIL while they wait on their transport, so other Python threads keep running.

The property that matters

Nothing buffers the whole sequence. A tensor is available before the rest of the stream has been read:

reader = hurray.StreamReader(source)
first = next(reader)        # available as soon as its bytes arrived
...                         # do work while the rest is still in flight
rest = list(reader)

If you only ever want everything at once, use hurray.load — a file gives you random access by name, which a stream deliberately does not.

Transports

You havePass
a pathhurray.StreamReader("t.hrry")
a socket, pipe, or open filethe object itself — anything with fileno()
bytes in handhurray.StreamReader(data)
nowhere to put ithurray.StreamWriter() and then getvalue()
import socket

producer, consumer = socket.socketpair()

with hurray.StreamWriter(producer) as writer:
    writer.write(tensor)
producer.shutdown(socket.SHUT_WR)       # tell the peer there is no more

for received in hurray.StreamReader(consumer):
    ...

The stream duplicates the descriptor, so finishing it does not close your socket:

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

sock.send(b"something else")     # still yours

io.BytesIO has no descriptor, so pass its contents instead:

hurray.StreamReader(buffer.getvalue())

Finishing

finish() flushes. A writer that is never finished may leave the tail of the stream sitting in a buffer, which is why the writer is a context manager — the with block cannot forget. If you cannot use with, call it yourself; it is idempotent, so the two compose:

writer = hurray.StreamWriter(path)
try:
    writer.write(tensor)
finally:
    writer.finish()

Writing after finishing raises hurray.StreamError.

Multi-buffer tensors travel whole

Every buffer a descriptor references crosses the stream, in descriptor order — sparse index arrays, quantization scales, page tables:

import struct

csr = hurray.Tensor(
    struct.pack("2f", 5.0, 7.0),
    hurray.float32,
    [2, 2],
    aux_buffers=[struct.pack("2Q", 0, 1), struct.pack("3Q", 0, 1, 2)],
    layout=hurray.CsrLayout(nnz=2),
)

with hurray.StreamWriter() as writer:
    writer.write(csr)

(back,) = list(hurray.StreamReader(writer.getvalue()))
back.layout        # CsrLayout(nnz=2)
back.buffer_count  # 3

What can go wrong

FailureException
a truncated or malformed framehurray.StreamError
a descriptor that will not decodehurray.InvalidDescriptorError
the transport failedhurray.FileError
the stream contains a compositehurray.UnsupportedError

Composites are refused, not skipped. A composite head owns no buffers and hurray.Tensor cannot represent one, so the reader raises rather than hand back a stream that decoded "successfully" having lost the composition. Read those with the Rust API until composite support reaches Python.

Truncation is only half-detectable. A stream has no end marker — frames are self-delimiting and it ends at EOF, which is the same property that forbids end-of-file indexes. A cut mid-frame raises hurray.StreamError; a cut exactly on a frame boundary is indistinguishable from a producer that wrote fewer tensors. If you need to know a stream was complete, say so above this layer.

Runnable example

python hurray-python/examples/streaming.py

See also

File I/O — Saving and Loading Tensors

hurray.save() writes a collection of named tensors to an HRRYFILE container. hurray.load() reads them back. Both functions are synchronous; the GIL is released during I/O so other Python threads are not blocked.

Saving tensors

import hurray

weights = hurray.zeros((512, 512), dtype=hurray.float32)
bias    = hurray.zeros((512,),     dtype=hurray.float32)

hurray.save(
    "model.hrry",
    {"weights": weights, "bias": bias},
    kv={"arch": "linear", "version": 1},
)

kv is optional file-level metadata. Values may be bool, int, float, str, bytes, or a homogeneous list of one of those scalar types.

Loading tensors

# Load all tensors
tensors = hurray.load("model.hrry")
w = tensors["weights"]   # hurray.Tensor
print(w.shape, w.dtype)  # (512, 512) float32

# Load a subset by name
subset = hurray.load("model.hrry", names=["bias"])

hurray.load() returns a dict[str, hurray.Tensor]. Tensors arrive with an owned buffer (a copy of the bytes from disk). For zero-copy access via memory-mapped files, use the native protocol (Layer 8c).

Round-trip example

import os, tempfile, hurray

t = hurray.eye(3, dtype=hurray.float64)

with tempfile.NamedTemporaryFile(suffix=".hrry", delete=False) as f:
    path = f.name

try:
    hurray.save(path, {"identity": t})
    loaded = hurray.load(path)
    print(loaded["identity"].shape)  # (3, 3)
finally:
    os.unlink(path)

Error handling

try:
    hurray.load("missing.hrry")
except hurray.FileError as e:
    # subclass of OSError — also caught by `except OSError`
    print(f"file error: {e}")
ExceptionRaised byCause
hurray.FileErrorload(), save()File not found, corrupt container, CRC mismatch, unexpected EOF
hurray.InvalidDescriptorErrorload()Tensor descriptor failed to decode
hurray.UnsupportedErrorload(), save()Sparse (multi-buffer) tensors are not yet supported

Limitations in this release

  • Loaded tensors hold an owned copy of the buffer data. Zero-copy memory-mapped loading will be added in a future release.

Sparse and other multi-buffer tensors round-trip through save() and load(): every buffer is written in descriptor order and read back with the layout intact.

Tensor Display: __repr__ and __str__

hurray.Tensor implements __repr__ and __str__ following NumPy/PyTorch display conventions.

hurray.Tensor

__repr__

For Tier 1 CPU tensors (when NumPy is installed), repr() shows the data values formatted by numpy.array2string, plus the dtype:

import hurray

t = hurray.ones([2, 3], dtype=hurray.float32)
repr(t)
# hurray.Tensor([[1. 1. 1.]
#  [1. 1. 1.]], dtype=float32)

t2 = hurray.arange(5)
repr(t2)
# hurray.Tensor([0 1 2 3 4], dtype=int64)

Large tensors are truncated automatically (NumPy threshold, default 1000 elements):

t = hurray.zeros([1000], dtype=hurray.float64)
repr(t)
# hurray.Tensor([0. 0. 0. ... 0. 0. 0.], dtype=float64)

Fallback (Tier 2 types, non-CPU devices, or NumPy not installed):

# Tier 2 — no NumPy equivalent
t = hurray.Tensor(b'\x21', hurray.dtype.int4, [2])
repr(t)
# hurray.Tensor(shape=(2,), dtype=int4, device=cpu)

__str__

str() returns the bare NumPy-style array string without the hurray.Tensor(...) wrapper — suitable for print():

t = hurray.linspace(0.0, 1.0, 5)
print(t)
# [0.   0.25 0.5  0.75 1.  ]

t2 = hurray.full([3, 3], 7.0, dtype=hurray.float32)
print(t2)
# [[7. 7. 7.]
#  [7. 7. 7.]
#  [7. 7. 7.]]

Falls back to repr() when NumPy is unavailable or for Tier 2 types.

Sparse-layout tensors

Both repr() and str() show format, shape, nnz, and dtype:

import numpy as np
import scipy.sparse as sp
import hurray

m = sp.csr_matrix(([1.0, 2.0], ([0, 1], [1, 0])), shape=(2, 2))

# Hurray's spec requires uint64 index arrays; SciPy builds int32 ones.
m.indices = m.indices.astype(np.uint64)
m.indptr = m.indptr.astype(np.uint64)

t = hurray.from_scipy(m)

repr(t)
# hurray.Tensor(layout='csr', shape=(2, 2), nnz=2, dtype=float64)

print(t)
# hurray.Tensor(layout='csr', shape=(2, 2), nnz=2, dtype=float64)

str() is identical to repr() for sparse tensors. By default the display is metadata only (SciPy-style).

Display options: metadata vs. content

Switch sparse display to a PyTorch-style content form that also shows the per-format buffer arrays. Use hurray.set_print_options to set it globally, or hurray.print_options(...) as a context manager for a scoped change (auto-reverts on exit). The default is "metadata", so existing behavior is unchanged.

import numpy as np
import scipy.sparse as sp
import hurray

m = sp.csr_matrix(([1.0, 2.0, 3.0, 4.0], ([0, 0, 1, 2], [0, 2, 1, 0])), shape=(3, 3))
m.indices = m.indices.astype(np.uint64)
m.indptr = m.indptr.astype(np.uint64)

t = hurray.from_scipy(m)

# Default — metadata only:
repr(t)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=4, dtype=float64)

# Global switch to content:
hurray.set_print_options(sparse_display="content")
repr(t)
# hurray.Tensor(layout='csr', shape=(3, 3), nnz=4, dtype=float64,
#   values=[1. 2. 3. 4.], col_indices=[0 2 1 0], row_ptr=[0 2 3 4])
hurray.get_print_options()
# {'sparse_display': 'content'}

# Or scope it to a block (reverts automatically):
hurray.set_print_options(sparse_display="metadata")
with hurray.print_options(sparse_display="content"):
    print(repr(t))   # content form
print(repr(t))       # back to metadata

The per-format arrays shown in content mode are:

FormatArrays
COOindices, values
CSRvalues, col_indices, row_ptr
CSCvalues, row_indices, col_ptr

Note: the sparse component accessors cover only the rank-2, SciPy-interop layouts COO, CSR, and CSC. The CSF (Compressed Sparse Fiber) layout exists in hurray-core (docs/spec/layouts/csf.md); a CSF tensor loads as a hurray.Tensor with layout == "csf", but has no component accessors yet, so it has no Python display form. Exposing rank-N CSF in the Python bindings is future work.

Content mode formats the arrays via NumPy (honoring your active numpy print options); if NumPy is not installed it falls back to the metadata string. set_print_options and print_options are backed by a contextvars.ContextVar, so the setting is isolated per asyncio task / thread context (like the strict/relaxed mode config). An invalid sparse_display value raises ValueError.

Runnable example

cd hurray-python
maturin develop
python examples/display.py

hurray-python Error Handling

This entry covers the hurray Python exception hierarchy and the catch_panic utility for converting Rust panics to typed Python exceptions.

Exception class tree

ValueError
├── hurray.InvalidDescriptorError  — parse / validation errors
└── hurray.BufferError             — buffer size / alignment errors

NotImplementedError
└── hurray.UnsupportedError        — unsupported element type or layout

RuntimeError
└── hurray.InternalError           — unexpected Rust panics

Note: hurray.BufferError (subclass of ValueError) is distinct from the Python built-in builtins.BufferError. The built-in is used by __dlpack__ for element types outside the DLPack type enum (per the Array API Standard). hurray.BufferError is used for buffer size and alignment errors from the Rust core.

Catching hurray exceptions

import hurray

# Catch a specific hurray error
try:
    # ... operation that may fail ...
    pass
except hurray.InvalidDescriptorError as exc:
    print(f"bad descriptor: {exc}")

# Catch by base class (when the exact subtype doesn't matter)
try:
    pass
except ValueError as exc:          # catches InvalidDescriptorError and BufferError
    print(f"value error: {exc}")
except NotImplementedError as exc:  # catches UnsupportedError
    print(f"unsupported: {exc}")
except RuntimeError as exc:        # catches InternalError
    print(f"internal: {exc}")

catch_panic — converting Rust panics to InternalError

The errors::catch_panic helper wraps a closure in std::panic::catch_unwind and converts any panic to hurray.InternalError. Use it in Rust code that calls into potentially-panicking operations:

// Not compiled: `#[pyfunction]` only expands inside a PyO3 module, and
// `compute_something` stands in for whatever the binding calls into.
use hurray::errors::catch_panic;

#[pyfunction]
fn risky_operation(py: Python<'_>) -> PyResult<i64> {
    catch_panic(|| {
        // ... call into Rust core that might panic on bad input ...
        Ok(compute_something())
    })
}

PyO3 already catches panics in #[pyfunction] wrappers and raises RuntimeError, but catch_panic ensures the more specific hurray.InternalError subclass is raised with the panic message embedded.

Build notes

[lints.rust] for PyO3 0.22 create_exception!

PyO3 0.22's create_exception! macro emits cfg(feature = "gil-refs") into the destination crate's scope. Without explicit configuration this triggers an unexpected_cfgs warning that -D warnings promotes to an error. The fix in hurray-python/Cargo.toml:

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("gil-refs"))'] }

Spec references

  • docs/impl/python-bindings.md — § Error Handling

API Reference

Generated reference documentation for this version of Hurray. Both references are built from this version's own source tree, so they describe exactly the code the rest of this book describes.

Note (non-normative): The links below are relative to the documentation site and resolve only there — they are generated output, not files in the repository. Reading this page on GitHub, follow it on the documentation site instead.

Rust

cargo doc output for every crate in the workspace.

CrateWhat it covers
hurray_coreElement types, shape, buffer handles, quantization and layout descriptors, TensorDescriptor encoding. No I/O.
hurray_ioAsync streaming interchange and the HRRYFILE container.
hurray_ffiThe C ABI: opaque handles, function table, release callbacks.
hurrayThe Rust side of the Python bindings — the PyO3 classes behind the hurray module.
hurray_inspectThe descriptor hex-viewer CLI.

Python

The hurray module — every class, function, and constant the bindings expose, with signatures and examples.

PageWhat it covers
hurrayTensor, Composite, Descriptor, layout and quantization classes, creation and interop functions, file and stream I/O, exceptions.
hurray.dtypeThe element-type constants and Dtype.
hurray.deviceThe device constants and Device.

For task-shaped introductions rather than an exhaustive surface, start from the cookbook's Python entries — Dtype, Device, and Tensor is the first one.

Tutorials

Guided, end-to-end walkthroughs of Hurray. Where the Cookbook gives focused recipes for one feature at a time, tutorials string those recipes together into a start-to-finish path.

Guided path: build up a Hurray implementation, one layer at a time

The layered cookbook is written to be read in order. Follow it top to bottom for a complete tour of the format, from element types to the C FFI:

  1. Layer 0 — Element Types and Shape
  2. Layer 1 — Buffer Protocol
  3. Layer 2 — Quantization Descriptors
  4. Layer 3 — Layout Descriptors
  5. Layer 4 — Tensor Descriptor Encoding
  6. Layer 5 — Streaming Interchange
  7. Layer 6 — File Format
  8. Layer 7 — C FFI

Task-focused tutorials

More tutorials will be added here over time. To propose or contribute one, open an issue or pull request on GitHub.

Integrating a Python Library with Hurray

You maintain a Python library with its own array type — a sparse linear algebra package, an inference runtime, a columnar store. Someone asks it to read and write Hurray tensors. There are four ways to do that, and they differ enormously in cost.

This tutorial is about choosing, not about mechanism. Each individual mechanism is already documented in the Cookbook; what is harder to find is which one your library should adopt, and what you sign up for by doing so.

Throughout, the running example is sparselib — a fictional stand-in for a SciPy-shaped library. It has its own storage types, cares about sparse layouts, and would rather not take a hard dependency on a format it merely supports. That makes it a good lens: it exercises the trade-offs instead of the happy path.

Choose a path

Answer four questions:

QuestionIf yesIf no
Do you need zero-copy?paths 2, 3path 1 is fine
Are producer and consumer in the same process?paths 2, 3paths 1, 4
Do you handle Tier 2 or quantized dtypes, or non-standard devices?path 3path 2 suffices
Can you take a dependency on hurray?paths 1, 2, 3path 4

Which gives:

PathYou writeYou depend onZero-copy
1Import hurrayPythonhurrayon the buffers, yes
2Speak DLPacknothing Hurray-specificnothingyes
3Implement __hurray__C/C++/Rust extensionhurray-ffiyes, full fidelity
4Parse the bytesa reader/writernothingyour choice

Note (non-normative): Most integrations should start at path 2, discover it covers their case, and stop. Path 3 exists for what DLPack cannot express; path 4 for when a dependency is unacceptable.


Path 1 — Import hurray

The direct route. Your library converts between its own type and hurray.Tensor, and uses hurray for file I/O.

For sparselib, whose arrays are already SciPy-compatible, conversion is nearly free — hurray.from_scipy wraps CSR/CSC/COO component arrays without copying, and Tensor.to_scipy() converts back:

import hurray

sparse = hurray.from_scipy(matrix)     # zero-copy over the component arrays
assert sparse.layout.name == "csr"
assert sparse.nnz == matrix.nnz

matrix_again = sparse.to_scipy()       # back to scipy.sparse

Dense arrays go through hurray.from_numpy and numpy.asarray. Either kind persists the same way — save() writes every buffer a tensor has, so a sparse tensor's index arrays travel with its values:

tensor = hurray.from_numpy(dense_array)
hurray.save("out.hrry", {"a": tensor})

loaded = hurray.load("out.hrry")["a"]

What you get. The full descriptor — quantization, statistics, shard — and the file format, or hurray.StreamWriter / hurray.StreamReader for the streaming one (see Python: Streaming).

What it costs. A hard dependency on hurray, and your users install a compiled extension. For a library whose Hurray support is one feature among many, that is the main objection — and path 2 exists precisely to avoid it.


Path 2 — Speak DLPack, and write no Hurray code at all

DLPack is an independent, header-only ABI plus a PyCapsule protocol. It is not part of Hurray, and Hurray does not own it — it is the same protocol NumPy, PyTorch, JAX, and CuPy already implement.

If sparselib's dense array type exposes __dlpack__ and __dlpack_device__, then Hurray can already consume it and sparselib ships nothing Hurray-specific:

# In hurray-aware code, elsewhere — sparselib itself needs no changes.
import hurray

tensor = hurray.from_dlpack(sparselib_array)   # zero-copy, no copy of your data
hurray.save("out.hrry", {"a": tensor})

Going the other way, a hurray.Tensor exposes __dlpack__ too, so your library consumes it with the machinery you already have:

arr = sparselib.asarray(tensor)     # via your existing from_dlpack support

What you get. Zero-copy in both directions, no dependency, no build changes. Implementing __dlpack__ is worth doing regardless of Hurray — it buys interoperability with the whole array ecosystem at once.

What it costs. DLPack's type and device enums are narrower than Hurray's descriptor. It cannot represent:

  • Tier 2 element types — int4, float8 variants, sub-byte packed types
  • quantized tensors and their scale buffers
  • UNIFIED / PEER / private memory classes and device tags
  • sparse layouts, block-paged layouts, composite tensors

hurray.Tensor.__dlpack__ raises BufferError for those rather than lying about the payload. If your library only ever handles dense Tier 1 data on CPU or CUDA, none of that matters and you are done.


Path 3 — Implement __hurray__

When DLPack is too narrow, implement Hurray's native protocol on your own type. This is for libraries with a compiled extension: you link hurray-ffi and hand back a PyCapsule.

This path is Python-plus-C, not Rust — there is no Rust counterpart to show, because a Rust producer would use hurray-core types directly rather than crossing a Python capsule boundary.

The contract, in full:

  1. Expose __hurray__(stream=None) returning a PyCapsule named "hurray_tensor".
  2. The capsule pointer is a HurrayBufferList — build it with hurray_buffer_list_new and one hurray_buffer_list_push per buffer, in descriptor buffer-table order.
  3. The capsule context carries your encoded TensorDescriptor and HURRAY_C_ABI_VERSION.
  4. Consumers rename the capsule to "used_hurray_tensor" on consumption; your destructor calls hurray_buffer_list_destroy if it was never consumed.

Discovery is duck-typed. Nothing registers anything:

if hasattr(obj, "__hurray__"):
    tensor = hurray.from_hurray(obj)
elif hasattr(obj, "__dlpack__"):
    tensor = hurray.from_dlpack(obj)        # narrower, but widely available
else:
    raise TypeError("no supported interchange protocol")

For sparselib, this is the path that makes its sparse types first-class: a COO matrix is values plus coordinates, two buffers, which DLPack cannot express as one object but a HurrayBufferList carries natively.

What you get. Full fidelity — every element type, every device tag and memory class, quantization with its scale buffers, multi-buffer layouts.

What it costs. A compiled extension linking hurray-ffi, and the lifetime discipline that comes with it. Two rules do most of the work:

  • A handle from hurray_buffer_list_get is borrowed. Do not destroy it; the list owns it.
  • hurray_buffer_list_destroy takes a pointer to your pointer and nulls it. Destroy the list exactly once; it frees every handle it holds.

See Multi-Buffer Tensors for worked code and the C FFI guide for the normative rules.


Path 4 — Parse the bytes yourself

Nothing obliges you to link anything. The format is designed to be re-implemented:

  • little-endian throughout
  • self-delimiting — every section states its own length
  • no back-references and no end-of-file index, so a reader can start work before it has seen the whole input

A pure-Python .hrry reader over struct and numpy is a reasonable weekend project, and for a library that refuses new binary dependencies it may be the only acceptable option.

What you get. Zero dependencies, full control, and the ability to read Hurray data anywhere Python runs.

What it costs. You own conformance. The compliance checklist is the contract, and the conformance vectors are how you check yourself against the reference implementation. You also inherit every future format addition.

hurray-inspect is the tool to develop against — it decodes any descriptor field by field, so you can compare your parser's interpretation against the reference one byte at a time:

hurray-inspect weights.hrry

Worked example: sparselib picks a path

sparselib handles float32 and float64 sparse matrices, has a compiled extension already, and does not want a hard hurray dependency for a feature only some users need.

Ruling out. Path 1 is rejected on the dependency. Path 4 is rejected as disproportionate — it is a lot of surface to own for one feature.

The choice. Path 2 for dense arrays, since sparselib already implements __dlpack__ and it costs nothing. Path 3 for the sparse types, because a COO matrix is inherently multi-buffer and DLPack has nowhere to put the coordinate array.

The seam. hurray becomes an optional dependency, imported lazily inside the conversion functions:

def to_hurray(matrix):
    try:
        import hurray
    except ImportError as exc:
        raise RuntimeError(
            "Hurray support requires the 'hurray' package: pip install hurray"
        ) from exc
    return hurray.from_scipy(matrix)

Users who never touch Hurray never install it; the sparse fast path stays zero-copy for those who do.

Where to go next

Tensor Data Interchange for AI/ML Systems: A Survey and the Hurray Proposal

Pascal Gillet
pascalgillet@ymail.com

Revision: September 2026 · Also available as PDF

Hurray project: https://pgillet.github.io/hurray/
Source code and specification: https://github.com/pgillet/hurray


Abstract

AI/ML systems move large amounts of tensor data between libraries, processes, machines, accelerators, and storage. Model weights are loaded from files into accelerator memory. Frameworks exchange tensors without copying them. Distributed training splits tensors across GPUs. Disaggregated inference transfers key-value (KV) caches between prefill and decode workers.

A tensor is a multidimensional array of values. Moving a tensor from one system to another requires transferring its data, but also enough information to interpret that data: its element type, shape, and memory layout, i.e. how its elements are arranged in memory. Some tensors require additional information. Quantized tensors store values using lower-precision encodings and associated parameters such as scales. Sparse tensors store selected values together with indexes instead of storing every value. Paged KV caches use blocks whose physical order can differ from their logical sequence order. Tensors can also be split, or sharded, across several devices.

Several interchange solutions cover parts of this information. DLPack describes strided tensors in memory, including their device. Apache Arrow defines dense and sparse tensor IPC (inter-process communication) representations and has added fixed- and variable-shape tensor types to its columnar data model. SafeTensors and GGUF store model tensors in files, with GGUF supporting many quantized representations. Zarr and NetCDF store large multidimensional arrays. NIXL and UCX move memory efficiently between machines and devices, while NCCL provides communication operations between GPUs.

This paper compares these solutions and the tensor information they preserve. The comparison shows a practical gap for tensors whose physical representation matters to computation. Layout, quantization, device memory, paging, and sharding increasingly appear at the boundaries between runtimes, but among the systems surveyed here, no widely used interchange format combines these properties in one tensor description reusable across memory, files, and network transfers.

We then present Hurray, an open-source tensor interchange project designed for this use case. Hurray defines a language-independent descriptor for a tensor's type, shape, layout, quantization, device and memory placement, buffers, and composition. The same descriptor is used for streaming and persistent files and can accompany data moved through existing communication systems.

The goal is simple: when two runtimes support the same tensor representation, they should be able to exchange it without first converting it to an intermediate layout. When they do not, the difference should be explicit so that the required conversion can be selected.


1. Introduction

AI/ML applications rarely run inside a single library.

A Python application may prepare input with NumPy, execute a model with PyTorch, call kernels through CUDA, exchange tensors with another runtime, and store model weights in SafeTensors or GGUF. A distributed training job can split one tensor across dozens or hundreds of accelerators. An inference service can compute a KV cache on one GPU and consume it on another machine.

At each boundary, two things have to move: the tensor data and the information needed to interpret it.

For a simple tensor this information is small. Consider a float32 matrix with shape [1024, 4096], stored consecutively by rows. A receiver needs to know the data type, the two dimensions, and where the data starts.

Modern tensor representations can be more complicated.

A matrix may be transposed without moving its data. A GPU kernel may require values to be stored in tiles. A four-bit weight tensor may pack several values into each byte and use one scale per block. A sparse matrix may consist of a value buffer and several index buffers. A KV cache may be divided into pages spread across GPU memory. A distributed tensor may be split across several GPUs.

These representations matter because converting between them costs time and memory bandwidth.

The raw parameter payload of a 70-billion-parameter model is about 140 GB at 16 bits per parameter and about 35 GB at 4 bits per parameter, before quantization metadata and other overheads. Converting a tensor of this size merely to cross a software boundary can require reading and writing tens or hundreds of gigabytes of memory.

The same issue appears at smaller scales but higher frequencies. An inference server can transfer KV-cache blocks for many requests between workers. If producer and consumer already support the same cache layout, converting those blocks to a generic dense representation before transfer is unnecessary work.

A tensor interchange format determines how much of the original representation survives such a boundary.

This paper surveys existing approaches to tensor interchange. It covers in-memory interfaces, data formats, scientific array storage, communication systems, and recent work on distributed inference. The systems considered include DLPack, Apache Arrow, SafeTensors, GGUF, ONNX, Zarr, NetCDF, NIXL, UCX, and NCCL, with MLIR, PJRT, and PyTorch DTensor considered as adjacent approaches.

The survey then considers Hurray, an open-source project that defines a broader tensor descriptor covering layout, quantization, memory placement, and tensor composition.

Hurray is currently a beta, pre-1.0 project. Its relevance therefore depends less on its current adoption than on the question examined in this paper: can one stable tensor descriptor carry enough of the execution representation --- layout, quantization, placement, buffers, and composition --- to be reused at runtime, in streams, and in files?


2. What Describes a Tensor?

2.1 Shape and data type

A tensor generalizes a scalar, vector, or matrix to any number of dimensions.

A vector of length 10 has shape [10]. A matrix with 1024 rows and 4096 columns has shape [1024,4096]. A batch of 32 RGB images with height and width 224 may have shape [32,3,224,224].

The number of dimensions is the tensor's rank. The tensor also has an element type, such as float32, float16, bfloat16, or int8.

Shape and type describe the logical array, but not necessarily how it is stored.

2.2 Dense tensors and strides

A dense tensor stores a value for every position in the array.

The simplest dense layout stores values consecutively. A C-style matrix normally stores one complete row after another. A Fortran-style matrix normally stores one complete column after another.

Many tensor libraries use strides to describe more general layouts. For a tensor with indexes (i_0,...,i_{n-1}), an ordinary strided representation can calculate the element position as:

o + sum(i_k * s_k)

where o is an initial offset and s_k is the stride of dimension k.

Strides make many tensor operations cheap. A transpose can often exchange dimensions and strides without moving the underlying values. A slice can adjust the starting offset and strides. Broadcasting can use a zero stride for a repeated dimension.

DLPack uses this model. It covers a large and important set of tensor views, but not every layout used by current accelerators and inference systems.

2.3 Memory layout

The memory layout of a tensor is the rule that maps a logical element such as [i,j,k] to the bytes that store it.

Strides are one such rule.

A tiled layout divides a tensor into smaller rectangular blocks and specifies how the blocks and their contents are ordered. Tiling can improve cache locality or match the matrix representation expected by accelerator hardware.

A packed layout rearranges values into the exact order a particular instruction or kernel reads them, and the result may no longer be described by one ordinary stride per dimension.

A paged layout divides the tensor into separately allocated blocks. A table maps logical regions of the tensor to those physical blocks. KV-cache management in modern LLM serving systems is an important example.

The layout is therefore part of the information a consumer needs if it wants to use the existing representation directly.

2.4 Sparse tensors

A sparse tensor avoids storing positions whose value is implicitly zero or another default value. Instead, it stores selected values plus indexes identifying their positions. Instead of storing a million-element matrix with only ten thousand non-zero values, a sparse representation stores those ten thousand values and the indexes that locate them.

COO, or coordinate format, records coordinates for stored values. CSR, or compressed sparse row format, compresses indexes by matrix row. CSC performs a similar operation by column. Higher-dimensional sparse arrays can use structures such as CSF, compressed sparse fiber.

A sparse tensor is consequently often made from several buffers: values, indexes, and offsets. Shape alone cannot describe it.

Apache Arrow is notable here because it defines standardized sparse tensor representations in addition to dense tensors.

2.5 Quantization

Quantization stores numerical values at lower precision to reduce memory use and often increase compute throughput.

A simple affine scheme can reconstruct an approximate value as x_hat = s(q-z), where q is the stored integer, s a scale, and z a zero point. The scale can apply to the whole tensor, one channel, or a small block of values.

Other schemes work differently. NF4 uses a small codebook. Block floating-point formats share scaling information across groups. Microscaling formats combine low-precision values with block-level scales.

A quantized tensor can therefore require more information than a data-type name: the logical type, storage type, quantization scheme, scales and optional zero points, grouping or block size, and, for sub-byte values, how bits are packed.

Two formats both called "4-bit" are not necessarily compatible.

2.6 Where the tensor is stored

Tensor data can reside in different kinds of memory.

Host memory is memory directly available to the CPU. Accelerator memory is memory associated with a GPU or another accelerator. Unified memory provides an address-space abstraction shared across processors, with the underlying system managing access or migration.

Other relevant cases include pinned host memory, operating-system shared memory, GPU memory accessible by peer devices, and memory registered for RDMA.

This information matters for zero-copy interchange. A consumer may understand a tensor's layout perfectly but still be unable to access its buffers where they currently reside.

2.7 Sharding and tensor composition

Large tensors are often divided among devices. This is called sharding. Distributed training systems treat tensor partitioning over devices as a first-class concern [17].

For example, a matrix with shape [65536,16384] might be divided by rows across eight GPUs. Each GPU stores a [8192,16384] shard.

Describing each shard independently does not describe the global tensor. The receiver also needs to know which part of the global tensor each shard represents.

A related concept is tensor composition. In this paper, that means describing one tensor or tensor artifact using several constituent tensors or regions. It can cover shards of one distributed tensor, several named tensors grouped in one model, sparse values and their index tensors, a quantized tensor and its scale tensors, and heterogeneous regions stored in different formats.


3. What an Interchange Format Needs to Do

3.1 Describe the tensor

The receiver needs enough metadata to interpret the buffers. For a basic dense tensor, this means shape, type, and layout. Other tensors can require sparse indexes, quantization parameters, page tables, or information about constituent tensors.

3.2 Avoid copies when possible

Zero-copy interchange means the receiver reads the producer's existing buffer rather than a copy of it. What crosses the interface is a pointer or a memory handle, not the bytes.

Zero-copy is not always possible. The receiver must understand the representation, be able to access the memory, satisfy alignment requirements, and observe the correct lifetime and synchronization rules. Buffer alignment is the requirement that a buffer start at an address that is a multiple of some specified size. Particular instructions and device interfaces can require it, and where they do not, it can still affect throughput. A format can state a minimum alignment rather than leaving it to convention. Synchronization matters because the producer may not have finished: if an accelerator is still writing the data, the consumer must wait for the appropriate synchronization point before reading it.

A format cannot guarantee zero-copy in every situation. It can provide enough information for the receiver to determine whether zero-copy is possible.

3.3 Stream large tensors

A streaming representation should let the receiver read the tensor description first and then process its payload incrementally. This is useful for network transfers and pipelines where a tensor can begin moving before the complete object is available at the destination.

3.4 Read individual tensors from files

A model can contain hundreds or thousands of named tensors. A reader should be able to locate one tensor without scanning or loading the entire file. Memory mapping is also useful because the operating system can map file pages into the process address space and load them on demand.

3.5 Work across languages

A tensor format is more useful when its definition does not depend on a Python or C++ object. DLPack and Apache Arrow both use language-neutral specifications and C-compatible interfaces to connect independent implementations. Such an interface is an ABI, or application binary interface: a fixed binary representation of structures and calls that separately compiled components can rely on without agreeing at source level.

3.6 Stay separate from the transport

Describing a tensor and moving its bytes are different jobs.

Shared memory, TCP, RDMA, CUDA IPC, NIXL, UCX, and other mechanisms can all carry data. A tensor descriptor does not need to replace them. It needs to tell the receiving application what the transferred buffers contain.


4. DLPack: In-Memory Tensor Exchange

DLPack [1] is one of the most widely used mechanisms for exchanging tensors between machine-learning frameworks.

Its basic DLTensor structure carries a data pointer, a device, the number of dimensions, a data type, a shape, strides, and a byte offset. Current DLPack also defines low-precision floating-point data types, including several FP8 and smaller formats. Managed tensor structures add lifetime information so the consumer and producer can coordinate ownership.

This is already a useful tensor descriptor.

A PyTorch tensor can, for example, be passed to another DLPack-compatible framework without converting it into an intermediate file or copying its data simply because the framework changes.

DLPack is especially well matched to ordinary strided tensors. It also carries device information. Its scope is intentionally small.

DLPack does not define a file format or a network framing protocol. A DLTensor describes one tensor rather than a distributed group of shards. Its core tensor structure does not provide standard descriptions for sparse indexes, paged KV caches, arbitrary accelerator tiling, or generic quantization parameters.

DLPack's main strength is precisely that a small common representation is easy for frameworks to adopt, and the Python array ecosystem has converged on it. The Python Array API Standard [25] weighed the Python buffer protocol and __cuda_array_interface__ against it and chose DLPack as its recommended protocol, on the grounds that a device-specific protocol leaves a consumer no defined order in which to try protocols, and that "DLPack has the widest support". The CUDA Array Interface [26] is itself worth noting here, because it demonstrates the same model on GPUs: a device pointer, shape, typestr, optional strides, and a stream field naming the stream on which the producer may still have work in flight, so the consumer knows what to synchronize against.


5. Apache Arrow

Apache Arrow [2] is the most mature example of a common physical data representation shared across many languages and systems.

Arrow is primarily designed for the tabular model: data as a set of records, typically rows from a relational database, each a set of named typed fields, stored in a columnar physical representation, with buffers defined by each Arrow type. Its basic objects are arrays, record batches, and tables. The Arrow specification defines their in-memory representation independently of a particular language implementation. The C Data Interface allows libraries in one process to share Arrow buffers, while Arrow IPC defines serialized messages for exchanging Arrow data between processes or storing it in streams and files.

Arrow also has substantial tensor support.

5.1 Standalone Tensor

Arrow defines a standalone Tensor structure [3] for multidimensional arrays. The representation contains tensor shape and strides and can therefore describe conventional strided multidimensional arrays.

Arrow also defines alignment rules for these tensor IPC structures. Standalone tensor bodies are aligned to 64-byte boundaries.

5.2 SparseTensor

Arrow separately defines SparseTensor [3].

This is significant because sparse tensors cannot in general be reduced to shape plus strides. Arrow defines standard sparse index structures and specifies how the associated buffers are represented.

Arrow therefore covers more than one tensor layout family.

5.3 Tensor-valued Arrow columns

Arrow also defines the canonical extension types arrow.fixed_shape_tensor and arrow.variable_shape_tensor [4].

These allow tensor-valued observations to participate in Arrow's normal columnar data model. The fixed-shape representation records tensor shape and can include dimension names and a permutation between logical and physical dimensions. The variable-shape representation supports tensor values whose dimensions vary.

The elements of these canonical tensor extensions are stored in row-major, C-contiguous order. A permutation can change the logical interpretation of dimensions, but it does not define an arbitrary tiled or paged physical layout.

5.4 Arrow Flight

Arrow Flight [5] provides high-performance network transfer for Arrow data. Flight uses Arrow IPC and gRPC with implementation optimizations designed to avoid unnecessary serialization and memory copies, and published benchmarks report multi-gigabyte-per-second transfers with high utilization of the available network bandwidth [6].

Flight is therefore strong prior art for network data interchange. The more relevant difference for computational tensors is what the transferred metadata describes.

Arrow's mature streaming ecosystem is centered on its columnar data model and RecordBatches. Its tensor structures do not provide a general description of accelerator-specific packed layouts, quantization schemes, paged KV caches, device memory, or distributed tensor composition.

5.5 What Arrow establishes

Arrow demonstrates that a public physical format can be shared by independent implementations; metadata and buffers can be separated; the same data model can support memory sharing and serialized interchange; alignment can be part of the format; extensions can add domain-specific semantics; and high-performance network transfer can be built around the same representation.

These principles are a major influence on Hurray.


6. Tensor and Array Files

6.1 SafeTensors

SafeTensors [7] is a simple format for storing named tensors, particularly model weights. Its header records tensor names, types, shapes, and byte ranges. The format supports memory-mapped and selective access.

SafeTensors deliberately keeps the tensor representation simple. It is not intended to describe a live GPU allocation, a paged cache, a sharded tensor, or a kernel-specific packed matrix.

6.2 GGUF

GGUF [8] is a model format developed in the GGML ecosystem. It stores model metadata and named tensors in one file and is especially relevant because it supports many quantized tensor types.

Types such as Q4_0 and Q4_K identify concrete GGML encodings. Their block structure, scales, and packing follow the definition of the selected GGML tensor type rather than a generic quantization descriptor stored with each tensor.

GGUF therefore provides strong prior art for preserving quantized representations in a portable artifact. Its quantization model is tied to named GGML tensor encodings rather than a general parameterized quantization descriptor intended for arbitrary runtimes.

6.3 Zarr and NetCDF

Zarr [9] divides large multidimensional arrays into independently stored chunks. NetCDF [10] provides named multidimensional scientific variables and supports partial access, with modern NetCDF storage able to use chunked layouts through HDF5.

These formats show that large arrays need not be loaded as complete files. Their layouts primarily optimize persistence and data access rather than the in-memory representation expected directly by accelerator kernels.

6.4 ONNX

ONNX [21] is a model interchange format, and its tensor representation is part of that. TensorProto carries dims, a data_type, and the values either in typed fields or in raw_data. SparseTensorProto pairs a values tensor with an indices tensor and the dense dims. Quantization parameters can be associated with tensors through annotations rather than fields of TensorProto itself: a TensorAnnotation maps a tensor name to the names of its quantization parameter tensors through quant_parameter_tensor_names, so scale and zero point are themselves tensors. The data type enum includes low-bit types, among them UINT4, INT4, FLOAT4E2M1, and the FLOAT8 variants. Tensor data need not sit inside the file: setting data_location to EXTERNAL moves the bytes to a file identified by location, with optional offset, length, and checksum.

ONNX therefore covers more of this paper's subject than a weights-only format does. Its scope is different. The tensor representation is part of a computational-model interchange format, the storage model is more constrained than an execution-layout descriptor, and it is not intended as a transport-independent physical tensor descriptor carried unchanged through runtime memory, streams, and files.


7. Moving Data: UCX, NIXL, and NCCL

Tensor formats and communication libraries solve different problems. The former describe data. The latter move it.

UCX [11] provides communication primitives over different hardware transports and generally treats transferred memory as buffers whose meaning is supplied by the application.

NIXL [12] targets point-to-point data movement in distributed inference and abstracts memory and storage types including GPU HBM, CPU DRAM, SSDs, and distributed storage. On suitable systems, it can use GPU Direct RDMA to transfer data directly between registered GPU memory regions.

NCCL [13] provides optimized collective and point-to-point communication between GPUs. It can use several paths depending on the hardware, including PCIe, NVLink, InfiniBand, and network sockets.

These systems do not need to understand that a buffer is a tiled matrix or a paged KV cache. A tensor interchange format can complement them by providing the description shared by the applications at either end.


8. Adjacent Systems: Compilers, Runtimes, and Frameworks

Three systems describe parts of a tensor without being interchange formats. They mark the boundary between describing a representation and exchanging one.

MLIR memref. The memref type [22] carries a shape, an element type, a layout, and a memory space. Its layout is either a strided form with an offset and per-dimension strides, or a semi-affine map, which the documentation notes is "sufficiently flexible to represent a wide variety of dense storage layouts, including row- and column-major and tiled". This is a highly expressive layout model covering many of the dense layouts discussed here. The relevant difference here is not simply layout expressiveness: memref is a type in a compiler intermediate representation, used inside a compilation pipeline, rather than a stable binary format that two independently built runtimes exchange directly. Expressiveness of a tensor representation is not by itself sufficient to make it an interchange format.

PJRT. PJRT [23] is a device plugin API rather than a data format, and it is relevant to the accessibility side of the problem. It exposes devices, buffers, and memory spaces as first-class objects: PJRT_Device_AddressableMemories returns the memories a device can address, and PJRT_Memory_AddressableByDevices returns the devices that can address a memory. Buffer layout is exposed as tiled or strided and may be backend-specific. PJRT describes and exposes device and memory accessibility; it does not define a portable representation of what the buffer contains.

PyTorch DTensor. DTensor [24] represents a logical tensor distributed over a DeviceMesh, with placements per mesh dimension: Shard for a tensor dimension split across devices, Replicate for a full copy on each, and Partial for values pending reduction. It is framework-specific rather than an interchange format, but it shows that placement and sharding are increasingly part of the tensor abstraction itself rather than metadata kept beside it.


9. Comparison

SystemMain useTensor model and layoutQuantizationDevice / memoryCompositionFileNetwork / stream
DLPackIn-process framework exchangeShape, type, dense stridesNo generic schemeDeviceSingle tensorNoNo
Arrow TensorTensor IPCShape, type, dense stridesNo generic schemeNot centralSingle tensorStandalone IPCStandalone IPC
Arrow SparseTensorSparse tensor IPCShape, type, standard sparse formatsNo generic schemeNot centralMulti-buffer sparse tensorStandalone IPCStandalone IPC
Arrow tensor extensionsTensor-valued columnsShape, type, C-contiguous + logical permutationNo generic schemeExternalArrow arrays/tablesArrow IPCFlight / IPC
SafeTensorsModel filesShape, type, conventional denseNo generic schemeNo live placementNamed tensorsYesNo standard runtime stream
GGUFModel filesShape, type, GGML encodingsGGML quantized typesNo live placementNamed tensorsYesNo runtime protocol
ONNXModel interchangeShape, type, dense and sparseAnnotated parameter tensorsNo live placementWithin a model graphYes, plus external dataNo
Zarr / NetCDFLarge arraysShape, type, storage-orientedApplication specificNo live placementDataset hierarchyYesRemote access possible
UCXCommunicationNone; opaque buffersOpaqueMemory buffersApplication-definedNoYes
NIXLInference data movementNone; opaque buffersOpaqueCPU/GPU/storage awareApplication-definedStorage backendsYes
NCCLGPU communicationElement type and count onlyOpaqueGPU-orientedApplication-definedNoYes

Each of these systems solves a different part of the problem. DLPack is a compact in-memory tensor ABI. Arrow is a language-independent memory representation with tensor structures and IPC. SafeTensors and GGUF are persistent model storage, GGUF including practical quantized model representations. Zarr and NetCDF are large chunked multidimensional storage. ONNX is model interchange carrying tensor, sparse, quantization, and external-data metadata. UCX, NIXL, and NCCL move data. The adjacent systems of § 8 cover compiler representation, device and memory abstraction, and distributed placement.

The less standardized case is a tensor that is simultaneously, for example, quantized, paged, sharded, resident in GPU memory, and composed from several buffers. Frameworks can represent such tensors internally, but this information is normally exchanged through framework-specific structures or application protocols.


10. Distributed LLM Inference as a Concrete Case

Transformer inference stores previously computed keys and values in a KV cache. Systems such as vLLM divide this cache into reusable blocks rather than allocating one contiguous buffer.

Kwon et al. introduced PagedAttention and vLLM [14], applying paging ideas to KV-cache management. Logical cache blocks can map to non-contiguous physical blocks.

DistServe [15] separates prefill and decode onto different GPUs. Once those phases run on different workers, the KV cache must cross the boundary between them.

Mooncake [16] develops this further by treating KV cache as distributed state spanning GPU memory, CPU DRAM, and SSD storage.

Figure 1

Figure 1. KV cache transfer between a prefill worker and a decode worker. The transport moves the blocks; what the blocks represent is agreed outside the transfer.

Different workers can also use different parallel decompositions. TensorRT-LLM's disaggregated-serving support [18] includes cache-layout transformation when context and generation workers use different parallel strategies.

This gives a concrete interchange problem:

Producer: "Here is the KV cache in representation A."

Consumer: "I can consume A directly," or "I require representation B."

Today this agreement is generally implemented inside the serving system. A common descriptor can make it explicit.


11. What Is Still Needed, and What Hurray Proposes

The survey suggests several requirements that are useful together:

  1. A common description of dense, sparse, tiled, packed, and paged layouts where these representations are shared between runtimes.
  2. Quantization metadata including storage type, scheme, grouping, scales, zero points where applicable, and packing.
  3. Memory and device information sufficient to decide how buffers can be accessed.
  4. Support for tensors made from several buffers.
  5. A way to describe shards as parts of a larger logical tensor.
  6. A tensor description reusable in streams and files.
  7. Negotiation so producer and consumer can agree on a representation before moving a large payload.
  8. A language-neutral runtime interface.

Supporting many representations creates complexity. A useful standard therefore needs a small common subset and explicit optional capabilities.

Hurray [19], [20] is an open-source project that defines this broader tensor description. The project is currently beta and pre-1.0. The rest of this section states what it standardizes and how it addresses each requirement above.

11.1 Interoperability boundary

Hurray standardizes the representation needed to decide whether a tensor can be consumed directly. It does not standardize the mechanism that makes the tensor accessible.

Those are two independent questions, and keeping them apart is what lets a descriptor be useful without duplicating a transport.

Representation compatibility asks whether the two sides agree on what the bytes mean: element type, shape, layout, quantization, buffer relationships, composition. Memory accessibility asks whether the consumer can reach those buffers where they currently are.

Neither answer implies the other. Two runtimes may agree exactly on BF16, a paged layout, and 64-token blocks, and still need NIXL or CUDA IPC before either can touch the other's buffers. Conversely, CUDA IPC may give one runtime access to another's GPU allocation, and that access is useless unless both sides agree on what the bytes mean.

Figure 2

Figure 2. Representation compatibility and memory accessibility are independent. A tensor descriptor establishes representation compatibility; allocators, memory-sharing mechanisms, and transports establish accessibility.

Hurray standardizes representation compatibility. It defines the tensor's logical type and shape, physical layout, quantization information, buffer relationships, device and memory information, composition, and the synchronization information needed to determine when the data can be consumed, which is enough for an independent runtime to decide whether it can consume the representation directly.

It does not standardize how a GPU buffer is allocated, how RDMA or CUDA IPC establishes access to it, how a communication library moves it, how a kernel is scheduled, or how a runtime internally converts an unsupported representation.

In short, Hurray answers:

"What tensor is in these buffers, and how is it represented?"

The surrounding runtime and communication stack answer:

"How do I access, move, convert, or execute on those buffers?"

This keeps Hurray complementary to DLPack, CUDA IPC, UCX, NIXL, NCCL, and similar systems.

11.2 How Hurray addresses the requirements

Hurray's central object is a language-independent tensor descriptor. It describes properties including logical element type, storage type, shape, memory layout, quantization, device and memory class, buffers, synchronization, and composition. The same description can be bound to different kinds of buffers depending on where it is used: a file offset, a host pointer, and a GPU memory handle are different ways of locating data, and they do not change the tensor's shape, quantization, or layout.

The eight requirements are addressed as follows.

  1. Layouts. Hurray currently defines twelve layout families, covering conventional dense layouts as well as strided, sparse, space-filling, paged, and composite representations. Each layout has a tag and layout-specific parameters, so the address mapping is stated per tensor rather than fixed by a single format-wide convention.
  2. Quantization. Hurray treats quantization separately from storage type. Its current specification includes normative schemes for common affine quantization cases and additional low-precision representations such as NF4 and MXFP. This lets runtimes reason independently about logical type, storage type, and quantization.
  3. Device and memory. Device, memory, and synchronization information travels with the tensor. This does not replace CUDA IPC, RDMA registration, NIXL, or another memory-transfer API; those mechanisms provide the actual buffer access.
  4. Several buffers. One descriptor can reference the buffers a tensor is made from, such as values and their indexes, or values and their scales, rather than a single base pointer.
  5. Shards and composition. Composite descriptions let one logical object refer to several regions or tensors. This provides a basis for sharded tensors, sparse representations, quantization parameters stored separately from values, paged structures, and heterogeneous regions.
  6. Streams and files. The streaming form places the tensor descriptor before its payload, so a receiver can determine what is arriving before all tensor bytes have arrived. The persistent file form contains named tensors and an index for locating them, and reuses the same tensor descriptor as the streaming form.
  7. Negotiation. Naming the layout, element type, and quantization scheme explicitly is what makes capability comparison possible; Hurray's interchange protocol carries the exchange itself, with each side advertising the layouts it supports and a request stating an ordered preference. The worked example below turns the outcome into three cases: direct use, relocation without reformatting, or explicit conversion.
  8. Language-neutral interface. Hurray provides a C ABI as its runtime boundary, following the same practical approach used by DLPack and Arrow's C interfaces.

12. Hurray Compared with Existing Solutions

Hurray overlaps with DLPack on language-independent tensor exchange, with Arrow on publicly specified buffer representations, with SafeTensors on indexed persistent storage, and with GGUF on preserving low-precision representations.

It is complementary to UCX, NIXL, and NCCL, which move the data rather than describe the complete tensor.

CapabilityDLPackArrow tensor facilitiesSafeTensorsGGUFNIXL/UCX/NCCLHurray
Shape and element typeStruct fieldsIPC message fieldsHeader fieldsTensor entry fieldsOpaque buffers; NCCL: datatype + countDescriptor fields
Dense stridesStrides fieldStrides in Tensor messageRow-major onlyFixed by tensor typeOpaque bytesStrided layout tag
Standard sparse representationNot definedSparseTensor messageNot definedNot definedOpaque bytesSparse layout tags
Specialized layoutsNot definedNot definedNot definedWithin GGML typesOpaque bytesLayout tag, extensible
Paged tensor layoutNot definedNot definedNot definedNot definedOpaque bytesPaged layout tag
Generic quantization metadataNot definedNot definedNo standardized schemeGGML tensor typeOpaque bytesQuantization descriptor
Device informationDevice fieldNot centralNot definedNot definedTransport handlesDevice, memory fields
Sharding / compositionSingle tensorHigher-level structuresNamed tensorsNamed tensorsApplication-definedComposite descriptor
In-process ABIC struct ABIC Data InterfaceNot definedLibrary-specificLibrary APIsC ABI
Stream representationNot definedArrow IPC, FlightNot definedFile-orientedByte transport onlyStreaming form
Indexed fileNot definedArrow IPC fileHeader offsetsTensor offsetsNot definedFile form with index
Representation negotiationApplicationApplicationNot definedNot definedApplicationCapability comparison / negotiation

This is not a scorecard. Simpler formats can be easier to implement and more interoperable: DLPack's simplicity has helped its adoption, Arrow's strict physical formats make zero-copy interoperability predictable, and SafeTensors' restricted model makes files easy to parse safely. Hurray makes a different trade-off: it describes more physical representations in order to avoid forcing every computational tensor through one common dense layout.

A richer descriptor has to justify its complexity, and the clearest case is where converting the representation is itself expensive. If two runtimes both understand the same tiled or paged representation, converting a large tensor to row-major solely for interchange wastes memory bandwidth. If they do not understand the same representation, the conversion is unavoidable and should be explicit.


13. Example: Exchanging a Paged KV Cache

Consider a prefill worker that has produced a BF16 KV cache stored in GPU memory, divided into 64-token blocks, sharded across four GPUs, represented by a block table, and ready for a decode worker on another machine.

The transfer itself could use NIXL.

Without a shared tensor description, the two applications need a private agreement covering model-dependent dimensions, element type, block size, block-table format, shard mapping, GPU buffers, synchronization, and the relationship between transferred blocks and the request.

Figure 3

Figure 3. Application-specific agreement and descriptor-based interchange. A descriptor covers the representation; agreement about the request and the model remains application-specific.

With a common descriptor, the consumer can make one of three decisions. The result depends on both representation compatibility and memory accessibility.

Direct use. The consumer supports the same representation. No layout conversion is necessary.

Relocation without reformatting. The consumer supports the layout but needs the buffers in different GPU memory.

Conversion. The consumer requires another block size, shard mapping, element type, or layout.

The descriptor does not eliminate conversion. It identifies when conversion is required.

Use the existing representation when possible; convert explicitly when necessary.


14. Open Questions

Hurray is not yet a mature standard.

Every additional layout increases implementation work. If the standard defines too few, it cannot preserve the representations that motivated it. If it defines too many, implementations may support disjoint subsets.

Hardware layouts and quantization schemes also change quickly. Extensions are necessary, but an extension identifier alone does not create interoperability. Public layouts and quantization schemes still need precise definitions and canonical test data.

Device handles pose another boundary. A device identifier is portable metadata; a live CUDA IPC or RDMA handle is not. Hurray therefore needs to keep tensor semantics separate from transport-specific buffer access.

Security also matters. Shapes, strides, offsets, indexes, and composition metadata feed into memory calculations. Implementations need strict validation, overflow checks, fuzzing, and adversarial test cases.

Most importantly, the format needs independent implementations.

Useful conformance tests include C++ producer to Rust consumer, PyTorch to a non-PyTorch runtime, independently decoded quantized tensors, sparse tensors, paged KV caches, GPU-to-GPU transfer through NIXL, and sharded tensors with different destination layouts.

For each standard layout and quantization scheme, the project should provide small canonical byte-level examples with known results.


15. Conclusion

AI/ML systems increasingly move tensors between frameworks, accelerators, processes, machines, and storage.

For ordinary dense tensors, this problem is already well served. DLPack provides a compact in-memory ABI with shape, type, strides, byte offset, and device information. Apache Arrow provides standardized dense and sparse tensor IPC and fixed- and variable-shape tensor types in its columnar model.

Persistent formats cover another part of the problem. SafeTensors provides simple indexed model storage, while GGUF preserves a wide range of quantized model representations. Zarr and NetCDF provide scalable access to large multidimensional datasets.

Communication systems cover the data path. UCX, NIXL, and NCCL can move memory efficiently without needing to understand the complete tensor stored in that memory. ONNX carries tensor and quantization metadata as part of model interchange, while MLIR, PJRT, and DTensor demonstrate richer layout, memory-accessibility, and distributed-placement abstractions within compilers and runtimes.

At the same time, the tensors used by current compute systems are becoming more varied. PagedAttention made block-based KV-cache storage a central part of LLM serving. DistServe separated prefill and decode across GPUs. Mooncake treats KV cache as distributed state spanning GPU memory, host memory, and storage. Distributed training systems treat tensor partitioning across devices as a first-class concern.

In these cases, moving the bytes is only part of the problem. The receiver also needs to know how those bytes represent the tensor.

Hurray proposes one description for that information: shape and type, but also layout, quantization, memory placement, buffers, synchronization, and composition. The descriptor is shared between its streaming and file formats and is designed to sit above existing memory and communication mechanisms.

The proposal can be summarized in one rule:

If producer and consumer support the same tensor representation, preserve it. If they do not, describe the difference clearly enough to perform the required conversion.

Whether Hurray becomes useful will depend on adoption and interoperability, not on how many features its specification contains. Independent implementations need to agree on layouts and quantization byte for byte. Real integrations need to show that preserving tensor representations removes meaningful copies or conversions. The common subset must remain simple enough for runtimes to implement.

Apache Arrow showed the value of agreeing on the physical representation of data rather than on one library's objects. DLPack showed that the same principle works for tensors when the representation is kept small.

Hurray explores how far that idea can be extended to tensors that are quantized, sparse, tiled, paged, sharded, and resident on accelerators.

Project website: https://pgillet.github.io/hurray/
Source code and specification: https://github.com/pgillet/hurray


References

  1. DLPack Project. DLPack: Open In-Memory Tensor Structure, specification and dlpack.h. DMLC. https://dmlc.github.io/dlpack/latest/ and https://github.com/dmlc/dlpack
  2. Apache Arrow Project. Apache Arrow Columnar Format. Apache Software Foundation. https://arrow.apache.org/docs/format/
  3. Apache Arrow Project. Other Data Structures: Tensor and SparseTensor. https://arrow.apache.org/docs/format/Other.html
  4. Apache Arrow Project. Canonical Extension Types: Fixed Shape Tensor and Variable Shape Tensor. https://arrow.apache.org/docs/format/CanonicalExtensions.html
  5. Apache Arrow Project. Arrow Flight RPC. https://arrow.apache.org/docs/format/Flight.html
  6. T. Ahmad, Z. Al Ars, and H. P. Hofstee. "Benchmarking Apache Arrow Flight: A Wire-Speed Protocol for Data Transfer, Querying and Microservices." ACM Conference on Big Data and Internet of Things (BID), 2022. https://doi.org/10.1145/3527199.3527264
  7. Hugging Face. SafeTensors. https://github.com/huggingface/safetensors
  8. GGML Project. GGUF Specification. https://github.com/ggml-org/ggml/blob/master/docs/gguf.md
  9. Zarr Developers. Zarr Specification. https://zarr-specs.readthedocs.io/
  10. Unidata. NetCDF Documentation. https://docs.unidata.ucar.edu/netcdf-c/
  11. OpenUCX Project. Unified Communication X. https://openucx.org/
  12. NVIDIA / Dynamo Project. NVIDIA Inference Xfer Library (NIXL). https://github.com/ai-dynamo/nixl
  13. NVIDIA. NCCL User Guide. https://docs.nvidia.com/deeplearning/nccl/user-guide/
  14. W. Kwon et al. "Efficient Memory Management for Large Language Model Serving with PagedAttention." SOSP, 2023. https://arxiv.org/abs/2309.06180
  15. Y. Zhong et al. "DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving." OSDI, 2024. https://www.usenix.org/conference/osdi24/presentation/zhong-yinmin
  16. R. Qin et al. "Mooncake: Trading More Storage for Less Computation ---A KVCache-centric Architecture for Serving LLM Chatbot." 23rd USENIX Conference on File and Storage Technologies (FAST), 2025. https://www.usenix.org/conference/fast25/presentation/qin
  17. L. Zheng et al. "Alpa: Automating Inter- and Intra-Operator Parallelism for Distributed Deep Learning." OSDI, 2022. https://www.usenix.org/conference/osdi22/presentation/zheng-lianmin
  18. NVIDIA. TensorRT-LLM: Disaggregated Serving, including KV cache transfer and cache layout conversion across parallel strategies. https://nvidia.github.io/TensorRT-LLM/advanced/disaggregated-service.html
  19. Hurray Project. Hurray: A Zero-Copy, Streamable, Language-Agnostic Tensor Interchange Format for AI/ML Inference Pipelines and Scientific Arrays. https://github.com/pgillet/hurray
  20. Hurray Project. Hurray Project Website. https://pgillet.github.io/hurray/
  21. ONNX Project. Open Neural Network Exchange: IR specification and onnx.proto, including TensorProto, SparseTensorProto, TensorAnnotation, and external data. https://onnx.ai/onnx/repo-docs/IR.html and https://github.com/onnx/onnx/blob/main/onnx/onnx.proto
  22. MLIR Project. Builtin Dialect: MemRefType. LLVM. https://mlir.llvm.org/docs/Dialects/Builtin/#memreftype
  23. OpenXLA Project. PJRT: uniform device API, and the PJRT C API header. https://openxla.org/xla/pjrt and https://github.com/openxla/xla/blob/main/xla/pjrt/c/pjrt_c_api.h
  24. PyTorch Project. torch.distributed.tensor: DTensor, DeviceMesh, and placement types. https://docs.pytorch.org/docs/stable/distributed.tensor.html
  25. Consortium for Python Data API Standards. Array API Standard: Data interchange mechanisms. https://data-apis.org/array-api/latest/design_topics/data_interchange.html
  26. Numba Project. CUDA Array Interface (version 3). https://numba.readthedocs.io/en/stable/cuda/cuda_array_interface.html

Hurray Documentation Website — Specification

Status: Accepted. Decision recorded in ADR-028. This document specifies the concrete structure of the website; it is infrastructure spec, not part of the normative format specification under docs/spec/.

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

1. Scope and goals

The website is the public face of Hurray, modelled on arrow.apache.org: a mostly-technical site for reference-implementation users, format implementers, and ML/inference engineers.

Goals, in priority order:

  1. Faithful, full-history versioned docs. Every released spec version is browsable, built from its own git tag.
  2. Simple, fully automated CI pipeline with no Node/npm toolchain.
  3. Easy to reorganize and extend — content is Markdown authored in place.
  4. Portable — a plain static tree deployable to GitHub Pages today and any static host later.

2. Toolchain

ConcernToolNotes
Versioned technical bookmdBookSpec + impl + cookbook + tutorials. Single Rust binary. Built-in per-book search.
Outer site shellZolaLanding, FAQ, blog, community. Single Rust binary.
Rust API referencecargo docPer version, per crate; published under the version path.
Python API referencepdoc + maturinPer version; maturin builds that version's wheel, pdoc introspects it (ADR-038).
CI / deployGitHub Actions → GitHub PagesFull static-tree deploy.

Both site generators are prebuilt binaries pinned to explicit versions in the workflow. The build MUST NOT require a Node or npm toolchain, and every tool it installs MUST be pinned to an explicit version in the workflow — the two prebuilt binaries, the Rust toolchain already used by the workspace, and the Python packages named above. It MUST NOT install anything unpinned or resolve a dependency tree at build time.

3. Deployed URL scheme

The site deploys as a single static tree. Paths (relative to the Pages site root):

/                         Landing / overview            (Zola)
/faq/                     FAQ                            (Zola)
/blog/                    Blog index + posts            (Zola)
/community/               Contributing, CoC, governance, mailing lists  (Zola)
/docs/                    → redirects to /docs/stable/
/docs/stable/             Latest stable release book     (mdBook; copy of the stable tag build)
/docs/dev/                Book built from `main`         (mdBook)
/docs/<version>/          Book built from tag <version>  (mdBook)   e.g. /docs/0.1.0/
/docs/<version>/api/      cargo doc for that version     (rustdoc)
/docs/<version>/python-api/  pdoc for that version's `hurray` module  (pdoc)
/docs/stable/api/         cargo doc for the stable release
/docs/dev/api/            cargo doc for `main`
/versions.json            Version manifest (drives the dropdown)
  • Version path segments MUST be the exact git tag name. The tag convention is MAJOR.MINOR.PATCH with no leading v (e.g. 0.1.0); release tags MUST follow it.
  • The API reference for a version MUST live under that version's api/ (Rust) and python-api/ (Python) subpaths so a single version prefix scopes the book and both API references.
  • Both API reference directory URLs MUST resolve to a landing page. cargo doc on a multi-crate workspace emits no root index.html, so the build MUST emit one; pdoc emits its own.
  • The book MUST carry a navigation entry linking both API references, using links relative to the version path so each version links to its own.
  • /docs/ MUST redirect to /docs/stable/ (an emitted index.html meta-refresh is acceptable, since GitHub Pages does not honour symlinks).

4. Versioning policy

  • A version is a spec version. Release tags follow the spec version. The set of published versions is exactly the set of matching git tags, plus the special dev (built from main).
  • stable is the highest non-prerelease semantic-version tag. Its build MUST be copied to /docs/stable/ (a copy, not a symlink) on each deploy.
  • Default entry point. The site navigation "Docs" link and /docs/ MUST resolve to /docs/stable/. The dev version MUST be reachable and MUST be clearly labelled as unreleased in the version dropdown and via an in-page banner on every dev page.
  • Bootstrap fallback. Until the first release tag exists, there is no stable build; /docs/, /docs/stable/, and the "Docs" nav link MUST fall back to /docs/dev/. The workflow MUST detect "no release tags" and emit this fallback rather than a broken link.
  • Prerelease tags (e.g. 0.2.0-rc.1) MAY be published as their own version entry but MUST NOT be selected as stable.
  • Immutability. A published version path, once deployed for a given tag, MUST reflect that tag's content; version builds MUST come from git checkout <tag>, never from main.

4.1 versions.json schema

A single manifest at the site root drives the version dropdown. It is regenerated on every deploy. Shape:

{
  "stable": "0.1.0",
  "dev": "dev",
  "versions": [
    { "id": "dev",   "label": "dev (unreleased)", "path": "/docs/dev/",   "released": null,         "prerelease": false, "dev": true },
    { "id": "0.1.0", "label": "0.1.0",            "path": "/docs/0.1.0/", "released": "2026-08-01", "prerelease": false, "dev": false }
  ]
}
  • versions MUST be ordered newest-first, with dev first.
  • stable MUST name the id of the stable version, or be null before the first release.
  • Consumers (the dropdown script) MUST treat an absent/null stable by pointing at dev.

5. Repository layout

New site sources live under website/; published content stays authored in place under docs/.

website/
├── book/
│   └── book.toml            # mdBook config; src points at the curated doc tree
├── site/                    # Zola project
│   ├── config.toml
│   ├── content/
│   │   ├── _index.md        # landing
│   │   ├── faq/
│   │   ├── blog/
│   │   └── community/
│   ├── templates/
│   ├── sass/ or static/
│   └── static/
├── theme/                   # shared tokens (colors, fonts) used to keep book + site coherent
├── build-site.sh            # builds the whole published tree (shell + one book per version)
├── check-doc-links.py       # CI check: internal .md links resolve on GitHub and on the site
├── check-rust-blocks.py     # CI check: the cookbook's Rust blocks compile and run
├── check-coverage-matrix.py # CI check: regenerates the Implementation Status page from the
│                            #   real crates, the Python module, and the C header
├── build-prior-art-pdf.py   # renders docs/prior-art.md to docs/prior-art.pdf (pandoc + typst)
└── coverage-matrix.toml     # canonical spec-feature list (rows) and implementations (columns)

docs/
├── SUMMARY.md               # mdBook table of contents — the single reorganization surface
├── spec/                    # (existing) format specification
├── impl/                    # (existing) implementation requirements
├── cookbook/                # (existing) cookbook entries
├── tutorials/               # (new) longer-form guided tutorials
├── adr/                     # (existing) architecture decision records — published as appendix
├── prior-art.md             # (existing) prior-art survey — published as appendix
├── prior-art.pdf            # generated from prior-art.md; served alongside it by the book
└── figures/                 # SVG diagrams — the one image format GitHub, mdBook, and the
                             #   PDF pipeline all render without a preprocessor
  • The book is a view over docs/. book.toml sets src to the doc tree and the book's navigation is defined solely by docs/SUMMARY.md. Reorganizing the book is editing SUMMARY.md; adding a page is adding a Markdown file and one SUMMARY.md line.
  • The ADRs (docs/adr/) and docs/prior-art.md MUST be published as a book appendix in v1, listed under an "Appendix" section in SUMMARY.md.
  • The Zola shell MUST NOT duplicate versioned technical content; it links into /docs/.

6. Visual coherence

  • The mdBook book and the Zola shell are themed independently (see ADR-028 Consequences).
  • A shared set of design tokens (brand colors, typography, logo) under website/theme/ SHOULD be applied to both so navigation between shell and book feels like one site.
  • Both themes MUST support light and dark modes and MUST be responsive.

7. CI/CD pipeline

A GitHub Actions workflow builds and deploys the entire tree. It is stateless: it reconstructs all versions from git on every run.

Triggers: push to main, push of a release tag (semver MAJOR.MINOR.PATCH[-prerelease], no leading v), and manual dispatch.

Stages (in order):

  1. Checkout with full history and tags (fetch-depth: 0).
  2. Install pinned tools: the Rust toolchain, mdbook (pinned version), zola (pinned version), and Python with maturin and pdoc (pinned versions).
  3. Build the shell: zola build from website/site/ into the output root (public/).
  4. Build dev: from the current main tree, mdbook build → public/docs/dev/, cargo doc --no-deps --workspace → public/docs/dev/api/, then maturin build + pdoc → public/docs/dev/python-api/. Stamp the "unreleased" banner.
  5. Build each release version: for every release tag (semver, no leading v), in a detached worktree at that tag, mdbook build → public/docs/<tag>/, cargo doc → public/docs/<tag>/api/, and maturin build + pdoc → public/docs/<tag>/python-api/. The Python reference MUST be built from that tag's own tree, and MUST be skipped — not faked from another version — for a tag whose tree has no hurray-python.
  6. Resolve stable: compute the highest non-prerelease tag; copy its build to public/docs/stable/. If no release tag exists, make stable fall back to dev.
  7. Emit public/versions.json and the public/docs/ redirect to stable.
  8. Deploy public/ to GitHub Pages (actions/deploy-pages).

The workflow MUST fail the build (not silently skip) if a tagged version fails to build, so history stays trustworthy. Pull-request builds SHOULD build the shell + dev only (no full history) for fast preview.

Note (non-normative): Stage 5 is O(number of release tags), and each version now costs a cargo doc plus a debug build of the Python extension. This is acceptable at current scale (zero tags today). When it becomes slow, switch to incremental builds that carry prior version outputs forward and rebuild only new/changed tags — see ADR-028.

7.1 Cookbook code blocks

Every fenced block in docs/cookbook/ is verified, in both languages, by the test and python-conformance jobs in ci.yml — not by the docs workflow, because a block rots when the API moves and that diff touches no Markdown.

LanguageRunnerTiers
Rustwebsite/check-rust-blocks.py (rustdoc --test per page)rust compiles and runs · rust,no_run compiles only · rust,ignore is skipped and MUST carry a comment saying why
Pythonhurray-python/tests/test_cookbook_python_blocks.pyRUN executes every block · COMPILE parses only and MUST carry a reason

The Python tiers are a table in the test module rather than a fence annotation, because lang-tabs.js groups blocks by language: writing ```python,ignore would change that tab's identity and break the page. A page with Python blocks that appears in neither table fails the suite, so a new page cannot skip the decision.

Within a page, Python blocks share one namespace and one scratch directory: a page is read top to bottom, so a later block may use an earlier one's names and the files it wrote.

  • mdBook's built-in search is enabled per book, giving per-version search for free.
  • Each API reference carries its own generator's search (rustdoc's, pdoc's), scoped to that reference and separate from the book's.
  • Cross-version and whole-site search are out of scope for v1 (ADR-028).

9. Content model

SectionSourceOwner
Landing / overviewwebsite/site/content/_index.mdcore
FAQwebsite/site/content/faq/core
Blogwebsite/site/content/blog/core contributors only
Community (contributing, CoC, governance, mailing lists)website/site/content/community/core
Spec / impl / cookbook / tutorials bookdocs/ via docs/SUMMARY.mdper existing agent ownership
Appendix: ADRs + prior-artdocs/adr/, docs/prior-art.md via docs/SUMMARY.mdper existing agent ownership
Rust API referencecargo doc outputgenerated
Python API referencepdoc output, from the bindings' doc commentsgenerated
API reference index pagedocs/api-reference.md via docs/SUMMARY.mdcore

10. Open questions

[OQ-1]: Resolved. Tag naming is MAJOR.MINOR.PATCH with no leading v (e.g. 0.1.0). See §4.

[OQ-2]: Resolved. ADRs and prior-art.md are published as a book appendix in v1. See §5, §9.

[OQ-3]: Branding — logo, color palette, and typography for the shared theme tokens. Deferred; to be decided before the theme is built.

ADR-001: Private Extension Types Must Carry Inline Descriptors

Status

Accepted

Context

The element-types spec defines a uint8 type tag space with a private extension range (0xF0–0xFE) reserved for implementation-specific types. The question (OQ-3 in element-types.md) was whether these extension tags should be opaque identifiers or carry enough inline metadata for any conforming reader to handle them gracefully — specifically, to compute buffer sizes and refuse cleanly rather than corrupting memory or crashing.

A related question arose: should the core type system be replaced entirely by a parameterized float descriptor (sign bits, exponent width, mantissa width, exponent bias, NaN/Inf flags) rather than an enumerated tag space? This would make the format self-describing for any IEEE 754-family format without requiring spec updates.

Decision

Private extension type tags (0xF0–0xFE) MUST carry an inline type descriptor in the tensor metadata. The descriptor MUST include at minimum:

  • Bit width of a single logical element
  • Whether the type is sub-byte packed (bool), and if so the packing factor
  • Whether the type is a floating-point type (bool)
  • For floating-point types: sign bits (uint8), exponent bits (uint8), mantissa bits (uint8), exponent bias (uint16), and a flags field encoding NaN/Inf semantics

A human-readable name was considered for the inline descriptor but was omitted from the binary encoding: a variable-length utf8 field would break the fixed 20-byte descriptor size and complicate reader logic. Diagnostic names for extension types are an application-layer concern; implementations that require them should maintain an out-of-band registry keyed on the implementation-defined tag value.

The core enumerated type tag space (Tier 1 and Tier 2) is retained as-is. The parameterized descriptor lives only in the extension mechanism, not in the core type system.

Alternatives Considered

Fully parameterized type system (no enumerated tags): Replace all type tags with inline descriptors. Rejected because:

  1. Structural parameters alone (sign, exponent, mantissa widths) cannot fully describe floating-point semantics — NaN patterns, infinity availability, and rounding conventions differ between formats that are structurally identical (e.g., OCP OFP8 float8_e4m3 vs. a hypothetical IEEE 754 binary8 with the same bit widths but different NaN/Inf rules).
  2. Every reader would need to parse a descriptor in hot-path interchange code instead of switching on a single byte.
  3. Enumerated tags allow the spec to make unambiguous semantic commitments for each named type.

Opaque extension tags (no inline descriptor): Extension tags carry no metadata. Rejected because readers cannot compute buffer sizes for unknown types, making safe rejection impossible without risking out-of-bounds memory access.

Consequences

  • The metadata.md spec section must define the inline type descriptor binary encoding for extension type tags.
  • Readers that encounter an unknown extension tag MUST parse the inline descriptor to determine buffer size, then reject the tensor (or skip it in permissive mode). They MUST NOT attempt to interpret the data buffer.
  • OQ-3 in element-types.md is resolved and can be removed when metadata.md is written.
  • The parameterized float descriptor format defined here for extensions may also serve as the canonical way to describe float4 and float6 variants (OQ-4, OQ-5) if those are added to Tier 2 before being formally standardized.

ADR-002: Multi-Buffer Descriptor for Sparse Layouts

Status

Accepted

Context

Sparse tensor formats (CSR, CSC, COO, BSR, ELLPACK) require multiple distinct component arrays — values, index arrays, and pointer arrays — each with a different element type and length. The original memory-layout.md draft assumed a single buffer per tensor descriptor and deferred the question of how to handle this as OQ-1.

The options considered were:

  1. Single buffer with offsets: pack all component arrays into one contiguous buffer, addressing each via byte_offset. Simple but problematic: the components have different element types, different alignment requirements, and sizes that are only known after computing nnz. Packing them together also breaks the zero-copy invariant when only one component needs to be shared.

  2. Nested tensor descriptors: each component array is itself a full Hurray tensor descriptor. Clean but heavyweight — a CSR tensor would require three complete tensor descriptors, each with its own layout tag, shape, and framing overhead.

  3. Multi-buffer descriptor (buffer table): the tensor descriptor carries a uint8 count followed by an ordered list of buffer handles. Dense tensors have count = 1; sparse layouts declare how many buffers they need and what each holds. This follows the same model Apache Arrow uses for arrays (up to three buffers per column: validity bitmap, offsets, data).

Decision

A buffer table is introduced as a first-class field in every tensor descriptor. The buffer table is a uint8-prefixed ordered list of buffer handles (as defined in buffer-protocol.md). Dense tensors always have count 0x01. Sparse layout tags declare their required buffer count and the role of each buffer (values, indices, pointers).

The existing buffer_index field in the general subpaving layout (0x06) already anticipated this; the buffer table formalises what those indices reference.

Alternatives Considered

  • Single buffer with offsets: rejected. Different element types in one buffer breaks type-safe access and complicates zero-copy sharing of individual components.
  • Nested tensor descriptors: rejected. Excessive framing overhead for what are logically sub-arrays of a single sparse tensor, not independent tensors.

Consequences

  • All tensor descriptors carry a uint8 buffer count, costing one byte for every dense tensor on the wire. This is accepted in exchange for a uniform decoder path (no special-casing of dense vs. sparse).
  • Sparse layout tags are now unblocked. They can be assigned and fully specified in a future revision of memory-layout.md.
  • buffer-protocol.md must define the buffer handle encoding used in the table entries, including size, alignment, and device fields.
  • The general subpaving layout's buffer_index field continues to work unchanged; it indexes into the new buffer table.

ADR-003: Panel/Pack Formats via Extension Layout Tags and Content Negotiation

Status

Accepted

Context

Panel/pack formats are internal buffer layouts used by BLAS/BLIS libraries (and equivalents such as cuBLAS, oneDNN) when preparing matrix inputs for GEMM kernels. Before a multiply, inputs are repacked into a layout tuned to the target hardware's cache hierarchy, SIMD register width, and panel dimensions. The repacked buffer is consumed immediately by the kernel and then discarded.

OQ-2 in memory-layout.md asked whether panel/pack should be a named Tier 1 or Tier 2 layout tag, or explicitly out of scope.

The initial analysis favoured "out of scope" on the grounds that these formats are implementation-specific and not portable. However, the design of content negotiation in interchange.md changed the calculus: a client can advertise its hardware profile to the server, the server transcodes and packs on the fly, and the client hands the result directly to the BLAS kernel. The packed buffer never crosses an incompatible boundary; portability is not required.

The remaining question was whether to define a named Tier 2 layout tag with normative hardware-parameter fields, or to use the existing extension layout mechanism.

Decision

Panel/pack formats are not given a named layout tag. They are explicitly supported via the extension layout mechanism (0xF0–0xFE) combined with the transport protocol's content negotiation.

The layout entry encoding in interchange.md is extended so that extension layout tags in preferred_layouts (in TENSOR_REQUEST) and supported_layouts (in CLIENT_HELLO / SERVER_HELLO) MAY carry opaque metadata (ext_metadata) alongside the tag byte. For panel/pack, this metadata encodes the client's hardware profile. The server either recognises the profile and transcodes, or skips to the next preference.

Alternatives Considered

  • Named Tier 2 layout tag with normative hardware-parameter fields: rejected. BLIS, OpenBLAS, cuBLAS, and oneDNN do not agree on the relevant parameters or their semantics. Any normative definition would either be too narrow (tied to one library's model) or too abstract to be actionable. Named layouts must be interpretable by any conforming reader; panel/pack cannot meet that bar without locking in specific library internals.

  • Explicitly out of scope: rejected. The content negotiation mechanism makes panel/pack tractable without requiring portability. Saying "out of scope" would miss a real use case that the extension mechanism already handles cleanly.

Consequences

  • The layout entry encoding in interchange.md is variable-length: core layout tags are a single byte; extension tags carry an additional uint16 length and opaque metadata. Decoders MUST be able to skip unrecognised extension entries using the length field.
  • Panel/pack is explicitly documented in memory-layout.md as the canonical use case for extension layouts via content negotiation.
  • No central registry of extension layout identifiers is defined. Producers and consumers must agree on the extension_layout_id and ext_metadata schema out of band (e.g. via a shared library or published profile specification).
  • NVIDIA Tensor Core fragment layouts and other hardware-internal formats remain out of scope even under this decision, as they are not intended for interchange at all.

ADR-004: Shard Descriptor Uses Offset + Shape (Axis-Aligned Box)

Status

Accepted

Context

A Hurray tensor MAY carry a shard descriptor indicating its position within a larger logical parent tensor. OQ-3 in memory-layout.md asked whether the shard descriptor should use the current offset + shape model (an axis-aligned hyperrectangle / box) or a more general subpaving region descriptor that could express non-rectangular or non-contiguous shards.

Decision

The shard descriptor retains the offset + shape design. A shard is an axis-aligned box in the parent tensor's index space, fully described by parent_shape, shard_offset, and the shard's own shape. For each dimension k the shard covers indices [shard_offset[k], shard_offset[k] + shape[k]).

Alternatives Considered

  • General subpaving region descriptor: would reuse the machinery of layout tag 0x06 to allow non-rectangular or non-contiguous shards. Rejected for two reasons:
    1. Conflates distinct concepts — the subpaving layout describes how elements are arranged in memory; the shard descriptor describes the logical position of a sub-tensor within a parent. Merging these adds implementation complexity without benefit.
    2. No identified use case: all practical sharding patterns in ML inference (batch splitting, tensor parallelism, pipeline stages) produce axis-aligned boxes.

Consequences

  • The shard descriptor is simple to encode, decode, and validate (one bounds check per dimension).
  • The parallel transfer protocol in interchange.md can rely on box semantics for coverage and non-overlap validation.
  • If a future use case for non-rectangular shards is identified, a general shard descriptor MAY be added alongside the current one as an optional field; the offset + shape form would remain the default.

ADR-005: Morton Layout Uses Zero-Padding for Non-Power-of-Two Dimensions

Status

Accepted

Context

The Morton (Z-order curve) layout uses morton_bits[k] to define the number of bits allocated to each dimension in the interleaved Morton code. The buffer must hold 2^(sum(morton_bits)) elements. When a dimension size is not a power of two, morton_bits[k] must be set to ceil(log2(shape[k])), padding the dimension to the next power of two. Elements with Morton codes corresponding to indices outside the tensor's shape are padding with undefined values.

OQ-4 asked whether this zero-padding approach should be mandated, or whether a "compact Morton" scheme should be defined to eliminate the padding waste.

Decision

The zero-padding approach is mandated. Writers SHOULD set morton_bits[k] to the minimum value satisfying shape[k] <= 2^morton_bits[k]. The buffer holds 2^(sum(morton_bits)) elements; padding elements are undefined and readers MUST NOT access them as tensor data.

Alternatives Considered

  • Compact Morton addressing: a bijective mapping from Morton codes to valid elements, skipping codes that fall outside the tensor's shape. Rejected because:
    1. It requires non-trivial per-access index computation (lookup tables or specialised bit manipulation), breaking the branchless bit-interleaving that makes Morton fast.
    2. It significantly complicates the implementation and the spec (the mapping is not self-evident and requires a normative algorithm).
    3. Morton layouts are inherently power-of-two structures; non-power-of-two use is already an unusual choice. Writers for whom padding waste is unacceptable should use a tiled or row-major layout instead.

Consequences

  • The padding factor per dimension is strictly less than 2× in the worst case (when shape[k] = 2^(b-1) + 1). For typical ML dimensions (224, 256, 512, etc.) the waste is small or zero.
  • Morton index computation remains a simple, branchless bit-interleaving operation with no special cases.
  • The morton_bits[k] field gives writers explicit control over the trade-off between waste and address space: a writer MAY choose a larger morton_bits[k] than the minimum (e.g. for alignment reasons), at the cost of more padding.

ADR-006: Hilbert Curve Is a Named Tier 2 Layout with the Skilling (2004) Algorithm

Status

Accepted

Context

The Hilbert curve layout (tag 0x40) was placed in Tier 2 as a provisional entry with no normative index mapping. OQ-5 asked whether to confirm the Tier 2 placement (requiring a complete normative algorithm) or move it to the implementation-private extension range (0xF0–0xFE) where no normative definition would be needed.

Decision

The Hilbert curve is confirmed as a named Tier 2 layout. The normative index mapping is the algorithm from Skilling (2004), reproduced verbatim in memory-layout.md. Both directions — CoordsToHilbert and HilbertToCoords — are specified as normative pseudocode.

Alternatives Considered

  • Extension range only: rejected. A layout tag in the extension range requires out-of-band agreement between producer and consumer, providing no interoperability benefit. If the mapping is not normative, there is no point including it in the spec.
  • Drop entirely: rejected. The Hilbert curve has a meaningful advantage over Morton for 2D/3D spatial tensors (no large jumps across quadrant boundaries), and a clean normative algorithm exists. Dropping it would leave a gap for spatial-locality use cases with no standard answer.

Consequences

  • Conforming implementations that support layout tag 0x40 MUST implement the Skilling (2004) algorithm exactly. Two compliant implementations will produce identical index mappings for identical inputs.
  • The algorithm imposes the constraint that all tensor dimensions equal 2^hilbert_order. Non-power-of-two spatial tensors must be padded or use a different layout.
  • The Skilling algorithm has O(r * p) time complexity per element (r = rank, p = order), which is more expensive than Morton's O(r * p) bit-interleave but with higher constant factors due to the rotation/Gray-code state machine. This is acceptable for a reference implementation; performance-critical paths may cache the mapping.
  • The conformance example table in memory-layout.md (16 entries for the 4×4 case) gives implementations a concrete test vector.

ADR-007: Empty Tensors Are Normatively Permitted

Status

Accepted

Context

OQ-2 in docs/spec/data-model.md asked whether a tensor with any dimension size equal to 0 (an empty tensor) should be permitted by the format, or rejected as invalid. The current spec text permits empty tensors ("a reader MUST accept an empty tensor without treating it as an error"); the question was whether to confirm that permissive rule or tighten it for v1.

Hurray's primary goal is faithful zero-copy interchange between runtimes and languages. The producers Hurray is designed to wrap — PyTorch, NumPy, JAX, Apache Arrow, DLPack — all permit zero-size dimensions. ML compiler IRs (XLA/StableHLO, TorchDynamo, MLIR tensor dialect) produce them as valid intermediate results from shape inference under dynamic batching, masked selection, and uniform broadcasting. ONNX is the principal outlier; its restriction is a documented source of friction between training and deployment toolchains.

Hurray already distinguishes an empty dimension (size 0, fully resolved, zero elements) from a dynamic dimension (the sentinel 0xFFFFFFFFFFFFFFFF, unresolved size to be supplied by the interchange channel). The two concepts are orthogonal and must not be conflated.

The secondary concern — that zero-byte GPU allocations are implementation-defined on some runtimes — is real but resolvable by specifying that an empty tensor's data buffer carries size = 0 and MAY be represented with a null pointer, so no device allocation is required.

Compatibility asymmetry matters: permitting now and forbidding later is a breaking change for v1 producers; forbidding now and permitting later is non-breaking. That asymmetry argues for caution, but the cost of forbidding — every runtime that permits empty tensors must insert a defensive check before every Hurray export — exceeds the cost of potentially tightening via a future stricter conformance profile.

Decision

Empty tensors are normatively permitted in Hurray v1.

  1. Any dimension size in the shape array MAY be 0. A tensor with one or more zero-size dimensions is empty and has element_count = 0.
  2. A writer MAY emit an empty tensor. A reader MUST accept an empty tensor without treating it as an error.
  3. An empty tensor MUST carry a complete, valid descriptor: rank, shape, element type, layout tag, buffer table, and any applicable quantization descriptor. No descriptor fields are optional on account of emptiness.
  4. The data buffer(s) of an empty tensor MUST have byte size 0. The buffer pointer MAY be null. The 64-byte buffer alignment requirement does not apply to a zero-length buffer (there are no addressable bytes to align); a non-null zero-length buffer pointer MAY have any alignment.
  5. The value 0 (resolved empty dimension) and the sentinel 0xFFFFFFFFFFFFFFFF (dynamic, unresolved dimension) are distinct. A reader MUST NOT treat them as equivalent, and MUST NOT substitute one for the other when resolving dynamic dimensions.
  6. For sparse layouts (COO, CSR, CSC, and any future sparse layout), nnz = 0 is valid and is independent of whether any logical shape dimension is 0. An empty sparse tensor has both element_count = 0 implied by shape and nnz = 0.
  7. For sub-byte element types (bool, int4, uint4, int2, uint2), an empty tensor occupies 0 bytes; no partial trailing byte is emitted.
  8. A quantization scheme with a per-axis or per-block descriptor MUST accept a shape in which the quantization axis has size 0: the scales and zero-point arrays are themselves empty (length 0) in that case.

Alternatives Considered

Reject empty tensors at the format level (ONNX-style). Pros: matches a deployment-focused lineage; eliminates the zero-byte buffer edge case in C FFI and device allocators. Cons: breaks zero-copy import from PyTorch, NumPy, JAX, Apache Arrow, and DLPack — every producer-to-Hurray handoff would need a defensive shape check and a fallback path. Rejected because it violates Hurray's primary goal of faithful zero-copy interchange with the ecosystem it targets.

Reject in a strict conformance profile only. Pros: permits the core format to stay liberal while giving deployment pipelines a way to enforce stricter rules. Cons: profiles are a v2-and-later concern; introducing one prematurely adds spec surface area before the compliance matrix has stabilised. Deferred — a future stricter conformance profile MAY forbid empty tensors without altering the core spec defined here.

Conflate zero-size and dynamic dimensions. Pros: one sentinel to carry both "unknown" and "zero". Cons: loses information. A producer that knows a batch is empty (a filter that selected zero rows) has different downstream semantics than a producer that has not yet resolved the batch size. Rejected as a clear information loss.

Consequences

  • Zero-copy import paths from PyTorch, NumPy, JAX, Arrow, and DLPack work without shape-gating. This is the intended outcome.
  • docs/spec/buffer-protocol.md (to be written) MUST specify that zero-length buffers MAY have a null pointer, that the 64-byte alignment requirement is waived for zero-length regions, and that consumers MUST NOT dereference a zero-length buffer regardless of its pointer value.
  • The C FFI layer (docs/impl/c-ffi.md) MUST treat a zero-length buffer handle as a valid input and MUST NOT issue a zero-byte device allocation. A null data pointer with size = 0 is the canonical representation.
  • docs/impl/compliance.md MUST require at least one round-trip test vector for an empty tensor: recommended cases are shape [0], shape [3, 0, 5], and an empty sparse CSR (nnz = 0).
  • docs/spec/data-model.md OQ-2 is resolved and the marker MUST be removed.
  • Any future stricter conformance profile that forbids empty tensors MUST be introduced as an additional constraint layered on top of this ADR, never by modifying the core rule.

ADR-008: Normative Rank Cap of 64

Status

Accepted

Context

OQ-1 in docs/spec/data-model.md asked whether the specification should impose a normative maximum rank (e.g., 64) or leave rank unconstrained with only a SHOULD-level recommendation for implementations.

The rank field in the tensor descriptor is encoded as a uint32, which allows values up to 0xFFFFFFFF. Without a normative cap, a conforming reader would be required to attempt to parse a shape array of up to ~4 billion uint64 values (32 GB) before determining whether the descriptor is valid. This is a latent denial-of-service vector for any implementation that reads Hurray descriptors from an untrusted source — IPC, cross-machine streaming, or file format.

In practice, no known ML workload uses tensors with rank above single digits. PyTorch caps rank at 64 (MAX_DIMS). NumPy caps at 32 (NPY_MAXDIMS). TensorFlow and Apache Arrow impose no formal cap, but neither targets the same security-sensitive interchange contexts as Hurray.

A normative cap also has implementation benefits: shape arrays, stride arrays, and per-dimension layout parameters can be stack-allocated with a fixed bound, eliminating heap allocation on the hot path of descriptor parsing.

Decision

The maximum rank of a Hurray tensor is 64.

  1. A writer MUST NOT emit a descriptor with rank > 64. A reader MUST reject a descriptor with rank > 64.
  2. A conforming implementation MUST support tensors of rank 0 through 64 inclusive.
  3. The cap applies to all layout descriptors: strides, tile shapes, Morton bits, sparse index arrays, and shard offsets are all bounded by rank ≤ 64.
  4. The uint32 encoding of rank is unchanged; values 65–0xFFFFFFFF are reserved and MUST be rejected.

Alternatives Considered

Leave rank unconstrained (SHOULD-level recommendation only). Pros: maximum flexibility for scientific computing use cases with very high dimensional data. Cons: exposes every reader to a DoS vector — a four-byte field that instructs the reader to consume up to 32 GB before rejecting the descriptor. For a format designed for IPC and cross-machine streaming, this is unacceptable. Also eliminates stack-allocation optimisations that simplify implementation. Rejected.

Cap at 32 (NumPy NPY_MAXDIMS). Pros: smaller stack footprint; matches NumPy. Cons: rejects tensors that PyTorch (MAX_DIMS = 64) considers valid, breaking round-trip fidelity at the upper edge of PyTorch's own range. Rejected in favour of 64 to match the dominant ML framework.

Cap at 8 or 16 (practical ML maximum). Pros: tightest possible bound; eliminates edge cases entirely. Cons: overly restrictive — future architectures (e.g., multi-dimensional attention with explicit head, sequence, and batch axes) may reach 8 naturally. Rejected as unnecessarily limiting.

Consequences

  • All spec sections that reference per-dimension arrays (shape, strides, tile_shape, morton_bits, outer_strides, inner_strides, shard offsets) are implicitly bounded at 64 entries. No individual section needs to repeat the cap; a normative cross-reference to this ADR from data-model.md suffices.
  • Implementations MAY use fixed-size stack arrays of length 64 for all per-dimension data, eliminating heap allocation from the descriptor-parsing hot path.
  • docs/spec/data-model.md OQ-1 is resolved and the marker MUST be removed. The existing conformance text ("MUST support tensors of rank up to 64 inclusive") is confirmed and extended with the rejection rule for rank > 64.
  • docs/impl/compliance.md MUST include a test vector with a descriptor carrying rank = 65 and verify that a conforming reader rejects it.

ADR-009: Release Callback is Normative; Reference Counting is an Implementation Detail

Status

Accepted

Context

OQ-1 in docs/spec/buffer-protocol.md asked whether the C ABI should expose a normative retain/release pair for reference-counted buffer sharing, or whether reference counting should remain an implementation detail with only the single-consumer release callback being normative.

The core use case for normative reference counting would be: two independent Hurray implementations simultaneously holding a reference to the same buffer, each needing to signal its release so the memory is freed only when both are done. This requires a shared, interoperable retain/release protocol at the C ABI boundary.

DLPack, the closest existing tensor ABI, takes the simpler path: a single deleter function on DLManagedTensor. The producer wraps its actual deallocation inside the deleter; if the producer wants to share the buffer across multiple consumers, it implements reference counting internally and provides each consumer with a separate DLManagedTensor pointing to the same data, each with a release-aware deleter. Consumers are not aware of the internal reference count.

Decision

Reference counting is an implementation detail. The only normative contract at the C ABI level is:

  • A buffer handle carries a release callback.
  • The consumer MUST call the release callback exactly once.
  • The release callback MUST be safe to call from any thread.

A producer that wishes to support multi-consumer sharing MUST implement reference counting internally and hand each consumer a separate buffer handle whose release callback decrements the internal count, invoking the actual deallocation only when the count reaches zero. Consumers are not aware of this; they call their release callback exactly once, as the normative contract requires.

No normative retain function is added to the C ABI.

Alternatives Considered

Normative retain/release pair at the C ABI level. Pros: makes cross-implementation multi-consumer sharing interoperable without coordination — any consumer can extend a buffer's lifetime by retaining it. Cons: forces every language binding (Python/GC, Rust/Arc, Go, Java) to bridge between its own memory management idiom and the C ref-count, adding complexity. The scenario requiring this — two independent implementations sharing a single buffer region with no producer coordination — is effectively nonexistent in inference-serving pipelines, which are sequential stage-to-stage rather than parallel fan-out across runtimes. Rejected as over-engineering for the target use case.

No release callback; ownership is always transferred. Pros: simplest possible ABI — the consumer owns the memory and is responsible for freeing it. Cons: forces a specific allocator on the producer (the consumer must know how to free memory allocated by the producer). Incompatible with GPU device memory, shared memory segments, and arenas. Rejected.

Consequences

  • docs/spec/buffer-protocol.md OQ-1 is resolved. The section on reference counting ("Implementations MAY use reference counting…") is confirmed as non-normative description, not a requirement.
  • docs/impl/c-ffi.md MUST define the release callback signature: void (*release_fn)(void *user_data) or equivalent, called exactly once, thread-safe. No retain function is defined.
  • Language bindings (hurray-python, and future Go/Java bindings) implement their own lifetime management on top of the single release callback. For Python, the __dlpack__ / __dlpack_device__ protocol already provides the correct abstraction.
  • The internal reference counting implementation in hurray-core (when multi-consumer sharing is needed within a single process) is an implementation detail of hurray-ffi and is not visible at the ABI boundary.

ADR-010: Multi-Tensor Collections Deferred; Streams Are Sequences of Self-Delimiting Tensors

Status

Accepted

Amended 2026-07-27 (deferral resolved): The multi-tensor use cases this ADR deferred have since been addressed by later decisions — informed by the very implementation experience this ADR said to wait for:

  • Grouping (multi-output inference; independent tensors under one logical identity) ships as the composite Group rule (ADR-027), bound by stream/file adjacency rather than a name namespace. ADR-027 records that it "amends the deferral scope of ADR-010."
  • Named / indexed storage (the SafeTensors/GGUF-style use case, including key-value metadata) ships as the HRRYFILE container (ADR-011): names + footer index + typed KV. ADR-011 revisited and accepted, for the at-rest file format, the naming and KV metadata this ADR rejected for the runtime wire format — the two remain distinct.
  • Batch streaming is the Option A sequential stream decided below, unchanged.

What remains out of scope is only a standalone hurray-archive sibling specification distinct from HRRYFILE; it is optional and not a v1.0 blocker. The Option A decision below still stands as the canonical multi-tensor streaming encoding.

Context

The Hurray spec defines one tensor per descriptor. The question arose whether Hurray should define a normative multi-tensor collection format — a file or stream that groups multiple tensors under shared framing, names, or an index.

Use cases that motivate this question:

  • Multi-output inference: a model server returning multiple output tensors in a single response (e.g., logits + hidden states + attention weights).
  • Model weight storage: a collection of named weight tensors stored in a file for distribution and loading (analogous to SafeTensors or GGUF).
  • Batch streaming: a stream of tensors produced by a pipeline stage, consumed incrementally by the next.

Three options were evaluated:

  • Option A — Sequential stream of self-delimiting descriptor+data pairs. No index, no names, pure streaming. Falls out of the existing invariants for free.
  • Option B — Named tensor map: a header with a name→offset index, followed by data blobs. Like SafeTensors but zero-copy at runtime.
  • Option C — Both: a streaming mode (no index) for runtime RPC, and an indexed file mode for model storage.

Decision

Option A is the normative multi-tensor encoding for Hurray v1. Options B and C are out of scope for v1.

A Hurray stream MAY contain zero or more tensors. Back-to-back concatenation of self-delimiting descriptor+data pairs is the canonical multi-tensor encoding. A reader processes tensors one at a time, advancing to the next descriptor after the current tensor's data is consumed. No new framing bytes, no container header, no names are defined.

A named/indexed tensor collection format (analogous to SafeTensors or GGUF) is deferred to a future hurray-archive sibling specification, not this document.

Rationale

Option A is free. The self-delimiting invariant (a reader can determine each descriptor's total byte length from its first 10 bytes) already makes sequential concatenation parse-able. Apache Arrow IPC uses exactly this model: a stream is zero or more record batches, each self-delimiting. Hurray adopts the same idiom for tensors.

Options B and C are premature. Introducing naming and indexing commits the spec to a set of decisions that are orthogonal to runtime interchange:

  • A string encoding and uniqueness policy for tensor names.
  • A namespace model (flat, hierarchical, Zarr-like groups?).
  • A lookup mechanism that is in tension with the no-back-references invariant: a header index requires knowing all descriptor and buffer sizes before writing begins (breaks streamable writers); a footer index requires scanning to end-of-file before reading begins (breaks streamable readers).
  • Eventual pressure to add key-value metadata (model provenance, quantization config, tokenizer parameters) — a parallel type system.

Making these decisions in v1, before any implementation experience, risks locking in choices that prove wrong in practice.

Ecosystem positioning. Hurray's differentiator is zero-copy at runtime, not better model storage. SafeTensors and GGUF are mature, ecosystem-supported, and solve the at-rest model-distribution problem. A hurray-archive format, if designed, should be informed by the experience of running the runtime format in production — not designed up front in parallel.

Compatibility. A future indexed format is additive: it would use a distinct magic byte sequence and would not alter the v1 tensor descriptor encoding. Deferral is safe.

Alternatives Considered

SafeTensors-style header (JSON name→offset map + flat data). Breaks the streamable-writer invariant: a writer must know all tensor sizes before it can write the header. Rejected for v1.

GGUF-style KV metadata + named tensors. Commits to a key-value metadata schema and a type system for metadata values. Scope is much larger than runtime interchange. Rejected for v1.

Zarr-style hierarchical namespace. Commits to a group/array namespace model with storage-backend abstraction. Orthogonal to runtime interchange. Rejected.

Consequences

  • docs/spec/interchange.md MUST add one normative statement: a stream MAY contain zero or more tensors; back-to-back concatenation of self-delimiting descriptor+data pairs is the canonical multi-tensor encoding.
  • TODO.md MUST record hurray-archive as a future exploration item.
  • No other spec files require changes.
  • A future hurray-archive specification may define a named/indexed format as a separate document. It will use a distinct magic byte sequence and will not modify the v1 tensor descriptor encoding. It may be introduced as a minor version increment (additive) or as a sibling specification, to be decided when the design is ready.

ADR-011: File Format — Random-Access Container for Hurray Tensors

Status

Accepted

Context

Hurray defines a streaming IPC format (descriptor + data, back-to-back, no seek) suitable for sockets, pipes, and RDMA. For on-disk model distribution, cold-start inference, and multi-tensor archives, a random-access container is needed: the ability to open a file, enumerate named tensors, and mmap any tensor's data without reading the rest of the file.

Prior art (SafeTensors, GGUF, Arrow IPC file) converges on a footer-based index with optional typed key-value metadata. SafeTensors uses a JSON header; GGUF uses a custom binary header. Arrow IPC file uses a FlatBuffers footer.

Both formats share the same tensor descriptor encoding (metadata.md); the file format is purely a container layer on top. This is the key design constraint: the file format MUST NOT redefine how individual tensors are described.

Decision

Define a Hurray file format as a named-tensor container wrapping the existing tensor descriptor encoding, with the following design:

  1. Magic: 8-byte HRRYFILE (0x48 0x52 0x52 0x59 0x46 0x49 0x4C 0x45). Distinguishes the file format from the streaming format (which begins with HRRY followed by a tensor descriptor). An HRRYFILE magic byte sequence is never a valid streaming tensor descriptor.

  2. Container version: Independent container_version_major / container_version_minor (uint8 each) in the file header, separate from the tensor descriptor's version. Current: 0x01 / 0x00.

  3. Tensor names: UTF-8, length-prefixed with uint16 (max 65 535 bytes), no null terminator. Names MUST be unique within a file (case-sensitive, byte-exact). Names MUST NOT be empty. No hierarchical semantics are assigned to any character, including /; names are opaque identifiers at the spec level.

  4. Index position: Footer only. The index is written after all tensor data; a fixed-size 32-byte trailer at the end of the file locates the index. This is the only design compatible with single-pass streaming writes.

  5. Trailer: Fixed 40 bytes at file_size - 40. Contains: index_offset (uint64), index_length (uint64), kv_offset (uint64), kv_length (uint32), index_crc32c (uint32, valid when HAS_INDEX_CRC32C file flag is set), _reserved (uint8[4], MUST be 0x00), and trailer_magic (HRRY, 4 bytes ASCII). The trailer was extended from 32 to 40 bytes to add index_crc32c and 4 reserved bytes; trailer_magic changed from HRRY_END to HRRY to fit within 4 bytes. The HAS_INDEX_CRC32C flag (bit 2 of file_flags) governs whether index_crc32c is populated and MUST be verified.

  6. Alignment: Tensor data buffers MUST be aligned to a page boundary within the file. The default page size is 4096 bytes; the file header MAY declare a larger alignment (up to 2 MiB) for huge-page environments. Tensor descriptors MUST be aligned to 8 bytes. All padding bytes MUST be 0x00.

  7. KV metadata: Optional file-level key-value metadata section with typed values (UTF-8 string, int64, uint64, float64, bool, byte sequence, typed array). This is required for ecosystem adoption: model architecture, tokenizer parameters, and quantization configuration cannot be expressed as tensor descriptors.

  8. Streaming write: MUST be supported. A writer tracks byte offsets as it writes each tensor inline, then appends KV metadata (if any), then the index, then the 40-byte trailer. No backward seeks are required.

  9. Streaming read (no seek): OPTIONAL. Tensor descriptors and data appear inline in file order; a non-seeking reader MAY consume them sequentially without the footer. This is a courtesy for pipe-based pipelines, not a required reader mode.

  10. Shared descriptor encoding: Both formats use the tensor descriptor encoding from metadata.md verbatim. The file format index caches descriptor_length per entry (for O(index) fast enumeration without parsing descriptors), but MUST NOT cache shape, dtype, layout, or any other descriptor field.

Alternatives Considered

Header index (offsets written before data). Rejected: a streaming writer cannot know tensor offsets before writing them. Requires full buffering or two-pass writes.

Dual index (header + footer). Rejected: doubles write cost; creates a reconciliation problem on disagreement.

FlatBuffers for the index. Rejected: adds an external dependency and a schema parser. The index schema is static and narrow; a hand-rolled binary format is simpler and fully specifiable in RFC 2119 terms.

Reuse Arrow IPC file format. Rejected: Arrow is columnar and cannot express Hurray's layout diversity (tiled, Morton, sparse) or quantization descriptors. Wrapping Hurray descriptors in Arrow's framing would add complexity without benefit.

No KV metadata in v1. Rejected: GGUF's widespread adoption is driven by typed KV metadata. Without it, producers must store model metadata out-of-band, creating a fragmented ecosystem.

Compress tensor data within the file. Rejected: compression and zero-copy mmap are mutually exclusive. Hurray is zero-copy-first.

Content-addressable/chunked storage (Zarr-style). Rejected: Zarr's strength is cloud storage with per-chunk compression. Incompatible with zero-copy mmap.

Consequences

  • Hurray now covers the full lifecycle: runtime interchange (streaming format) and model storage / distribution (file format).
  • The file format enables SafeTensors/GGUF replacement with zero-copy mmap and rich layout/quantization metadata.
  • README.md Core Property 2 ("no end-of-file index") must be scoped to the streaming format. The file format explicitly uses a footer index.
  • hurray-io must implement both a streaming writer/reader and a file writer/reader.
  • hurray-inspect should print the format kind on open (stream vs file).
  • The C FFI API should expose separate entry points (hurray_file_open vs hurray_stream_open) to prevent format confusion.
  • A new spec file docs/spec/file-format.md is the normative reference for the container layer.

ADR-011: Server Device Selection Algorithm

Status

Accepted

Context

The Hurray network transport protocol advertises device capabilities in CLIENT_HELLO and SERVER_HELLO (supported_devices lists) and accepts a preferred_device tag in TENSOR_REQUEST. The spec overview claimed "device negotiation" support, but no normative selection algorithm existed. Without one, the DEVICE_UNAVAILABLE error code and preferred_device field had no defined semantics.

The core problem is that device selection, unlike layout selection, has no natural ordered preference list on the wire (preferred_device is a single tag). A silent fallback to a different device (e.g., CUDA → CPU) produces correct results but may cause catastrophic performance regressions in inference workloads.

Decision

The server follows a strict ordered algorithm upon receiving preferred_device:

  1. Serve on preferred_device if available.
  2. If preferred_device is CPU and unavailable: DEVICE_UNAVAILABLE error.
  3. If preferred_device is non-CPU and unavailable: DEVICE_UNAVAILABLE error (no silent fallback).
  4. Single exception: if preferred_device was advertised but is transiently unavailable, the server MAY fall back to CPU if and only if the client also advertised CPU. This is the only permitted silent fallback.

The actual device is reported in the buffer handle device_tag fields of TENSOR_DESCRIPTOR.

Alternatives Considered

Silent fallback to CPU always. Rejected — hides performance collapses in inference workloads.

Client supplies a preference list (ordered). Rejected for v1 — expands the TENSOR_REQUEST wire format and conflates device selection with layout negotiation. The DEVICE_UNAVAILABLE error gives the client enough information to retry with a different preferred_device.

Never fallback, always error. Rejected — the narrow CPU-fallback exception provides a useful graceful-degradation path for clients that advertise CPU support, at zero protocol complexity cost.

Consequences

  • DEVICE_UNAVAILABLE (0x00000005) is now a normative response to an unsatisfiable preferred_device.
  • Clients that want graceful CPU fallback MUST advertise CPU in supported_devices.
  • The hurray-io Layer 5 implementation will need a server-side device-availability hook (not a spec concern).
  • buffer-protocol.md § Device Colocation gains a cross-reference noting that TENSOR_PUT device_tag is binding on the receiver.

ADR-012: GPUDirect RDMA via Bidirectional RDMA_REGISTER

Status

Accepted

Context

The RDMA data plane handshake (defined for OQ-2) only covered the source-buffer owner registering its memory region. For GPUDirect — where the sender writes directly into the receiver's GPU memory, eliminating the host-to-device copy — the receiver must also register a destination GPU memory region and share its rkey with the sender. No message or capability existed for this.

The existing RDMA_REGISTER message tag (0x0000000C) is defined as "Either" direction but the prose only described its use by the source-buffer owner.

Decision

Bidirectional RDMA_REGISTER: the destination-buffer owner sends a second RDMA_REGISTER (same message tag, same payload shape) after the source-buffer owner's RDMA_REGISTER, before RDMA_READY. This is gated by a new capability flag RDMA_GPUDIRECT (bit 3 of capability_flags), which MUST imply RDMA_DATA_PLANE (bit 2).

The mechanism is fully symmetric: the same rules apply to TENSOR_REQUEST (server is source, client is destination) and TENSOR_PUT (client is source, server is destination) with roles inverted.

For TENSOR_PUT, the client unilaterally declares the destination device_tag in the descriptor; the server rejects with DEVICE_UNAVAILABLE before the RDMA handshake if it cannot honor that device.

Alternatives Considered

Option A — Extend RDMA_READY to carry an optional destination rkey. Rejected — conflates an acknowledgement message with a registration message; creates an asymmetry between TENSOR_REQUEST (client sends RDMA_READY with rkey) and TENSOR_PUT (server sends RDMA_READY, which direction carries the rkey?).

Option B — New RDMA_REGISTER_DST message type. Rejected — adds a new tag that is semantically identical to RDMA_REGISTER; the only distinction is "which direction"; that distinction is already captured by the message sequence and the capability flag.

Option C (chosen) — Bidirectional RDMA_REGISTER. Reuses the existing tag; the payload is role-agnostic (rkey + remote_addr + length); the sequence position and capability flag make the role unambiguous.

Consequences

  • New capability flag RDMA_GPUDIRECT (bit 3). Reserved range updated to 4–63.
  • No new message type tags.
  • RDMA_REGISTER payload prose updated to describe both source-owner and destination-owner roles.
  • New normative subsection: § GPUDirect Destination Registration.
  • TENSOR_PUT with RDMA subsection rewritten to include full diagram and GPUDirect path.
  • Receivers advertising RDMA_GPUDIRECT are responsible for ensuring NIC-GPU topology compatibility (PCIe root complex colocation or NVLink); the protocol cannot validate this.
  • Per-direction RDMA_GPUDIRECT capability flags (TX vs RX) are deferred to a future revision.

ADR-013: Rust Representation of LayoutDescriptor

Status

Accepted

Context

Layer 3 of hurray-core introduces the in-memory representation of layout descriptors. The format spec (docs/spec/memory-layout.md and docs/spec/layouts/*.md) defines:

  • A 1-byte layout tag with a partitioned tag space: core (0x01–0x3F), extended (0x40–0x7F), reserved, private extension (0xF0–0xFE), and two invalid sentinels (0x00, 0xFF).
  • Per-layout descriptor fields ranging from "none" (row-major, column-major, Morton) through fixed scalar fields (Hilbert hilbert_order, COO nnz + is_sorted, CSR/CSC nnz) to variable-length structures (Strided, Tiled with recursion up to 8 levels, Subpaving region lists, Private Extension opaque payload).
  • A buffer_count that depends on the layout (1 for dense non-quantized, 2 for COO, 3 for CSR/CSC).
  • A permissive-mode requirement: a reader MUST be able to hold an unrecognised layout tag without dereferencing its data.

The constraints that drive the choice are:

  1. Permissive mode requires a non-fatal "unknown layout" representation.
  2. Sparse layouts demand a deterministic mapping from descriptor to required buffer_count.
  3. Strides, tile shapes, and shape are coupled with rank; mismatches must be detectable at construction time.
  4. The common path (row-major, column-major, Morton) MUST NOT allocate.
  5. Adding Tier 2 layouts in a future spec version should not be a breaking change to consumer code.
  6. The Rust type SHOULD mirror the wire layout cleanly enough that decoding does not require pivot/translation logic.

Decision

LayoutDescriptor is a fat enum with a small-data discipline and an explicit Unknown variant for permissive mode. Layout-specific structs live in their own files under hurray-core/src/layout/ and are referenced from the enum.

// hurray-core/src/layout/mod.rs (illustrative sketch)
#[non_exhaustive]
pub enum LayoutDescriptor {
    RowMajor,                                 // 0x01 — no payload, no alloc
    ColMajor,                                 // 0x02 — no payload, no alloc
    Strided(StridedLayout),                   // 0x03
    Tiled(Box<TiledLayout>),                  // 0x04 — Box keeps enum small (recursive)
    Morton,                                   // 0x05 — no payload, no alloc
    Subpaving(SubpavingLayout),               // 0x06
    Coo(CooLayout),                           // 0x07
    Csr(CsrLayout),                           // 0x08
    Csc(CscLayout),                           // 0x09
    Hilbert(HilbertLayout),                   // 0x40
    PrivateExtension(PrivateExtensionLayout), // 0xF0..=0xFE
    Unknown(UnknownLayout),                   // permissive mode only
}

Key rules:

  1. #[non_exhaustive] on LayoutDescriptor and on every payload struct that may grow fields. Adding a Tier 2 layout is then a non-breaking change at the source level.
  2. Box<TiledLayout> because TiledLayout is recursive (inner_layout can be Tiled again, up to 8 levels). Boxing only the recursive variant keeps the enum size bounded.
  3. Unknown(UnknownLayout) carries { tag: u8, raw_bytes: Vec<u8> }. It is the only path for tags the reader does not recognise. Constructors for named variants reject 0x00, 0xFF, and any reserved tag. The wire decoder routes unknown-but- not-invalid tags through Unknown only in permissive mode; in strict mode it returns an error.
  4. UnknownLayout carries the layout-section raw bytes, not just the tag. This preserves zero-copy forwarding: a relay in permissive mode can re-emit an unrecognised descriptor byte-for-byte.
  5. buffer_count() is a method on LayoutDescriptor returning Option<NonZeroU8>. For Unknown, it returns None. Sparse-layout buffer counts are constants on the per-variant struct, exposed through this method.
  6. Layout tag is not stored in the enum payload. The discriminant is the tag for known variants; Unknown carries it explicitly. A pub fn tag(&self) -> u8 returns the canonical wire tag for any variant, eliminating any tag/params mismatch.
  7. Rank validation is explicit and external. LayoutDescriptor does not store rank. A method validate(&self, shape: &Shape) -> Result<(), Error> is called at the tensor descriptor boundary (Layer 4), where shape and layout are assembled together. Per-variant constructors validate intra-descriptor invariants only (e.g., tile_shape values > 0, hilbert_order > 0).
  8. One file per layout under hurray-core/src/layout/: row_major.rs, col_major.rs, strided.rs, tiled.rs, morton.rs, subpaving.rs, coo.rs, csr.rs, csc.rs, hilbert.rs, private_extension.rs, unknown.rs. mod.rs declares the enum and re-exports.

Alternatives Considered

Tag + separate LayoutParams enum (Option B)

A { tag: LayoutTag, params: LayoutParams } struct mirrors the wire format. Rejected: admits invalid combinations (LayoutTag::RowMajor with LayoutParams::Strided(..)) at the type level, forcing every consumer to handle "should never happen" branches. The fat enum makes invalid states unrepresentable.

Trait object — Box<dyn Layout> (Option C)

Heap allocation on the hot path (every row-major tensor) violates constraint 4. Exhaustive matching is lost. Downcasting requires TypeId-based escape hatches. Rejected.

Storing rank inside LayoutDescriptor

Allows construction-time stride validation but replicates rank across the tensor descriptor boundary, creating a second source of truth that can drift during reshape. Validation is performed once at Layer 4 where rank and layout meet. Rejected.

Consequences

  • Zero-copy: Unknown retains raw layout-section bytes for permissive forwarding. Known variants own O(rank) or O(region_count) heap data — unavoidable given the wire format. Buffer data itself is never touched.
  • Spec stability: #[non_exhaustive] makes adding a Tier 2 layout non-breaking at the source level. Old strict-mode readers correctly reject new tags per spec.
  • FFI (Layer 7): The C ABI MUST NOT expose this enum directly. It exposes opaque handles plus a hurray_layout_tag() getter and per-layout typed accessors.
  • Quantization interop: Layout and quantization remain orthogonal. The tensor descriptor (Layer 4) combines layout.buffer_count() with the quantization scheme's parameter-buffer count to verify the buffer table.
  • Follow-up: Layer 4 MUST call layout.validate(&shape) during tensor construction.

Open / Deferred

  • Whether RegionDescriptor (Subpaving) recursion should be boxed: start with a flat Vec<RegionDescriptor>; box if benchmarks show enum size is a problem.
  • serde derives on layout types: deferred; not required for Layer 3.
  • ADR-011 numbering conflict (ADR-011-file-format-random-access-container.md and ADR-011-server-device-selection.md share a number): flag to format-spec-writer for renumbering.

Date

2026-05-04

ADR-014: Layout Address Computation Lives in hurray-core as a Trait-Based Sub-Module

Status

Accepted

Context

Layer 3 has just landed descriptor-only types in hurray-core/src/layout/ (LayoutDescriptor, StridedLayout, TiledLayout, CooLayout, etc.). These structs carry the metadata fields defined by the spec but contain zero address-computation logic. Layer 4 (tensor descriptor encoding) is about to begin, and Layer 4 also requires hurray-inspect to drop its self-contained parser and depend on hurray-core (CLAUDE.md, "Implementation rules").

The spec defines an element-address formula for every layout in docs/spec/memory-layout.md and docs/spec/layouts/*.md. These formulas are normative. The question is where in the workspace the implementation of those formulas lives.

Key forces:

  1. Spec fidelity. Address formulas are part of the format contract. The closer the formula sits to the descriptor whose fields parameterise it, the harder it is for the two to drift.
  2. Reusability. hurray-inspect, hurray-ffi, hurray-python, and the future array-database engine all need to compute addresses. None should need an extra crate dependency for this.
  3. hurray-core charter. The crate already carries quantization descriptors, alignment validation, and rayon. "No I/O, no async" — not "no logic".
  4. Implementation complexity (Morton and Hilbert):
    • Morton: a doubly-nested loop over (bit_position, dimension) — trivial integer bitops.
    • Hilbert (Skilling's algorithm): two nested loops with bit-XOR swaps. Non-trivial to derive, mechanically straightforward to transcribe from the spec pseudocode. No SIMD or lookup table needed for v1. Neither algorithm justifies a separate crate.
  5. Deferral cost. hurray-inspect's self-contained parser MUST be replaced in Layer 4. Without address computation in core, inspect cannot display element values, forcing it to keep its private addressing code — the drift risk the design is meant to prevent.

Decision

Address computation MUST live in hurray-core, in a dedicated sub-module hurray-core/src/layout/addressing/ (one file per layout, mirroring the descriptor layout). It MUST NOT live in a new crate.

The implementation is organised around two traits defined in hurray-core/src/layout/addressing/mod.rs:

/// Implemented by every dense layout descriptor.
pub trait ElementAddress {
    /// Returns the linear element offset (in logical elements, not bytes)
    /// of the element at the given multi-dimensional index.
    fn element_offset(&self, index: &[u64]) -> Result<u64, Error>;
}

/// Implemented by sparse layout descriptors; requires borrow of index buffers.
pub(crate) trait SparseElementAddress {
    /// Returns the storage offset of the given index, or None if structurally absent.
    fn sparse_element_offset(&self, index: &[u64], buffers: &SparseBuffers<'_>) -> Result<Option<u64>, Error>;
}

Key rules:

  1. Each dense layout file under hurray-core/src/layout/addressing/ MUST carry a // Spec: docs/spec/layouts/<name>.md § <section> comment at the top of each impl block to maintain auditable spec-to-code traceability.
  2. LayoutDescriptor gains a single dispatching method element_offset(&self, index: &[u64]) -> Result<u64, Error> in hurray-core/src/layout/mod.rs via a match over the enum.
  3. Sparse impls are pub(crate) initially; promoted to pub after the first consumer (Layer 4) validates the API shape.
  4. A free function byte_address_from_element_offset(element_offset, byte_offset, element_type) -> ByteAddress in mod.rs handles the common whole-byte / sub-byte byte-address conversion from memory-layout.md § Element Address Computation, separating pure layout geometry from element-type byte arithmetic.
  5. unsafe MUST NOT be used in addressing code in v1. SIMD / lookup-table optimisations are explicitly deferred and MUST be benchmark-gated with a separate ADR before introduction.

Alternatives Considered

New hurray-access crate (Option B)

Would keep hurray-core as a pure-types crate. Rejected: the total addressing code is ~600–800 LOC; a separate crate adds dependency overhead for every consumer while the supposed purity boundary is already not held (quantization logic, rayon). The real boundary is reference CPU addressing (core) vs backend-optimised addressing (future backend crates).

Methods directly on structs, no trait (Option A-flat)

Simpler, but callers cannot dispatch polymorphically on LayoutDescriptor without their own match, and adding a new layout in v2 gives no compile-time reminder that addressing is needed. The trait costs almost nothing and pays for itself the first time a new layout is added.

Defer (Option C)

Directly contradicts the Layer 4 obligation to refactor hurray-inspect. Every Layer 5+ consumer that needs addressing would write its own copy, multiplying drift risk.

Consequences

Positive:

  • hurray-core is the single normative source for descriptor structure and addressing semantics.
  • hurray-inspect Layer 4 refactor drops its self-contained parser in one move.
  • hurray-ffi, hurray-python, and the array-DB engine get addressing at no extra dependency cost.
  • The trait creates a compiler-enforced coverage hook when new layouts are added.
  • Sparse/dense distinction is type-safe (two traits, different return types).

Obligations created:

  • hurray-core description in Cargo.toml SHOULD be updated to mention reference address computation.
  • Error enum gains IndexOutOfRange and IndexRankMismatch variants (additive, non-breaking).
  • // Spec: comment on every ElementAddress impl is a review-time obligation.
  • Conformance-table tests for Morton and Hilbert MUST be reproduced as unit tests.

Open Questions

  • OQ-014.1: Promote SparseElementAddress to pub after Layer 4 validation? Resolved. The SparseElementAddress trait / SparseBuffers shape was not promoted; it was removed. CSF (ADR-025) validated a simpler shape — a standalone public element_offset function per sparse layout, taking typed index-buffer slices and returning Ok(Some(offset)) or Ok(None) for a structural zero. COO, CSR, and CSC now follow that same shape (addressing::{coo,csr,csc}::element_offset), so sparse element lookup is uniform and public across all sparse layouts.
  • OQ-014.2: Should byte_address_from_element_offset return a single struct with bit_offset: 0 for whole-byte types, or a sum type? (Defer to first FFI consumer.)
  • OQ-014.3: Subpaving region lookup — are regions pre-sorted? Resolved. Writers SHOULD emit regions in lexicographic order of origin; readers MUST NOT rely on it. See docs/spec/layouts/subpaving.md § Region Order. The linear scan in SubpavingLayout::locate_element remains conformant; a binary-search fast-path is deferred to a benchmark-gated ADR (see // TODO(OQ-014.3) in addressing code).

Layer 4 Impact

The Layer 4 plan gains a prerequisite sub-task before the hurray-inspect refactor:

  1. hurray-core/src/layout/addressing/mod.rs — traits + byte_address_from_element_offset
  2. One addressing impl file per dense layout under hurray-core/src/layout/addressing/
  3. Sparse impls (coo.rs, csr.rs, csc.rs) as pub(crate)
  4. Unit tests reproducing Morton and Hilbert conformance tables from the spec
  5. Error enum additions
  6. Runnable example in hurray-core/examples/element_offset.rs and cookbook entry

Date

2026-05-05

Amendment 2026-05-06

Three Layer 4a planning questions resolved before implementation:

  • ElementAddress::element_offset takes &Shape. Revised signature: fn element_offset(&self, index: &[u64], shape: &Shape) -> Result<u64, Error>. Row-major and column-major carry no stride fields; every dense layout requires shape for either stride derivation or index-envelope validation. Index rank and bounds validation MUST happen inside the trait method, not in callers. LayoutDescriptor::element_offset adopts the same signature.

  • Strided returns u64 via two's-complement reinterpretation of an i64 sum. The trait return type stays u64; widening it to i64 would impose signed arithmetic on every non-strided layout. The strided impl MUST compute the sum in i64 with checked_mul / checked_add (overflow → Error::IndexOutOfRange or new Error::AddressOverflow), then cast signed_sum as u64. The final bounds check is centralised in byte_address_from_element_offset, which gains a buffer_size: u64 parameter and returns Err if the byte address falls outside [0, buffer_size). Sub-byte element-type bit arithmetic MUST be done on the signed i64 offset before reinterpretation (floor division semantics).

  • Subpaving does NOT implement ElementAddress. The trait models a single u64 offset against a single buffer; subpaving is fundamentally multi-buffer. Instead, SubpavingLayout exposes a pub inherent method:

    pub fn locate_element(&self, index: &[u64], shape: &Shape)
        -> Result<SubpavingLocation, Error>;
    
    pub struct SubpavingLocation {
        pub region_index: u32,
        pub buffer_index: u32,
        pub region_byte_offset: u64,
        pub region_element_offset: u64,  // fully resolved within the region's buffer
    }

    locate_element MUST recurse into the region's inner layout and return a fully-resolved element offset within the region's buffer. Region lookup uses a linear scan in v1 with an inline // TODO(OQ-014.3) comment; OQ-014.3 (region ordering) is routed to format-spec-writer in parallel and MUST NOT be resolved by implementation choice. Recursive subpaving is supported with an implementation-level depth limit (8 levels) returning Error::SubpavingNestingTooDeep — a non-normative safeguard. LayoutDescriptor::element_offset for the Subpaving variant returns Error::LayoutRequiresMultiBuffer { layout_tag: 0x06 } and its docstring redirects callers to SubpavingLayout::locate_element.

Error enum additions (all additive):

  • AddressOverflow — strided/byte-conversion arithmetic overflow
  • LayoutRequiresMultiBuffer { layout_tag: u8 } — Subpaving dispatch
  • SubpavingNestingTooDeep — recursion guard
  • DynamicDimInIndexing { dim: u32 } — DYNAMIC dimension cannot be addressed
  • IndexNotInAnyRegion { index: Vec<u64> } — subpaving miss
  • IndexArithmeticOverflow — Morton/Hilbert shift overflow
  • ByteAddressOverflow — byte address outside buffer bounds

ADR-015: Subpaving Region Inline Layout Encoding

Status

Accepted

Context

The general subpaving layout (0x06) partitions a tensor's index space into non-overlapping rectangular regions, each with its own inner layout. Each RegionDescriptor carries a region_layout_tag byte that identifies the inner layout, but the format left the encoding of layout-specific fields for that tag (e.g., strides for 0x03, tile dimensions for 0x04, Morton bits for 0x05, Hilbert order/rank for 0x40, or a nested region list for recursive 0x06) undefined.

This was filed as subpaving.md OQ-1. Without a normative encoding, the reference implementation could only handle row-major (0x01) and column-major (0x02) inner regions — both of which have no additional descriptor fields. The Layer 4 restriction note was added to subpaving.md and the addressing code pending resolution of this OQ.

Three options were considered:

(a) Reuse the top-level layout encoding inline, with a length prefix Add region_layout_length: uint32 immediately after region_byte_offset. The next region_layout_length bytes carry the layout-specific fields for region_layout_tag, using the same field encoding defined in metadata.md § Layout-Specific Fields for that tag — with the tag byte itself omitted (it is already present in region_layout_tag). Set region_layout_length = 0 for layouts that have no additional fields (row-major 0x01, column-major 0x02).

(b) Tag-specific inline structs without a length field Encode the layout-specific fields inline in a fixed, tag-defined order with no framing length. Readers must know the exact byte size for every tag they may encounter; unknown tags make the descriptor un-parsable without forward-skipping.

(c) A separate "inner layout table" referenced by index Add a parallel table of inner layout descriptors to the subpaving header, and have each RegionDescriptor carry an index into that table instead of inline fields. More compact when many regions share the same inner layout, but introduces a non-sequential encoding dependency and is harder to stream.

Decision

Use option (a): a region_layout_length: uint32 (little-endian) prefix immediately following region_byte_offset in every RegionDescriptor, followed by region_layout_length bytes of layout-specific fields using the same per-tag encoding defined in metadata.md § Layout-Specific Fields (tag byte omitted).

The RegionDescriptor binary layout becomes:

FieldTypeDescription
originuint64[rank]Starting index along each dimension (inclusive).
region_shapeuint64[rank]Size along each dimension. Every value MUST be > 0.
region_layout_taguint8Inner layout tag. MUST NOT be 0x00 or 0xFF.
_reserveduint8[3]MUST be 0x00.
buffer_indexuint32Index into the tensor's buffer table for this region.
region_byte_offsetuint64Byte offset to the start of this region's data in the buffer.
region_layout_lengthuint32Byte count of the inner layout payload that follows. MUST be 0 for region_layout_tag values 0x01 and 0x02.
region_layout_payloadbytes[region_layout_length]Layout-specific fields for region_layout_tag, encoded identically to metadata.md § Layout-Specific Fields for that tag, with the tag byte omitted.

An 8-level nesting depth limit applies: a reader MUST reject any descriptor where the total subpaving recursion depth exceeds 8. This limit matches the existing implementation constant MAX_SUBPAVING_DEPTH.

In the Rust reference implementation, RegionDescriptor gains a new field:

inner_layout: Option<Box<LayoutDescriptor>>

None for tags that carry no additional fields (0x01, 0x02); Some for all other tags, where the LayoutDescriptor variant must have tag() == region_layout_tag.

Alternatives Considered

Option (b) — tag-specific inline structs, no length field was rejected because it requires every reader to know the exact byte width of every possible inner layout tag. An unknown future tag would make the remaining RegionDescriptors un-parsable. The length prefix enables safe forward-skipping and is consistent with the length-prefixed approach used throughout the Hurray format.

Option (c) — inner layout table was rejected because it introduces a backward reference (each RegionDescriptor points into a table that precedes it in the byte stream only if the table is in the header), which conflicts with the format's streamability requirement. It also adds per-table parsing complexity for what is often a small number of distinct inner layouts.

Consequences

  • Every RegionDescriptor is 4 bytes larger due to the mandatory region_layout_length field. For the common case of row-major or col-major inner regions the payload length is 0, so the only overhead is the 4-byte length field itself.
  • True recursive subpaving (inner region_layout_tag = 0x06) is now encodable; the 8-level depth cap prevents unbounded recursion at parse time.
  • The subpaving.md spec must be updated: remove OQ-1, add region_layout_length and region_layout_payload to the RegionDescriptor table.
  • metadata.md § General Subpaving (0x06) must be updated with the same RegionDescriptor table change.
  • The RegionDescriptor::new() constructor signature is unchanged; a with_inner_layout() builder is added for the non-trivial case.
  • Layer 4 restriction in addressing/subpaving.rs is lifted; region_inner_offset now dispatches through inner_layout for all supported tags.
  • OQ-014.3 (region ordering/pre-sorting) remains open and is tracked separately.

ADR-016: Device Tag Assignments

Status

Accepted

Context

docs/spec/buffer-protocol.md § Device Tags currently assigns four named device tags (0x00 CPU, 0x01 CUDA, 0x02 ROCm, 0x03 Metal), reserves 0x04–0xEF for future specification versions, reserves 0xF0–0xFE for implementation-private device types, and treats 0xFF as permanently invalid.

The same tag space is reproduced in docs/spec/interchange.md § Device Tags (for transport convenience), in CLIENT_HELLO/SERVER_HELLO supported_devices lists, and in TENSOR_REQUEST.preferred_device.

Once a tag value is published, it is frozen: changing it is a breaking wire-format change. The reserved range provides 236 unallocated slots, so there is no slot pressure, but gratuitous proliferation creates a maintenance and conformance burden — every named tag is a device that compliant readers MUST be able to identify, even if they cannot execute on it.

DLPack — the dominant zero-copy tensor protocol that the Python binding layer (hurray-python) will bridge to via __dlpack__ / __dlpack_device__ — currently enumerates these additional device types beyond the four Hurray already names: kDLOpenCL, kDLVulkan, kDLVPI, kDLOneAPI, kDLWebGPU, kDLHexagon, kDLMAIA, kDLTrn. Hurray does not need to match DLPack's integers (the binding layer translates), but DLPack's coverage is a strong signal of which devices are used for cross-runtime tensor exchange today.

Decision

Named device tags (v1 amendment)

The following named tags are added to buffer-protocol.md § Device Tags. All other reserved values remain reserved.

ValueDevice
0x00CPU host memory
0x01CUDA device memory
0x02ROCm device memory
0x03Metal device memory (Apple Silicon unified memory)
0x04Vulkan device memory
0x05WebGPU device memory
0x06Qualcomm Hexagon (HVX/HMX) memory
0x07Intel Level Zero / oneAPI device memory
0x08OpenCL device memory
0x09–0xEFReserved for future specification versions
0xF0–0xFEImplementation-private device types
0xFFReserved (invalid)

Rationale for each addition:

  • 0x04 Vulkan — Cross-platform GPU compute on Android, desktop Linux/Windows. Used by llama.cpp (Vulkan backend), MNN, ncnn. Fully standardised memory model.
  • 0x05 WebGPU — Browser-based inference (ONNX Runtime Web, Transformers.js, WebLLM). W3C-standardised memory model. Growing rapidly; already in DLPack.
  • 0x06 Hexagon — Qualcomm DSP is the dominant on-device accelerator for Android inference. QNN SDK uses it heavily. Mobile inference is in scope for Hurray.
  • 0x07 Intel Level Zero / oneAPI — Intel Arc GPUs and Gaudi accelerators are in production inference deployments today. DLPack added kDLOneAPI in 2022. Without a named tag every implementation would converge on a private tag; promoting it later is more disruptive.
  • 0x08 OpenCL — Declining, but still present on embedded Arm/Intel hardware and in long-tail ML runtimes. Cheap to name now; promoting after implementations have settled on private tags is costly.

The numeric ordering is contiguous from the existing assignments and reflects rough priority (Vulkan, WebGPU first; Hexagon, Level Zero next; OpenCL last among named tags). The values intentionally do not match DLPack's integers. Translation occurs at the Python binding layer; binding implementors MUST consult docs/impl/python-bindings.md for the authoritative Hurray ↔ DLPack mapping table.

The exact memory model for each tag (allocation API, alignment requirements beyond the 64-byte minimum, synchronisation requirements, and DLPack mapping) MUST be documented in buffer-protocol.md before this ADR is considered fully realised. This ADR assigns the integers; the per-device prose subsection is an editorial follow-up routed to format-spec-writer.

Generic NPU tag — deferred

A "generic NPU" tag is not added. The label covers vastly different architectures (Apple ANE, Hexagon NPU subsystem, Intel NPU, Google Edge TPU, Rockchip NPU, Huawei Ascend, AWS Trainium, Microsoft Maia, etc.) with different memory models and synchronisation primitives. A reader that sees a generic NPU tag cannot dereference, transcode, or verify alignment without out-of-band knowledge — which is exactly what the 0xF0–0xFE private range already handles. Specific NPUs with broad cross-runtime adoption are promoted as named tags individually.

Future device tag policy (private-first promotion)

  1. Private slot first. A new device starts in the 0xF0–0xFE range. Implementations document their chosen private tag and exchange it only with peers that have agreed on the semantics out of band.
  2. Promotion criteria. A private tag MAY be proposed for promotion when:
    • At least two independent implementations (not just two bindings of the same library) use the device for tensor interchange.
    • The device's memory model is sufficiently specified that a non-Rust reader can implement allocation, alignment validation, and DLPack translation from the spec alone.
    • The device is in active production use, not a discontinued or research-only platform.
  3. Promotion process. A new ADR records the tag assignment and rationale; buffer-protocol.md and interchange.md are amended in lockstep; python-bindings.md is updated with the DLPack mapping.
  4. Tag values are forever. Once published, a tag MUST NOT be reassigned.
  5. No silent expansion. Adding a named tag is a minor-revision change. Readers compiled against a prior revision will reject the new tag; the supported_devices advertisement in CLIENT_HELLO/SERVER_HELLO ensures this is detected at session establishment, not mid-stream.

Alternatives Considered

Match DLPack's integers exactly. Rejected. Hurray's tag space is uint8 with a private range at 0xF0+; DLPack's enum is open-ended with non-contiguous integers. Forcing alignment would either waste Hurray slots or constrain future assignments. Translation at the Python binding layer is the correct boundary.

Add only WebGPU and Hexagon; defer everything else. Rejected as too conservative. The cost of naming Vulkan, OpenCL, and Intel Level Zero today is one byte each. Deferring forces implementations to use private tags that then need to be migrated.

Add a generic NPU tag. Rejected. The label is too coarse to be actionable without out-of-band information, which is exactly what the private range handles.

Widen device_tag from uint8 to uint16. Rejected. The buffer handle is a fixed 16-byte structure; widening requires a breaking re-layout. The 236-slot reserved range will not be exhausted within the v1 spec lifetime.

Consequences

  • buffer-protocol.md § Device Tags: table gains rows 0x04–0x08; reserved-future range narrows to 0x09–0xEF; a per-device prose subsection is authored by format-spec-writer.
  • interchange.md § Device Tags: reproduced table updated in lockstep.
  • docs/impl/python-bindings.md: Hurray ↔ DLPack mapping table added for all eight named tags (CPU, CUDA, ROCm, Metal, Vulkan, WebGPU, Hexagon, Level Zero, OpenCL).
  • hurray-core/src/buffer.rs: DeviceTag enum extended with five new variants at the assigned values; reserved-range validation narrows to 0x09–0xEF. Routed to rust-developer as part of the next implementation pass that touches buffer types.
  • Backward compatibility: producers using only 0x00–0x03 continue to work unchanged. Readers compiled before this amendment reject the new tags; supported_devices advertisement surfaces this at session establishment.
  • Devices explicitly deferred: NVIDIA VPI, Google TPU, AWS Trainium/Inferentia, Microsoft Maia, generic NPU. All eligible for private tags; revisit when cross-runtime adoption evidence emerges.
  • Memory-class sub-distinctions (CUDA Managed, CUDA Host, ROCm Host) are a separate design question and are deferred. Implementations MAY use private tags in the interim.

ADR-017: Extensibility as a Core Property

Status

Accepted

Context

docs/spec/README.md § Scope and Goals already includes the line "Be extensible without breaking existing readers", but the term is undefined. The mechanisms that implement extensibility are scattered across multiple sections:

  • Tag-space partitioning with a private extension range (0xF0–0xFE) for element types (element-types.md), layout tags (memory-layout.md), and device tags (buffer-protocol.md).
  • Reserved-for-future-spec ranges (e.g. 0x80–0xEF) that no implementation may consume.
  • Reserved flag bits in the descriptor header for future optional sections.
  • Length-prefixed sections that allow unknown trailing bytes to be skipped by older readers.
  • Independent version axes (descriptor, container, quantization scheme) per versioning.md, with a MAJOR / MINOR / PATCH change classification.
  • Permissive-mode parsing for unknown layout and quantization scheme tags.
  • ExtensionTypeDescriptor for custom numeric types within the extension tag range.

The risk of leaving extensibility implicit:

  • Future spec authors and implementors discover the extensibility contract by reverse-engineering the tag tables and version policy, and may inadvertently break it.
  • External implementors evaluating Hurray cannot quickly determine the extensibility posture.
  • The boundary between "private extensions are forever" and "private extensions are unsupported" is ambiguous.

The risk of making it explicit:

  • A named guarantee invites maximalist interpretations ("a v1 reader will accept anything I put in the descriptor").
  • It may be read as a commitment that every reserved range will eventually be filled, or that the spec will accommodate any feature a downstream user wants.
  • It locks the existing extension-point inventory into a contractual surface that cannot be reduced without a major version bump.

Decision

Hurray adopts extensibility as a named core property of the format and protocol. The property is defined precisely and narrowly to avoid over-promising.

What Hurray commits to

The format MUST provide the following extension points, and these extension points MUST remain stable across all minor versions of a given major version:

  1. Reserved tag ranges in the element-type, layout, device-tag, and quantization-scheme tag spaces. The spec MUST NOT repurpose, narrow, or remove any reserved range within a major version.
  2. Implementation-private tag ranges (0xF0–0xFE for element types, layouts, and device tags). These ranges MUST remain implementation-private for the lifetime of major version 1.x. The spec MUST NOT allocate any named value into a private range.
  3. Reserved flag bits in the descriptor header, file header, and per-section flag fields. Bits reserved at version 1.0 MUST remain available for backward-compatible feature gating throughout 1.x.
  4. Length-prefixed sections. Every variable-length section in the descriptor and file format MUST carry a length prefix that enables an older reader to skip unknown trailing content without rejecting the whole structure.
  5. Permissive-mode parsing for unknown layout tags and unknown quantization scheme tags. A reader MUST be able to parse the descriptor's shape and buffer table even when it cannot interpret the layout or scheme.
  6. Independent version axes. The descriptor, container, and per-quantization-scheme versions MUST evolve independently. Adding a new feature on one axis MUST NOT force a version bump on the others.
  7. A spec amendment process for named values. Any new public tag value (element type, layout, device, quantization scheme, KV value tag, or flag bit) MUST go through a spec amendment that increments the appropriate minor version. New named values are added by the spec, not by implementations.

What Hurray explicitly does NOT commit to

  1. No forward compatibility across major versions. A reader MUST reject data at a higher major version on the relevant axis.
  2. No commitment to interpret unknown content. Permissive mode allows the descriptor to be parsed; it does not allow the data buffer to be interpreted. A reader that does not understand a quantization scheme MUST NOT attempt to dequantize.
  3. No interoperability of private-range values. Tags in the private range MUST NOT be exchanged between independent implementations without out-of-band agreement.
  4. No runtime plugin or codec mechanism. The extension surface is a curated tag space, not a runtime-loadable codec pipeline. Adding a layout or scheme requires a spec amendment, not a registered plugin.
  5. No user-defined non-numeric element types. The extension range exists for new numeric encodings, not for arbitrary user types.
  6. No back-compatibility guarantee for pre-1.0 drafts. The extensibility guarantee begins at 1.0.

Where this is documented

  1. docs/spec/README.md § Scope and Goals — extensibility is added as a named bullet replacing the current "Be extensible without breaking existing readers" line, with a pointer to the "Extensibility Contract" section in versioning.md.
  2. docs/spec/versioning.md — a new "Extensibility Contract" section is added that enumerates the seven commitments and six non-commitments above in RFC 2119 normative language. It cross-references the per-tag-space rules already in element-types.md, memory-layout.md, buffer-protocol.md, and quantization.md rather than restating them.
  3. No new normative text is needed in the individual per-section spec files.

Alternatives Considered

Leave extensibility implicit. Continue relying on the one-line goal in README.md and per-section tag tables. Rejected because implementors discover the contract piecemeal, and the boundary between "private ranges are forever" and "private ranges are unsupported" remains ambiguous. The cost of naming it is one section in versioning.md.

Name it as a goal but add no normative text. Add the bullet to the README without a normative contract section. Rejected — a goal without normative backing is not a contract; spec-checker cannot verify compliance against an unstated rule.

Define extensibility as a runtime plugin contract (Zarr v3-style). Treat extensions as runtime-loadable codecs. Rejected — conflicts with zero-copy, language-agnostic, and streaming goals. Changes Hurray's category from "stable interchange format" to "extensible compute substrate."

Narrow the contract to reserved bits and tag ranges only, excluding permissive mode and independent version axes. Rejected — permissive mode and independent version axes are already normative; excluding them from the named property splits the extensibility surface across "named guarantees" and "unnamed guarantees" for no benefit.

Consequences

  • External evaluators find the extensibility posture in one place (versioning.md § Extensibility Contract).
  • Spec-checker audits gain a concrete normative anchor: every amendment that touches a reserved range, private range, length-prefixed section, or version axis is testable against the contract.
  • The non-commitments are explicit, providing documented responses to requests for runtime-loadable codecs, cross-vendor private tags, or user-defined element types.
  • The contract creates a documented expectation that reserved ranges remain reserved across 1.x. The spec MUST NOT recover bytes from a reserved range mid-major-version.
  • This decision is non-breaking: it documents existing behaviour and adds no new wire-format constraints.

Follow-up work

  1. format-spec-writer: edit docs/spec/README.md § Scope and Goals to replace "Be extensible without breaking existing readers" with the named extensibility bullet.
  2. format-spec-writer: add "Extensibility Contract" section to docs/spec/versioning.md (after § Compatibility Matrix, before § Writer Requirements) with the seven commitments and six non-commitments in RFC 2119 language, cross-referencing the per-section files.
  3. spec-checker: add a checklist item "Does this amendment preserve the Extensibility Contract?" to docs/SPEC_CHECKLIST.md once the section is in place.
  4. No code changes required in any hurray-* crate.

ADR-018: GPU Stream and Event Synchronisation at Buffer Handoff

Status

Proposed (revised)

Context

The buffer protocol (docs/spec/buffer-protocol.md) defines how buffer handles carry size, alignment, and device tag, and how ownership transfer is signalled via a release callback (ADR-009). It is silent on GPU stream and event synchronisation: when a producer hands off a CUDA/ROCm/Metal/Vulkan/Level-Zero/OpenCL/WebGPU buffer to a consumer, nothing in the spec guarantees that the producer's previously-enqueued device writes are visible when the consumer dereferences the buffer.

The interchange protocol (docs/spec/interchange.md) inherits the same gap. TENSOR_DATA_END is described as the authoritative "buffer is ready" signal for the RDMA path (the receiver "MUST NOT read from the transferred buffer before receiving TENSOR_DATA_END"), but for in-process and IPC handoff there is no such signal; for the RDMA GPUDirect path, the spec relies on the sender's RDMA completion queue to ensure the write retired before TENSOR_DATA_END is sent — which gives a producer-side fence but does not establish a consumer-side device-stream ordering relationship.

Concretely, three failure modes exist today:

  1. In-process CUDA handoff. Producer issues a kernel on stream A that writes the buffer, then hands the buffer to a consumer. Consumer enqueues a kernel on stream B that reads the buffer. Streams A and B are not ordered. The consumer's kernel may execute before the producer's kernel retires. Data race.
  2. IPC GPU handoff. Same as above, across processes, with a CUDA IPC handle. The handle carries no ordering information.
  3. Cross-machine GPUDirect. Sender's NIC has DMA-written into the receiver's GPU memory. Receiver's first read on a GPU stream may not observe the write without a device-side fence, depending on the RDMA provider and the GPU vendor's PCIe ordering guarantees.

DLPack issue #176 is the closest prior-art discussion. DLPack currently treats producer-side stream synchronisation as advisory; the issue debates four options (producer-MUST-sync, pass producer stream handle, pass event handle, sync-version field). The Python array API has converged on passing a consumer stream handle to __dlpack__(stream=...) so the producer can record an event on its own stream and make the consumer stream wait — pushing the cost onto the producer but localising it to a single stream-wait rather than a full stream sync.

The choice has direct implications for Hurray:

  • The buffer handle is a 16-byte fixed binary record (buffer-protocol.md § Buffer Handle). Three bytes at offsets 13–15 are currently reserved.
  • Whatever rule is chosen MUST be implementable by a non-Rust reader from the spec alone (interoperability invariant).
  • The rule MUST NOT close the door on additional device types (device_tag 0x09–0xEF reserved) whose synchronisation primitives may not yet exist or may differ structurally from CUDA events.
  • The rule MUST integrate cleanly with ADR-009: the release callback signals end-of-use, not start-of-use; the new synchronisation rule occupies the symmetric position at the start of consumer access.

Open questions resolved by this ADR:

  • [OQ-A] Producer-side requirement: nothing, sync stream, or record event? → Three tiers, producer's choice.
  • [OQ-B] Consumer-side requirement: nothing, sync stream, or wait on event? → Depends on producer's chosen tier, declared in the buffer handle.
  • [OQ-C] Wire-format change: extend buffer handle, extend interchange message, or neither? → Discriminant in the binary buffer handle (offset 13); handle in the C ABI out-of-band.
  • [OQ-D] Per-transport variation: should in-process, IPC, and cross-machine differ? → Yes; see § Per-transport rules.

Decision

Hurray defines a normative handoff-completed-before-handoff-observed rule, stated as a behavioural contract on the producer. The buffer handle binary layout is extended by one byte to carry a sync_mode discriminant (consuming one of the three existing reserved bytes); the synchronisation primitive itself (CUDA stream, CUDA event, hipEvent_t, MTLSharedEvent, VkSemaphore, etc.) is not carried in the binary descriptor and is exchanged out of band via the C ABI for in-process and IPC, and is subsumed by TENSOR_DATA_END for cross-machine.

The rule has three tiers, one per producer mechanism, and per-transport rules govern which tiers are valid on which transport.

1. Producer requirement (all transports)

A producer of a non-CPU buffer MUST ensure that, at the instant ownership of the buffer is transferred to the consumer, all device-side writes enqueued by the producer that affect the buffer's bytes have reached a point at which a properly-synchronised consumer access on the same device will observe them. The producer satisfies this requirement by exactly one of the following mechanisms; the choice is the producer's, and the choice MUST be declared in the buffer handle's sync_mode field (see §3):

  • (P1) SYNC_PRODUCER_SYNCED = 0x00. Producer issues a host-side wait on the device stream(s) that wrote the buffer, ensuring all preceding work has completed before handoff. Strongest guarantee, highest cost. This is the universal default and the only mode valid on cross-machine transports.
  • (P2) SYNC_EVENT = 0x01. Producer records a device event on the stream(s) that wrote the buffer and provides the event handle to the consumer through the C ABI. The producer-side stream is not blocked. Valid in-process; valid for IPC only when the device supports IPC-exportable events.
  • (P3) SYNC_CONSUMER_STREAM = 0x02. When the consumer has advertised its target stream at handoff time via the C ABI, the producer issues a device-side wait that orders the consumer's stream after the producer's writing stream(s). The producer-side stream is not blocked; no event handle crosses the boundary.

Values 0x03–0xFE are reserved for future specification versions. Value 0xFF is reserved (invalid). A reader MUST reject a buffer handle whose sync_mode is not one of the values defined for the format version it implements.

For CPU buffers (device_tag = 0x00) sync_mode MUST be SYNC_PRODUCER_SYNCED (0x00); the producer MUST still ensure that any concurrent host-side writes are sequenced before handoff using a host memory fence (a release-store or equivalent).

2. Consumer requirement (all transports)

A consumer that has received a non-CPU buffer MUST inspect the buffer handle's sync_mode field and apply the matching rule:

  • If sync_mode == SYNC_PRODUCER_SYNCED, the consumer MAY access the buffer immediately on any stream — the producer's host-side wait (or, for cross-machine, the producer-side fence preceding TENSOR_DATA_END) established a host-program-order point.
  • If sync_mode == SYNC_EVENT, the consumer MUST retrieve the producer's event handle from the C ABI handoff structure and MUST issue a device-stream-wait on it on every stream that will access the buffer, before enqueuing any work that touches it. The consumer MUST release the event handle exactly once via the event-release callback defined in §4.
  • If sync_mode == SYNC_CONSUMER_STREAM, the consumer MAY access the buffer on the stream(s) it declared at handoff, but MUST NOT access the buffer on any other stream until it has issued an inter-stream wait.

Because exactly one mode is declared per buffer handle, a consumer MUST NOT need to negotiate or guess the producer's chosen mechanism. A consumer that does not recognise the declared sync_mode value MUST reject the descriptor.

3. Wire format: sync_mode added to the buffer handle

The buffer handle binary layout in buffer-protocol.md § Buffer Handle is updated by reallocating one byte of the existing _reserved field to a sync_mode discriminant:

OffsetFieldTypeDescription
0byte_sizeuint64Size of the buffer in bytes (little-endian). 0 denotes an empty buffer.
8alignmentuint32Minimum alignment of the buffer's base address in bytes (little-endian).
12device_taguint8Device where this buffer resides.
13sync_modeuint8Producer-side synchronisation mechanism in effect. See §1.
14_reserveduint8[2]MUST be 0x00. Readers MUST reject a descriptor with non-zero reserved bytes.

Total size: 16 bytes. All multi-byte fields MUST be encoded in little-endian byte order.

The opaque event handle (for SYNC_EVENT) and the consumer stream handle (for SYNC_CONSUMER_STREAM) are not carried in the binary descriptor. They are exchanged out of band via the C ABI (see §4) because event and stream handles are pointers / opaque IDs valid only in the producer's driver context and have a transient lifetime that does not match the descriptor's. The sync_mode field is a declaration of a buffer's synchronisation properties; the synchronisation handle is a transport detail, not a buffer property.

Note (non-normative): hurray-inspect and any other static inspector can surface sync_mode from the descriptor alone, without reaching into the C ABI layer.

No synchronisation field is added to TENSOR_DESCRIPTOR, RDMA_REGISTER, RDMA_READY, or TENSOR_DATA_END. The buffer handle inside TENSOR_DESCRIPTOR already carries sync_mode, so the interchange protocol inherits it for free.

4. C ABI

The C ABI handoff structure carries the handle corresponding to the sync_mode declared in the buffer handle. docs/impl/c-ffi.md MUST define:

  • For SYNC_PRODUCER_SYNCED: no additional fields. The C ABI handoff carries only the buffer pointer and release callback.
  • For SYNC_EVENT: an opaque sync_handle pointer (device-vendor-specific event), a sync_handle_device_tag (which MUST equal the buffer's device_tag), and an event_release_fn callback the consumer MUST call exactly once after it has issued its stream-wait. The lifetime model mirrors ADR-009: a single normative release, thread-safe, with internal refcounting at the producer's discretion.
  • For SYNC_CONSUMER_STREAM: at handoff request time, the consumer supplies an opaque consumer_stream handle of type matching the buffer's device_tag; the producer issues the stream wait before returning the buffer handle.

The C ABI MUST validate that the handle provided at handoff time matches the sync_mode declared in the buffer handle. A mismatch is a producer-side bug that the ABI layer MUST reject before returning the handle to the consumer.

The C ABI no longer carries a sync_mode discriminant of its own — the descriptor is the single source of truth; the ABI provides only the payload for whichever mode is declared.

The exact C struct layout is the C ABI layer's concern; this ADR fixes only the abstract contract and the binary-descriptor field.

5. Relationship to the release callback (ADR-009)

The release callback and the synchronisation contract are independent and symmetric:

  • The synchronisation contract governs the start of consumer access (when is it safe to read?).
  • The release callback governs the end of consumer access (when does the producer learn the consumer is done?).

A consumer that has issued device work using the buffer MUST NOT call the release callback until that device work has completed on the device. The consumer is free to satisfy this by host-side waiting, by recording its own completion event and waiting on it, or by deferring the release callback to a completion callback registered on its stream. This is symmetric to the producer's obligation in §1 and is an existing implication of ADR-009's "consumer MUST NOT access the buffer after calling the release callback" rule; this ADR makes the implication explicit.

The event-release callback (for SYNC_EVENT mode) is separate from the buffer release callback. A consumer therefore makes two release calls in SYNC_EVENT mode: one for the event handle (called after the consumer has issued its stream-wait — typically immediately after handoff) and one for the buffer (called after all device work on the buffer is complete). Conflating the two would force the producer to keep the event alive for the buffer's entire lifetime, which defeats the purpose of using events instead of full stream sync.

6. Per-transport rules

In-process

All three sync_mode values are valid. Event handles in SYNC_EVENT are valid because both parties share the same device context. Stream handles in SYNC_CONSUMER_STREAM are valid for the same reason.

IPC (same machine, different processes)

  • SYNC_PRODUCER_SYNCED (P1) is always valid.
  • SYNC_EVENT (P2) is valid only if the device supports IPC-exportable events (CUDA cudaIpcEventHandle_t, ROCm hipIpcEventHandle_t). The C ABI MUST expose a query that tells a binding which sync_mode values are available for a given device_tag and IPC channel.
  • SYNC_CONSUMER_STREAM (P3) is valid only if the device supports IPC-exportable streams (not universally available).

A producer that cannot offer any valid mode for an IPC GPU buffer handoff MUST fall back to a host-staged copy. This degradation is not negotiated on the wire; it is the producer's local decision.

Cross-machine (network transport)

A producer MUST set sync_mode = SYNC_PRODUCER_SYNCED for every buffer handle transmitted over a network transport. SYNC_EVENT and SYNC_CONSUMER_STREAM are forbidden across machines because device event and stream handles are not valid in a different driver context on a different host. A receiver MUST reject a cross-machine TENSOR_DESCRIPTOR whose buffer handle declares any other mode.

The existing TENSOR_DATA_END message is the authoritative producer-synced signal:

  • For the non-RDMA path (TENSOR_DATA frames), the sender MUST ensure all device-side writes to the source buffer are visible to host reads before sending the first TENSOR_DATA frame whose byte_offset_in_buffer covers those bytes.
  • For the RDMA path without GPUDirect, the sender MUST ensure source-buffer writes have retired on the device before posting the RDMA send; the receiver MUST NOT read the buffer before TENSOR_DATA_END.
  • For the RDMA path with GPUDirect (ADR-012), the sender MUST ensure: (a) producer-side device writes to the source buffer have retired before posting the RDMA send, and (b) the RDMA completion has been observed via the sender's completion queue before sending TENSOR_DATA_END. The receiver, upon receiving TENSOR_DATA_END, MAY assume the destination GPU buffer is visible to subsequent device-stream reads on the receiver's GPU, provided the receiver is using a CUDA/ROCm/etc. driver version that establishes PCIe-write-ordering between an inbound NIC DMA and a subsequent kernel launch on the same device — the spec MUST state this as a receiver-side responsibility, not a producer-side one, because it depends entirely on the receiver's hardware topology.

TENSOR_DATA_END is the cross-machine equivalent of SYNC_PRODUCER_SYNCED.

7. Versioning

Because Hurray has not yet shipped a 1.0 wire format and has no external users, the buffer handle layout change lands in the still-draft 1.0 spec without a version bump; no migration path is required.

Forward extensibility:

  • New sync_mode values MAY be added in future format revisions by allocating from the reserved range 0x03–0xFE. Adding a value is a wire-incompatible change (a v1 reader that does not understand the new value MUST reject the descriptor) and therefore requires a format version bump.
  • A future device type added in device_tag 0x09–0xEF whose synchronisation model does not fit any of P1/P2/P3 MUST motivate either a new sync_mode value or a separate extension ADR. P1 remains the universal fallback because every device supports host-side wait.

The C ABI version MUST be bumped to expose the sync_handle / event_release_fn / consumer_stream payloads defined in §4.

Alternatives Considered

Alternative A — Normative "producer MUST sync" rule, no negotiation

Mandate SYNC_PRODUCER_SYNCED always; the producer MUST host-wait on its device stream before handoff. Simplest spec, simplest consumer.

  • Pros: zero ABI surface, zero negotiation, trivially correct, matches what most CPU code expects of GPU buffers.
  • Cons: full stream synchronisation is the single most expensive thing one can do on a CUDA stream; it serialises the entire inference pipeline. For the inference workloads Hurray targets, this collapses pipeline parallelism.
  • Rejected because: the cost is unacceptable for the primary use case. Hurray's value proposition is zero-copy zero-stall handoff; mandating a full stream sync defeats both halves.

Alternative B — Carry both the discriminant AND a sync-object handle in the buffer handle binary layout

Burn 8 bytes of _reserved (or extend the buffer handle from 16 to 24 bytes) for an opaque sync-object handle, in addition to the discriminant.

  • Pros: fully self-describing on the wire; no out-of-band C ABI handle needed for in-process use.
  • Cons: (1) event and stream handles are not portable across processes (need IPC export) or machines (meaningless); the same field would carry incompatible payloads or be null across transports. (2) Bakes a pointer-shaped value into a format that is otherwise pointer-free, breaking the rule that the binary descriptor is a declaration of properties, not a transport detail. (3) The handle has a lifetime that does not match the descriptor's — the descriptor may be inspected long after the event has been released, leaving a dangling pointer encoded in the format. (4) Closes the door on future devices whose synchronisation primitives do not fit a single 8-byte handle (e.g., a 16-byte UUID, or a handle + context pair).
  • Rejected because: handles are transport-layer state, not format-layer state. The discriminant belongs in the format; the handle does not. See Alternative F for the chosen hybrid.

Alternative C — Add a new BUFFER_SYNC interchange message

Define a new 0x0000000E BUFFER_SYNC message carrying a sync-object payload, sent after TENSOR_DESCRIPTOR and before TENSOR_DATA_END.

  • Pros: clean separation; opt-in via capability flag; does not pollute the buffer handle.
  • Cons: meaningless for in-process and IPC (which do not use the wire framing for handoff at all) and meaningless for cross-machine (where event handles do not cross host boundaries).
  • Rejected because: solves a wire-format problem where the problem is not wire-format.

Alternative D — Defer entirely, leave to bindings

Make no normative statement; each binding defines its own rule.

  • Pros: zero spec surface.
  • Cons: directly violates the language-agnostic interoperability invariant. Two implementations both claiming Hurray 1.0 compliance could fail to share GPU buffers safely.
  • Rejected because: synchronisation is the single most common source of silent data corruption in GPU code. Leaving it implementation-defined is a guarantee of interoperability failures.

Alternative E — DLPack-style: consumer passes a stream at request time, producer waits on it

Adopt only SYNC_CONSUMER_STREAM (P3); forbid P1 and P2.

  • Pros: matches the Python array API; pushes the wait onto the producer's device side (cheap) rather than the host side (expensive); no event handle lifetime to manage.
  • Cons: forces every consumer to have a designated stream before requesting a buffer. Some consumers (CLI inspectors, disk writers) have no GPU stream; they would have to construct one purely to satisfy the protocol. Also fails the IPC case where stream handles are not shareable across drivers without IPC-stream support.
  • Rejected because: too restrictive. P3 is offered as one option among three; mandating it loses the host-side use case and the cross-driver IPC use case.

Alternative F (chosen) — Hybrid: sync_mode discriminant in the binary descriptor, sync handle in the C ABI

Add a single uint8 sync_mode field at offset 13 of the buffer handle (consuming one of the three existing reserved bytes, leaving two), and keep the actual sync object handle (event pointer, stream pointer) in the C ABI handoff structure.

  • Pros:
    • Self-describing at the binary level: a static inspector (hurray-inspect) can show the synchronisation contract for any buffer without reaching into the C ABI.
    • Zero byte cost: uses an already-reserved byte; the buffer handle stays 16 bytes; CPU buffers naturally encode sync_mode = SYNC_PRODUCER_SYNCED = 0x00, which is the natural zero value.
    • No device-specific primitives baked into the format: the field carries a discriminant, not a vendor-specific handle. Future devices can allocate a new sync_mode value without restructuring the descriptor.
    • Transports stay clean: across machines, the discriminant is SYNC_PRODUCER_SYNCED and no handle is needed; in-process and IPC carry the handle out of band in the C ABI.
    • C ABI simplifies: the discriminant moves from the C ABI handoff struct into the descriptor, leaving the C ABI to carry only the handle payload matching the declared mode.
    • Forward-compatible: two reserved bytes remain at offsets 14–15 for future use.
  • Cons:
    • Wire-incompatible with any earlier draft that assumed offset 13 was reserved. Acceptable because Hurray 1.0 has not shipped and has no external users.
    • The discriminant and the handle live in two different places; the C ABI layer MUST cross-check them at handoff time.
  • Accepted because: it captures the discoverability benefit of Alternative B without inheriting its cost (a non-portable pointer baked into the format). The user's explicit removal of backward-compatibility pressure is what made this option reachable.

Consequences

Positive

  • The buffer handle gains a sync_mode discriminant in a byte previously reserved; the handle stays 16 bytes; CPU-only workloads naturally encode SYNC_PRODUCER_SYNCED = 0x00 and are unaffected.
  • The producer-consumer synchronisation contract is normative, closing a silent-data-corruption gap before 1.0 freeze.
  • All three sync mechanisms are available; producers and consumers can choose the cheapest valid option for their workload.
  • Cross-machine GPUDirect inherits TENSOR_DATA_END as the producer-synced signal — no new message types, no new fields, full alignment with ADR-012.
  • Event-release callback is symmetric with the buffer-release callback (ADR-009), so the C ABI gains one mechanism shaped like one the bindings already implement.
  • hurray-inspect can surface sync_mode without reaching into the C ABI.

Negative

  • The buffer handle layout is updated in-draft (offset 13 moves from _reserved to sync_mode). Must land before 1.0 freeze; readers MUST validate sync_mode.
  • The C ABI surface grows: per-mode payloads and an event-release callback are added. The C ABI version MUST be bumped.
  • Bindings MUST implement three code paths for the three sync modes.
  • IPC GPU handoff requires producers to know whether the target device supports exportable events.
  • Two-callback handoff (event-release + buffer-release) is more error-prone than one; binding authors MUST be guided clearly.

Risks

  • A producer using SYNC_EVENT that fails to record the event before handoff could cause a deadlock. Mitigation: producers MUST record the event before calling the handoff function; the C ABI contract MUST state this.
  • A future device type (≥ 0x09) may have a synchronisation model that fits none of P1/P2/P3. Mitigation: P1 is universal; a fourth sync_mode can be added in a later ABI version.
  • The receiver-side PCIe ordering assertion for GPUDirect is hardware-dependent. Must be stated as a receiver responsibility and documented in c-ffi.md or a hardware-compatibility note.

Compatibility impact

  • Wire format: buffer handle layout updated in-draft. No backward-compatibility hazard — Hurray 1.0 has not shipped.
  • C ABI: requires a version bump. The default sync_mode for legacy clients MUST be SYNC_PRODUCER_SYNCED, preserving safe-but-slow behaviour for pre-ADR consumers.
  • Forward compatibility: a future ADR MAY add sync_mode values without affecting existing values.

Handoff

  • format-spec-writer: update buffer-protocol.md § Buffer Handle table — split offset 13 from _reserved to sync_mode (uint8); shrink _reserved to uint8[2] at offsets 14–15. Add a new normative subsection § Stream and Event Synchronisation covering §1, §2, §5, §6 of this decision, including the sync_mode enumeration (SYNC_PRODUCER_SYNCED = 0x00, SYNC_EVENT = 0x01, SYNC_CONSUMER_STREAM = 0x02, reserved 0x03–0xFE, invalid 0xFF). Add a cross-reference in interchange.md § RDMA Data Plane and § Streaming: Tensor Descriptor and Data Frames stating that cross-machine senders MUST set sync_mode = SYNC_PRODUCER_SYNCED and that TENSOR_DATA_END is the cross-machine producer-synced signal.
  • format-spec-writer: update docs/impl/c-ffi.md to define the per-mode handoff payloads (sync_handle + event_release_fn for SYNC_EVENT; consumer_stream for SYNC_CONSUMER_STREAM) and the ABI-side cross-check against the descriptor's sync_mode. The C ABI no longer carries a sync_mode discriminant of its own. Bump the documented C ABI version.
  • rust-developer: hurray-core's BufferHandle gains a sync_mode: SyncMode field at byte offset 13. Add a SyncMode enum with three named values plus a Reserved(u8) carrier for forward-compat rejection (decoders reject reserved values; producers cannot construct them). The hurray-inspect refactor onto hurray-core (already scheduled for Layer 4) MUST surface sync_mode in the per-buffer view.
  • architect: revisit if a device type added in the reserved range cannot express its synchronisation model via P1/P2/P3; that would prompt an extension ADR rather than amending this one.
  • spec-checker: audit the new buffer-protocol subsection and the interchange cross-references for RFC 2119 correctness and consistency with ADR-009 (release callback) and ADR-012 (GPUDirect RDMA) once the spec edits land. Verify that sync_mode = SYNC_PRODUCER_SYNCED is stated normatively as the constraint for both CPU buffers and cross-machine transports.

Open Questions Deferred

  • Per-direction GPUDirect sync semantics for asymmetric NIC-GPU topologies (deferred with ADR-012's per-direction RDMA_GPUDIRECT flag).
  • Whether SYNC_CONSUMER_STREAM can be carried over IPC for drivers that support stream IPC export (CUDA's cuStreamGetCtx is not exportable; ROCm has experimental support). Resolution depends on driver capability evolution; not 1.0 blocking.

Date

2026-05-12

ADR-019: Format Evolvability as a Core Property

Status

Proposed

Context

ADR-017 named extensibility as a core property of Hurray and codified the stable extension surface (reserved tag ranges, private ranges, reserved flag bits, length-prefixed sections, permissive-mode parsing, independent version axes, spec-amendment process). Extensibility — also called evolvability, modifiability, or plasticity depending on the literature — is the single property of a format that makes it easy for engineers to change it in the future, adapting it for unanticipated use cases as requirements change. ADR-017 defined the extension surface; it did not formally specify the operational rules for using that surface: how backward and forward compatibility are achieved, what a reader is obliged to do when it meets data written by a newer minor version, how tags are added, and how an enum variant is safely deprecated and removed.

The mechanics required for safe evolution are largely already in place:

  • Magic bytes (HRRY, HRRYFILE).
  • Three independent version axes (descriptor (major, minor), container (major, minor), per-scheme scheme_version).
  • descriptor_length in bytes 6–9 (self-delimiting from the first 10 bytes).
  • Length prefixes on every optional section, gated by a flag bit.
  • Reserved tag ranges and reserved flag bits (MUST be zero until allocated).
  • Permissive-mode parsing for unknown layout and quantization scheme tags.
  • A change-classification table (MAJOR / MINOR / PATCH) and compatibility matrix in versioning.md.

A focused review against the evolution rules of Protobuf, Avro, Thrift, FlatBuffers, and the DDIA Chapter 4 framework surfaced seven concrete gaps:

  1. Compatibility direction is never named with industry-standard vocabulary. The mechanics implement backward compatibility within 1.x and a particular shape of forward compatibility, but the spec never uses those words.
  2. Forward compatibility is asymmetric and undocumented. Inside an existing length-prefixed section, an old reader silently skips trailing bytes added in a later minor version (forward-compatible). For a new optional section gated by a new flag bit, an old reader MUST reject (not forward-compatible). The asymmetry is correct, but it is not explained.
  3. No defaults table for newly appended trailing fields. If 1.1 appends a field to an existing section, the spec does not say what value a 1.0 reader conceptually sees for that field.
  4. No anti-rebind rule. Nothing prevents a future minor revision from reusing the same tag-byte value for a different meaning after the original is deprecated. Protobuf's reserved keyword closes this gap explicitly.
  5. No migration-spec commitment. When 2.0 ships, the spec does not promise a normative migration path from 1.x.
  6. No deprecation convention. There is no wire signal or spec convention for "this tag value still works but writers SHOULD NOT emit it."
  7. No spec-writer guard rail preventing a future minor amendment from allocating a new field at a fixed offset that an older reader would mis-parse (instead of properly gating it behind a flag bit or length-prefixed section).

The research also confirmed two design choices that are NOT appropriate for Hurray:

  • Protobuf per-field tagging imposes per-field tag/length-type overhead on every read, defeating fixed-offset zero-copy.
  • FlatBuffers vtables add an indirection table per object that breaks the "read the next field at a known offset" property required by streaming readers.

Hurray's existing flag-bit + length-prefix model is the correct analogue for a zero-copy, fixed-offset format and must remain the sole evolution mechanism for the optional surface of the descriptor and container.

This ADR closes the seven gaps above by naming the compatibility direction, adopting normative writer/reader/spec rules, and elevating evolvability to a named core property alongside extensibility. It introduces no new wire-format constraints; it documents and disciplines the existing one.

Decision

Hurray adopts format evolvability as Core Property #4. Evolvability — the same concept as extensibility, modifiability, or plasticity — is the property of a format that makes it easy for engineers to change it in the future. This ADR formally specifies the rules that make that property concrete: four compatibility direction declarations, two normative writer rules, two normative reader rules, six normative spec-amendment rules, and an explicit rejection of per-field tagging and vtables.

CD — Compatibility direction (named and bounded)

Within major version 1.x, on each of the three axes (descriptor, container, per-scheme):

  • CD1 — BACKWARD compatible within a major. A reader at minor M MUST correctly parse data written at any minor N ∈ {0, …, M}. This is the existing behaviour; CD1 names it.
  • CD2 — FORWARD_ADDITIVE within a major. A reader at minor M reading data written at minor N > M MUST correctly parse every field defined at minor M (fixed header up to M, buffer table, sections gated by flag bits defined at M, and trailing bytes of those sections up to the prefix length). The reader MUST reject the data when it encounters any flag bit or public tag value not defined at minor M, subject only to the permissive-mode exceptions defined in quantization.md § Descriptor Header and memory-layout.md § Layout Tag Space.
  • CD3 — No forward compatibility across major versions. A reader supporting major K MUST reject data whose declared major version on the relevant axis is K + 1 or higher.
  • CD4 — No automatic backward-transitive compatibility across major versions. A reader supporting major K + 1 is not required to read major-K data directly. Cross-major reading is supported only via the migration specification described in S5.

The CD2 name FORWARD_ADDITIVE is normative. It is defined as: a reader correctly parses every part of newer-minor data that its own minor version defines, ignores trailing additive content inside known length-prefixed sections, and rejects newer-minor data that uses any feature gate (flag bit or public tag value) not defined at the reader's minor. It is deliberately a stricter property than Protobuf's "ignore unknown fields" forward compatibility, because Hurray's zero-copy fixed-offset model cannot safely skip unknown gated sections whose semantics may affect the data buffer.

W — Writer rules (new, normative)

  • W3. A writer MUST NOT emit a deprecated public tag value or a deprecated flag bit when a non-deprecated equivalent exists.
  • W4. When a writer appends an optional trailing field to an existing length-prefixed section under a version_minor increment, the writer MUST emit that field at the documented offset for that section and MUST update the enclosing length prefix accordingly. A writer MUST NOT emit a partial trailing field.

R — Reader rules (new, normative)

  • R3. A deprecated public tag value MUST be treated as semantically equivalent to its non-deprecated definition. Deprecation MUST NOT change a value's wire semantics; deprecation only signals "writers SHOULD prefer the replacement."
  • R4. When a reader at minor M encounters a length-prefixed section whose encoded length is shorter than the length defined for minor M (because the data was written at minor N < M), the reader MUST treat every field beyond the data's section length as carrying its documented default for minor M. The defaults table is normative and MUST be maintained per S4.

S — Spec amendment rules (new, normative)

  • S1. A new public tag value MUST be allocated from the documented public reserved range of its tag space (element type, layout, device, quantization scheme, KV value, or named flag bit). It MUST NOT be allocated from an implementation-private range.
  • S2 — Anti-rebind. An allocated public tag value MUST NOT be rebound to a different meaning within the same major version, even after it has been marked deprecated. Once allocated, a tag-byte value's meaning is fixed for the lifetime of 1.x.
  • S3 — Deprecation convention. When a public tag value or named flag bit is deprecated, the relevant tag table in the spec MUST mark it deprecated since 1.N and SHOULD include a pointer to its replacement. Deprecation is a writer-facing signal only; deprecation MUST NOT change reader behaviour (per R3).
  • S4 — Defaults for appended trailing fields. Any new optional trailing field appended to an existing section under a minor bump MUST be accompanied by a normatively documented default in the same minor revision. The default is what a reader at the prior minor conceptually sees for that field, per R4. Defaults MUST be expressible without reference to other fields in the same descriptor unless that dependency is documented.
  • S5 — Major-version migration commitment. A future major version (e.g., descriptor 2.0, container 2.0) MUST be accompanied by a normative migration specification mapping the prior major version's encoding to the new one for every tensor it can represent. The migration spec is normative for tools but does not impose a runtime obligation on a 2.x reader to consume 1.x data.
  • S6 — No fixed-offset additions in minor revisions. A new field added under a minor bump MUST be gated by a flag bit (for a new section), by a tag value (for a new variant), or by a length-prefixed trailing extension of an existing section. A minor amendment MUST NOT allocate a new field at a fixed offset that an older reader would parse as part of an existing structure.

Anti-patterns explicitly rejected

The following evolution mechanisms are incompatible with Hurray's zero-copy fixed-offset access model and MUST NOT be adopted within major version 1.x:

  • Per-field tagging (Protobuf-style). Every read would require scanning a tag/length/type sequence to locate the next field, defeating the property that a reader can compute the offset of any field from a small set of inputs (rank, layout tag, flag bits).
  • vtables (FlatBuffers-style). Each tensor descriptor would carry a per-object virtual table indirecting every field access, losing single-pass streamability and the self-delimiting property.

Hurray's evolvability surface is the flag-bit + length-prefix model:

  • New optional content is gated by a flag bit and length-prefixed. Old readers that don't know the bit reject; old readers that don't enter the section can skip it via the length prefix.
  • Additive growth of an existing section happens by appending trailing bytes under a minor bump (W4 + R4 + S4 + S6). Old readers see defaults for the appended fields; new readers parse them.
  • Tag spaces grow only within reserved ranges, never by rebinding (S1 + S2).

Resolutions of the open questions

  • OQ-A (spec fingerprint KV entry). Deferred, non-blocking. A future hurray.spec_fingerprint KV entry MAY be defined in file-format.md § KV Value Types for archival forensics. Not part of this ADR's normative scope.
  • OQ-B (descriptor header reserved bytes for compat_flags). Rejected on premise. Bytes 6–9 of the descriptor header are descriptor_length (uint32), not reserved. Future compatibility-flag bits MUST be allocated from the reserved bits of the descriptor flags field (currently bits 4–31), which is the correct architectural channel. A minor amendment MUST NOT allocate a new byte at a fixed offset per S6.
  • OQ-C (name for selective forward compatibility). Resolved as FORWARD_ADDITIVE. See CD2.
  • OQ-D (worked example placement). Resolved in two parts. One worked example is added inline in versioning.md (the MXFP scheme_version scenario). A full evolution playbook with multiple scenarios is deferred to docs/cookbook/evolution-playbook.md as a Layer 5+ doc-updater deliverable.

New Core Property #4

The following paragraph is inserted into README.md as Core Property #4, with the existing properties #4–#11 renumbered to #5–#12:

4. Format Evolvability

Hurray is designed to evolve. Within major version 1.x, the format is BACKWARD-compatible: a reader at minor M correctly parses data written at any minor N ≤ M. The format is also FORWARD_ADDITIVE: a reader at minor M correctly parses every part of newer-minor data that its own minor version defines, ignores additive trailing bytes inside known length-prefixed sections, and rejects newer-minor data that relies on an unknown flag bit or tag value. Public tag values are never rebound once allocated; deprecated tags retain their original semantics forever within 1.x. A future major version is accompanied by a normative migration specification. Per-field tagging (Protobuf) and vtables (FlatBuffers) are explicitly rejected as incompatible with Hurray's zero-copy fixed-offset access model. See Versioning § Evolvability Contract for the full normative definition.

Alternatives Considered

Leave the compatibility direction unnamed. Rejected: implementors comparing Hurray to Protobuf/Avro/FlatBuffers cannot match the property to a familiar label, and spec-checker has no anchor for auditing compatibility direction. Naming costs almost nothing.

Adopt full forward compatibility (Protobuf-style) by silently ignoring unknown gated sections. Replace CD2 with "a reader MUST skip any unknown flag-gated section using its length prefix." Rejected: an unknown flag bit signals that the writer relied on a feature whose semantics are unknowable to the reader. Silently dropping the feature could allow a reader to parse a tensor's shape while mis-interpreting its data buffer if the unknown section changes how byte_offset or sync_mode is interpreted. FORWARD_ADDITIVE captures exactly the cases where skipping is genuinely safe (trailing bytes inside an already-understood section).

Adopt per-field tags or vtables to gain Protobuf/FlatBuffers-style evolvability. Rejected on architectural grounds (see § Anti-patterns rejected).

Defer S5 (major-version migration commitment) to the actual 2.0 planning cycle. Rejected: the value of S5 is the promise made before any user bets a workflow on 1.x archives. Deferring it weakens the evolvability property at the exact point users need it most.

Encode deprecation as a wire-level flag bit. Rejected: deprecation is a writer-facing recommendation, not a reader-facing state. R3 mandates identical wire semantics for deprecated and non-deprecated tags, so a wire flag would carry no information for the reader. A spec-table annotation (S3) is the right channel.

Allow tag rebind after a grace period. Rejected: a tag value's meaning being stable for the lifetime of 1.x is more valuable than recovering byte space. Tag spaces have large reserved ranges; exhaustion within 1.x is not a realistic concern.

Consequences

Positive

  • The compatibility posture of Hurray is expressible in a single sentence per axis ("BACKWARD within 1.x, FORWARD_ADDITIVE within 1.x, no cross-major automatic compat, migration via S5").
  • Spec amendments have a normative checklist (S1–S6) that spec-checker can apply mechanically.
  • Long-lived archives are protected by the anti-rebind rule (S2) and the migration commitment (S5).
  • The deprecation convention (S3 + R3 + W3) lets the spec retire wire forms cleanly without breaking readers.

Negative / obligations created

  • Every future minor amendment MUST document a default for any appended trailing field (S4). New editorial obligation on format-spec-writer.
  • S6 constrains spec authors: a future minor revision MUST gate new content behind flag bits, tag values, or length-prefixed trailing extensions.
  • S5 binds the project to writing a migration specification before any future major bump can be Accepted.
  • A docs/cookbook/evolution-playbook.md deliverable is created for Layer 5+.

Risks

  • FORWARD_ADDITIVE misread as "Hurray is forward-compatible." Mitigation: the name explicitly contains ADDITIVE and versioning.md § Evolvability Contract explains the asymmetry with a worked example.
  • Defaults table becoming inconsistent. Mitigation: S4 mandates the default appears in the same minor revision that introduces the trailing field; spec-checker gains a corresponding checklist item.
  • S2 misread as forbidding tag reuse across majors. It is not. S2 binds 1.x only; a 2.0 migration spec MAY remap tag values entirely.

Compatibility impact

This ADR introduces no new wire-format constraints and no new fields. It documents and disciplines existing behaviour and adds editorial rules for future spec amendments. W3 and W4 constrain future writers; they do not invalidate any existing writer's output, because no deprecated tags exist yet and no trailing fields have been appended.

Handoff

  • format-spec-writer: add § Evolvability Contract to versioning.md (sub-sections: Compatibility Direction, Writer Evolution Rules, Reader Evolution Rules, Spec Amendment Rules, Defaults for Appended Trailing Fields, Anti-Patterns, worked MXFP example). Add cross-reference in § Change Classification MINOR row to S6. Add § Core Property 12 to README.md. Update docs/SPEC_CHECKLIST.md preamble (13→14 categories, 11→12 Core Properties) and insert new § 12 Format Evolvability category with the 6 checklist items listed below.
  • spec-checker: the new § 12 checklist items are: (1) appended trailing fields have documented defaults (S4); (2) new public tags allocated from reserved ranges (S1); (3) no allocated tag rebound (S2); (4) deprecated tags marked in their table (S3); (5) new fields gated, not at fixed offsets (S6); (6) FORWARD_ADDITIVE preserved for prior-minor readers (CD2).
  • doc-updater (Layer 5+): create docs/cookbook/evolution-playbook.md with four worked scenarios (new flag-gated section, appended trailing field, new KV value tag, cross-major migration outline).

Date

2026-05-14

ADR-020: Memory Class Field in the Buffer Handle

Status

Draft

Context

The buffer handle (defined in docs/spec/buffer-protocol.md) carries a device_tag field that identifies where a buffer resides (CPU, CUDA, ROCm, Metal, etc.). This encodes a single dimension of buffer identity: the allocator or hardware domain.

A second, orthogonal dimension is left unrepresented: how a buffer is accessible — specifically, whether it can be read without copying by more than one compute unit simultaneously. Modern hardware offers at least three meaningfully distinct access classes beyond device-exclusive memory:

  1. Host-pinned — CPU RAM page-locked for GPU DMA. The CPU can read it at native speed; the GPU can access it over PCIe/interconnect without a copy, but at reduced bandwidth (no device-local caching). Example: cudaMallocHost, hipHostMalloc, CL_MEM_ALLOC_HOST_PTR.

  2. Unified / managed — A single allocation coherently accessible by both the CPU and one or more accelerators, with hardware-managed page migration or physical sharing. Examples: cudaMallocManaged, ROCm HMM (hipMallocManaged on supported hardware), Metal MTLStorageModeShared on Apple Silicon.

  3. Peer-to-peer (P2P) — A device-local buffer made directly accessible to a specific set of peer accelerators (not the CPU) via NVLink, xGMI, or PCIe BAR mapping. Examples: CUDA P2P (cudaDeviceEnablePeerAccess), ROCm P2P.

Under the current model, a consumer receiving a CUDA buffer handle cannot distinguish cudaMalloc (GPU-exclusive VRAM) from cudaMallocManaged (CPU+GPU) from cudaMallocHost (CPU-accessible, GPU-mapped). All three carry device_tag = 0x01. The consumer must therefore either copy unconditionally or agree out-of-band — which defeats the purpose of a self-describing handle.

ADR-016 explicitly deferred "Memory-class sub-distinctions (CUDA Managed, CUDA Host, ROCm Host)" as a follow-up design question. This ADR resolves it.

DLPack already encodes this as separate device type values: kDLCUDAManaged (13), kDLCUDAHost (3), kDLROCMHost (11). Hurray previously chose not to mirror DLPack's integers (ADR-016 rationale), but the translation cost is low and bounded to the Python bindings layer. The question is whether to follow DLPack's flat-enum approach (Option 2) or to factor the concept into a separate field (this ADR, Option 3).

Why not Option 2 (new device tags per access mode)?

Adding CudaManaged, CudaHost, RocmHost, RocmManaged, etc. as separate tags encodes the same information but hides the structure. Every new accelerator needs 2–4 tag variants; the tag space grows quadratically with the number of device types and access modes. It also obscures that the distinction is conceptually orthogonal to device identity: a consumer that doesn't care about access mode must enumerate all variants for each device type it supports.

Why not Option 1 (a single "unified" flag bit)?

A single boolean cannot model the three-way distinction above. Host-pinned and Unified have different performance and coherency semantics: pinned memory has no hardware-managed coherency; unified/managed memory does. A consumer choosing between a GPU kernel and a CPU-path fast route needs to distinguish them.

Decision

Wire format change

Repurpose byte 14 of the 16-byte buffer handle — currently _reserved[0] — as a new memory_class field. Byte 15 remains _reserved (MUST be 0x00; readers MUST reject non-zero values).

Updated buffer handle layout:

OffsetFieldTypeDescription
0byte_sizeuint64Size of the buffer in bytes (little-endian).
8alignmentuint32Minimum alignment in bytes (little-endian).
12device_taguint8Device where this buffer resides.
13sync_modeuint8Producer-side synchronisation mechanism.
14memory_classuint8Memory access class. See § Memory Class Values.
15_reserveduint8MUST be 0x00.

The total handle size remains 16 bytes. No existing field is displaced.

Memory class values

ValueNameSemantics
0x00STANDARDDevice-exclusive memory. Only the primary compute unit of the tagged device can access this buffer without a copy. Default for all device types. CPU buffers (device_tag = 0x00) with this class are standard heap allocations.
0x01HOST_PINNEDCPU-accessible, device-mapped. The CPU can read and write at native cache speed. The device can access it over its interconnect (PCIe, NVLink) without an explicit copy, but with reduced bandwidth compared to device-local memory. No hardware-managed coherency between CPU and device caches.
0x02UNIFIEDHardware-managed unified/coherent memory. Both CPU and device can access this buffer at any time; the hardware (driver or MMU) ensures coherency. Physical pages may migrate.
0x03PEERPeer-to-peer device memory. Directly accessible by a specific set of peer accelerators agreed out-of-band (NVLink, xGMI, PCIe BAR mapping). Not accessible from the CPU without a copy. The set of peers is communicated via the interchange protocol, not this field.
0x04–0xEF(reserved)Reserved for future specification versions. Readers MUST reject a buffer handle with a memory_class in this range.
0xF0–0xFE(private)Implementation-private memory classes, valid only when paired with a private device_tag (0xF0–0xFE). Semantics are agreed out-of-band. Readers that do not recognise the private class MUST reject the handle unless they have agreed out-of-band.
0xFF(invalid)Reserved. Readers MUST reject a buffer handle whose memory_class is 0xFF.

Per-device validity table

Not all (device_tag, memory_class) pairs are meaningful. The following table defines the valid combinations. Readers MUST reject handles with combinations not listed as valid for the declared device. Private device tags (0xF0–0xFE) MAY use any private memory class; semantics are out-of-band.

DeviceSTANDARDHOST_PINNEDUNIFIEDPEER
CPU (0x00)✓✓ (pinned for GPU DMA)✓ (unified addr space, device = CPU side)✗
CUDA (0x01)✓ cudaMalloc✓ cudaMallocHost✓ cudaMallocManaged✓ P2P
ROCm (0x02)✓✓ hipHostMalloc✓ hipMallocManaged (hw-dependent)✓ xGMI/PCIe
Metal (0x03)✓ StoragePrivate✓ StorageManaged (discrete GPU only)✓ StorageShared (Apple Silicon)✗
Vulkan (0x04)✓ DEVICE_LOCAL✓ HOST_VISIBLE✓ DEVICE_LOCAL|HOST_VISIBLE (integrated)✓ via external memory ext
WebGPU (0x05)✓✗✗✗
Hexagon (0x06)✓ VTCM/DDR✓ FastRPC shared✓ FastRPC coherent✗
Level Zero (0x07)✓ zeMemAllocDevice✓ zeMemAllocHost✓ zeMemAllocShared✓
OpenCL (0x08)✓✓ CL_MEM_ALLOC_HOST_PTR✓ SVM (OpenCL 2.0+)✗

Note (non-normative): Metal HOST_PINNED (StorageManaged) is deprecated and unavailable on Apple Silicon. Producers targeting Apple Silicon MUST use UNIFIED (StorageShared) instead. The HOST_PINNED value remains defined for discrete Metal GPU configurations.

Note (non-normative): ROCm UNIFIED requires hardware support for Heterogeneous Memory Management (HMM). Producers MUST verify hardware support before tagging a buffer UNIFIED; consumers MAY fall back to a copy-based path if UNIFIED is declared but the consumer's runtime does not support HMM on the current device.

Backward compatibility

Existing descriptors that set _reserved[0] to 0x00 are implicitly STANDARD (memory_class = 0x00), which is the correct interpretation for all allocations that predate this field. The field defaults to the most conservative semantics; no existing consumer is broken.

Readers compiled before this amendment will reject descriptors with memory_class != 0x00 at the _reserved byte check. This is the correct fail-safe: a consumer that doesn't understand the memory class should not silently treat a UNIFIED buffer as STANDARD, as it may issue incorrect synchronisation.

The supported_memory_classes field SHOULD be added to CLIENT_HELLO/SERVER_HELLO in the interchange protocol to allow peers to advertise which memory classes they support, analogous to supported_devices. This is a follow-up editorial change routed to format-spec-writer.

DLPack mapping

The Python bindings layer MUST translate (device_tag, memory_class) pairs to DLPack DLDeviceType values. The mapping is maintained in docs/impl/python-bindings.md. Representative entries:

Hurray device_tagHurray memory_classDLPack DLDeviceType
0x00 CPUSTANDARDkDLCPU (1)
0x01 CUDASTANDARDkDLCUDA (2)
0x01 CUDAHOST_PINNEDkDLCUDAHost (3)
0x01 CUDAUNIFIEDkDLCUDAManaged (13)
0x02 ROCmSTANDARDkDLROCM (10)
0x02 ROCmHOST_PINNEDkDLROCMHost (11)
0x03 MetalSTANDARD / UNIFIEDkDLMetal (8)

Metal STANDARD and UNIFIED both map to kDLMetal (8) because DLPack does not distinguish Metal storage modes. Consumers that need to distinguish storage modes MUST use the Hurray memory_class field directly.

Alternatives Considered

Option 2 — new named device tags per access mode (e.g., 0x0A CudaUnified, 0x0B CudaHostPinned). Rejected: encodes the same information but hides the factored structure, requiring O(devices × access_modes) tag assignments. Each new device would need multiple tag variants, growing the tag table quadratically.

Option 1 — a single is_unified flag bit in a reserved byte. Rejected: a boolean cannot distinguish HOST_PINNED from UNIFIED, which have different coherency and performance semantics. Consumers choosing between GPU and CPU execution paths need the three-way distinction.

Widen device_tag to uint16 and encode access mode in the high byte. Rejected: breaking wire layout change with no benefit over a separate byte; the handle is already 16 bytes with a free byte available.

Consequences

  • docs/spec/buffer-protocol.md:
    • Buffer handle table: byte 14 renamed from _reserved[0] to memory_class; byte 15 remains _reserved (now a single byte, not a two-byte array).
    • New § Memory Class Values section.
    • New § Per-Device Validity Table section.
    • Existing per-device alignment subsections amended to note valid memory classes.
  • docs/spec/interchange.md: supported_memory_classes advertisement added to CLIENT_HELLO/SERVER_HELLO (editorial follow-up → format-spec-writer).
  • docs/impl/python-bindings.md: DLPack mapping table extended with (device_tag, memory_class) → DLDeviceType entries.
  • hurray-core/src/buffer.rs: BufferHandle struct gains a memory_class: MemoryClass field at byte offset 14; MemoryClass enum defined with the values above. The existing _reserved: [u8; 2] field is replaced by memory_class: MemoryClass + _reserved: u8. Routed to rust-developer as part of the next buffer-touching pass.
  • ADR-016 § Consequences note "Memory-class sub-distinctions deferred": resolved by this ADR.
  • Backward compatibility: descriptors with memory_class = 0x00 (STANDARD) are identical to pre-ADR-020 descriptors with _reserved[0] = 0x00. No existing producer or consumer is broken if they write 0x00 (the common case).

ADR-021: NVFP4 Quantization Scheme — Deferred

Status

Deferred

Context

NVFP4 is NVIDIA's 4-bit floating-point quantization format introduced with the Blackwell GPU architecture (B100, B200, GB200). It is implemented in hardware via Blackwell Tensor Cores and used in production by NVIDIA TensorRT-LLM, vLLM (Blackwell path), and — for model-weight compatibility on non-NVIDIA hardware — Apple's MLX framework.

Encoding summary

  • Element type: 4-bit float, E2M1 (2-bit biased exponent, 1-bit mantissa, 1-bit sign), packed LSB-first into byte sequences.
  • Scale format: E4M3 (4-bit) per-group scale value. One scale per group of 16 elements. No zero-point / bias term.
  • Group size: Fixed at 16 elements.

How it differs from existing Hurray schemes

NVFP4 cannot be expressed by any scheme currently defined in quantization.md:

PropertyMXFP (0x05)NVFP4
Scale formatE8M0 (8-bit, OCP MX)E4M3 (4-bit, NVIDIA)
Default group size3216
Standardisation bodyOCP (Open Compute Project)NVIDIA (proprietary)

A different scale type and group size means the MXFP scheme tag cannot accommodate NVFP4 even with a different block_size parameter.

Arguments for adding NVFP4 as Tier 2

  1. Hardware support is real and shipping. Blackwell Tensor Cores execute NVFP4 natively. Tier 2 precedent (MXFP) was set on the same basis.
  2. Cross-ecosystem adoption is observable. Apple MLX added NVFP4 support on Apple Silicon solely for model-weight portability — not native hardware support. This is a concrete instance of the interchange scenario Hurray targets.
  3. Existing scheme machinery applies. Tier 2 schemes are OPTIONAL; adding one imposes no conformance burden on implementations that don't support it.

Arguments for deferring

  1. Vendor-proprietary, not an open standard. MXFP is specified by OCP; NVFP4 is documented in NVIDIA CUDA/CUTLASS SDK documentation. If NVIDIA alters the encoding, the Hurray spec would require a scheme_version bump — coupling the spec lifecycle to a vendor's SDK.
  2. Published NVFP4 model weights remain scarce. As of May 2026, the dominant open-weight formats are GGUF (Q4–Q8), safetensors (bf16/fp16), and AWQ int4. NVFP4 weights exist but are not yet widely distributed.
  3. Specification source is not stable enough to write a normative spec section. The canonical NVFP4 encoding details are spread across NVIDIA CUTLASS headers and TensorRT-LLM documentation rather than a versioned, citable specification document. Writing a normative spec section against a moving target risks drift.
  4. Private extension range already covers the interim need. Implementations that need NVFP4 interchange today MAY use a private scheme tag (0xF0–0xFE) and agree on semantics out of band. This is the intended path for formats that are not yet ready for standardisation.

Decision

Deferred. NVFP4 is not assigned a Tier 2 (or Tier 1) scheme tag at this time.

Implementations that need to exchange NVFP4 tensors MAY use a private scheme tag in the 0xF0–0xFE range with the encoding derived from NVIDIA CUTLASS / TRT-LLM documentation. The recommended private encoding mirrors the Tier 2 MXFP scheme structure (same 4-byte header, same buffer table placement rules) with:

  • scale_format = E4M3 (to be defined if/when the scheme is promoted)
  • block_size = 16
  • No zero-point field

Promotion criteria

This decision SHOULD be revisited when two or more of the following conditions are met:

  1. NVFP4 weights are distributed in at least two major open-weight model releases (e.g., on Hugging Face Hub) in a format that requires cross-runtime interchange (not just single-runtime inference).
  2. A second hardware vendor implements native NVFP4 Tensor Core support, or a standards body (OCP, ISO, JEDEC) adopts a compatible specification.
  3. NVIDIA publishes a versioned, citable NVFP4 specification document (not just SDK headers) that can be referenced normatively.
  4. At least two independent Hurray implementations have shipped private-tag NVFP4 support and request promotion.

Alternatives Considered

Add NVFP4 as Tier 2 immediately. Rejected at this stage: the normative source is not stable enough to write a durable spec section. scheme_version provides a forward migration path, but repeated version bumps due to upstream churn would erode spec credibility.

Add NVFP4 as Tier 1. Not considered: Tier 1 requires implementation by all conforming implementations that advertise quantization support. A vendor-specific 4-bit format with no open standard is not an appropriate Tier 1 candidate.

Do nothing / no ADR. Rejected: the question has been raised and evaluated; an explicit deferral with promotion criteria is more useful than silence. Future reviewers will otherwise re-investigate the same question from scratch.

Consequences

  • No changes to quantization.md or the scheme tag table at this time.
  • Implementations that need NVFP4 exchange today SHOULD use private scheme tag 0xF0 and document their encoding internally, pending promotion.
  • This ADR is the tracking record for the NVFP4 promotion decision. It SHOULD be revisited when the promotion criteria above are met, at which point it is superseded by a new ADR assigning a Tier 2 tag.
  • TODO.md: add a note to re-evaluate NVFP4 when Blackwell model-weight distribution becomes mainstream (estimated: late 2026 based on hardware ramp).

ADR-022: hurray-python Runtime Compliance Modes

Status

Superseded by ADR-029

Note: The runtime compliance modes described here existed solely to gate __array_namespace__ visibility by dtype tier. ADR-029 drops the Array API conformance claim and removes both __array_namespace__ and these modes. This ADR is retained for historical context.

Context

hurray-python targets two audiences simultaneously:

  1. Array API consumers — libraries (NumPy, PyTorch, JAX, SciPy, scikit-learn, Xarray, …) that accept any Python Array API Standard-conformant array. For these consumers, hurray.Tensor must behave exactly as the standard requires, including the requirement that __array_namespace__ MUST NOT be implemented on tensors with non-Array-API dtypes (Tier 2, quantized).

  2. Hurray-native consumers — code that deliberately uses the full Hurray type system: int4, float8 variants, quantized types, non-standard memory layouts. These consumers do not need Array API conformance; they need access to the complete Hurray feature set without hitting artificial walls.

The Python Array API Standard clearly delineates the two cases — Tier 1 types are Array-API-conformant; Tier 2 and quantized types are not — but does not prescribe how an implementation must behave when a user tries to cross that line. Different implementations have made different choices (raise, return a special object, do nothing), creating interoperability confusion.

The project TODO records the need for: "hurray-python runtime modes: support two modes — strict Array API compliance mode (Tier 1 types only, all Array API invariants enforced) and standard-free mode (Tier 2 / quantized types exposed, Array API constraints relaxed)."

An open question during Layer 8a planning (OQ-A) asked: for hurray.Tensor instances with Tier 2 / quantized dtypes, should __array_namespace__ be (a) absent (raises AttributeError, hasattr returns False) or (b) present but raises TypeError? The correct answer depends on the runtime-modes design: in strict mode (a) is correct; in relaxed mode (b) is wrong — it must be present and functional.

A third concern is thread safety: hurray-python is used in multi-threaded inference pipelines (Torch DataLoader workers, Triton server thread pools, asyncio coroutine groups). A plain global flag (hurray.config.strict = False) would be unsafe across threads.

Decision

Mode carrier: contextvars.ContextVar

The compliance mode is stored in a module-level contextvars.ContextVar[bool] named _strict_mode, with a default value of True (strict). This is thread-safe and coroutine-safe: each OS thread and each asyncio Task inherits a copy of the context on spawn; changes in one thread do not affect others.

Note (non-normative): Threads created with the raw threading.Thread API inherit the default value of the ContextVar (True, strict), not the caller's current context. This is standard Python contextvars behavior. Document it prominently in the user guide.

Public API (reserved in Layer 8a, fully implemented in a later layer)

Four names are reserved in the hurray module namespace from Layer 8a onward:

NameSignatureBehaviour in Layer 8a
hurray.set_strictset_strict(strict: bool) -> Noneset_strict(True) is a no-op; set_strict(False) raises NotImplementedError
hurray.is_strictis_strict() -> boolalways returns True
hurray.strictcontext manageralways a no-op (already in strict mode)
hurray.relaxedcontext managerraises NotImplementedError

The full implementation (allowing set_strict(False) and relaxed() to actually switch mode) is deferred to a later layer. Reserving the names now prevents users or third-party packages from squatting on them.

OQ-A resolution: __array_namespace__ visibility

Tensor dtypeMode__array_namespace__hasattr(t, '__array_namespace__')
Tier 1strictpresent, returns hurray namespaceTrue
Tier 1relaxedpresent, returns hurray namespaceTrue
Tier 2 / quantizedstrictabsentFalse
Tier 2 / quantizedrelaxedpresent, returns hurray namespaceTrue

In strict mode, Tier 2 / quantized tensors MUST NOT expose __array_namespace__. This satisfies the Array API Standard literally (the attribute does not exist) and ensures array-api-tests conformance checks pass correctly.

In relaxed mode, __array_namespace__ is present on all tensors and returns the hurray module, which acts as a non-conformant extended namespace for Tier 2 types. The user has explicitly opted out of Array API conformance guarantees for that scope.

Implementation: __getattribute__ override on Tensor

To make hasattr(tier2_tensor, '__array_namespace__') return False in strict mode while keeping a single Tensor class, Tensor MUST override __getattribute__ in PyO3:

# Pseudo-code; actual implementation is in Rust via PyO3 #[pymethods]
def __getattribute__(self, name):
    if name == '__array_namespace__' and not is_tier1_dtype(self.dtype):
        if is_strict():
            raise AttributeError(
                f"hurray.Tensor with dtype {self.dtype.name!r} is a Tier 2 type "
                f"and does not implement __array_namespace__ in strict mode. "
                f"Use `with hurray.relaxed(): ...` to access Hurray-native features."
            )
    return type(self).__getattribute__(self, name)

The fast-path early return (name not in GATED_ATTRIBUTES) MUST be placed before the mode check to minimise overhead on non-gated attribute accesses. GATED_ATTRIBUTES is a small frozen set; initially {'__array_namespace__'}.

A private helper is_tier1_dtype(dtype) -> bool MUST be factored out from the gate check. Layer 8b+ will call it from multiple sites.

Scope of the mode: narrow

The compliance mode gates exactly two things:

  1. Tier 2 / quantized dtype admission through Array-API-shaped construction APIs (hurray.zeros, hurray.ones, hurray.asarray, etc.). In strict mode these raise hurray.UnsupportedError for non-Tier-1 dtypes. In relaxed mode they succeed.
  2. __array_namespace__ visibility on Tier 2 / quantized tensor instances, per the table above.

The mode does not affect:

  • size returning None for dynamic dimensions (correct Array API behavior, not a constraint to relax).
  • T raising ValueError for non-rank-2 tensors (Array API specification).
  • shape returning Tuple[Optional[int], ...] (Array API specification).
  • DLPack BufferError for element types outside the DLPack type enum (structural limitation of DLPack, not a compliance constraint).
  • Error hierarchy or exception semantics.

These invariants hold in both modes. They are properties of well-formed operations, not of compliance scope.

The namespace object returned by __array_namespace__() for Tier 1 tensors is the same hurray module in both modes. It is not "extended" or restricted based on mode. Tier 2 entry is through bare hurray module functions (e.g., hurray.zeros), not through the namespace returned by __array_namespace__.

Alternatives Considered

Option 1a: Plain module-level global flag. hurray.config.strict = False sets a global boolean. Rejected: not thread-safe. Two Torch DataLoader worker threads can race on the flag. In a Triton inference server, one request handler's mode flip leaks to concurrent handlers. Unsafe by default in all multi-threaded ML environments.

Option 2: Tensor subclass (hurray.RawTensor). Two classes: hurray.Tensor (strict, Tier 1, full Array API) and hurray.RawTensor (standard-free, all types). Rejected: contradicts the user's intent for a simple global setting, and requires every consumer library to widen isinstance checks. Makes from_numpy ambiguous when the dtype is int4.

Option 3: Per-instance __getattr__ dispatch. The method is absent from the class definition; __getattr__ raises AttributeError for Tier 2 in strict mode. Rejected: __getattr__ only fires on attribute-not-found; it cannot conditionally expose an attribute that exists on the class. Furthermore, the mode belongs to the call site, not the tensor instance — a Tier 2 tensor created in relaxed mode should not carry __array_namespace__ for its entire lifetime. __getattribute__ is the correct hook.

(b) for OQ-A: __array_namespace__ present, raises TypeError for Tier 2. Rejected: the Array API conformance test suite (array-api-tests) probes hasattr(t, '__array_namespace__'). Returning True and raising on call makes the tensor claim Array API capability and then crash the consumer at the worst moment. Worse than absent.

Consequences

  • Layer 8a ships strict-mode only. set_strict(False) and relaxed() raise NotImplementedError. This is documented in docs/impl/python-bindings.md.
  • Tensor.__getattribute__ is overridden from day one. The strict-mode gate for __array_namespace__ is active; the relaxed-mode branch is a no-op placeholder. Adding relaxed mode later requires no public API change.
  • is_tier1_dtype(dtype) -> bool is a first-class internal helper. It is factored out in Layer 8a and reused in Layer 8b+ wherever Tier-2 behavior diverges.
  • TODO.md item "hurray-python runtime modes" is resolved by this ADR for architecture; the implementation of the relaxed path is a separate tracked layer.
  • docs/impl/python-bindings.md § "Tier 2 and quantized types" MUST be amended to reference this ADR and describe strict vs relaxed behavior. A new § "Runtime modes" MUST be added.
  • Threads created with raw threading.Thread always enter strict mode regardless of the spawning thread's mode. This SHOULD be documented in the user guide.

ADR-023: hurray-python Native Buffer Interchange Protocol

Status

Accepted

Context

hurray-python interoperates with the broader Python ecosystem through three buffer-sharing surfaces:

  1. DLPack (__dlpack__ / __dlpack_device__) — the cross-library protocol used by PyTorch, JAX, NumPy, CuPy, etc.
  2. NumPy (__array__ / from_numpy) — CPU-only, Tier 1 dtypes only.
  3. Python buffer protocol — CPU-only, byte-level access.

DLPack is the right tool for cross-library zero-copy, but its device/memory model is strictly less expressive than Hurray's. Per the device mapping table in docs/impl/python-bindings.md, hurray.UnsupportedError is raised today in every one of the following situations because DLPack v1.0 has no representation for them:

  • ROCm + UNIFIED — kDLROCMManaged does not exist in the DLPack DLDeviceType enum.
  • PEER memory for any device — DLPack has no flat enum value for peer-mapped memory.
  • Private device tags (0xF0–0xFE) — by design, the format reserves these for implementation-private use; DLPack cannot represent them.

For hurray-to-hurray transfers — hurray-python ↔ hurray-python, hurray-python ↔ hurray-ffi consumer, or hurray-python ↔ another binding built on hurray-ffi — DLPack is not the correct protocol. The Hurray C ABI buffer handle (HurrayBuffer in hurray-ffi) already carries the full descriptor: device_tag, memory_class, sync_mode, alignment, byte_size, release callback, release context. A native protocol can share all of that losslessly without flattening into DLPack's DLDeviceType enum.

The proposal: an opt-in __hurray_buffer__() / hurray.from_hurray_buffer() PyCapsule protocol. The capsule wraps a HurrayBuffer pointer from hurray-ffi, with the same lifetime discipline as DLPack capsules (refcount on create, decrement on consume/delete; capsule renamed after consumption to prevent double-free).

This ADR does not propose replacing DLPack. DLPack remains the only protocol consumed by external libraries (PyTorch, JAX, NumPy). The native protocol exists strictly to plug the holes the device mapping table calls out, and to give two Hurray-aware peers a path that preserves the full descriptor.

What was decided in OQ-A (ADR-022)

ADR-022 establishes that runtime compliance modes gate exactly two things: Tier 2 / quantized dtype admission and __array_namespace__ visibility on Tier 2 tensors. The mode does not gate DLPack, __array__, error hierarchy, or buffer-protocol semantics. That scope decision is load-bearing for this ADR: the native buffer protocol is not an Array API construct and falls outside the modes' jurisdiction.

Decision

1. Protocol name: __hurray_buffer__ / from_hurray_buffer

Amended by ADR-033: the protocol is named __hurray__ / from_hurray, and the capsule is named "hurray_tensor" / "used_hurray_tensor". The original text follows unchanged.

hurray.Tensor MUST expose a dunder method __hurray_buffer__(stream=None) -> PyCapsule. The hurray module MUST expose hurray.from_hurray_buffer(obj, /) -> hurray.Tensor that accepts any object whose __hurray_buffer__ returns a valid capsule.

The dunder name __hurray_buffer__ mirrors the established pattern of __dlpack__, __array__, and __cuda_array_interface__. The hurray_ prefix is namespace-safe: dunders prefixed with a project identifier are an accepted convention (PyTorch's __torch_function__, JAX's __jax_array__).

2. Layer placement: deferred to Layer 8c (post-FFI Python exposure)

The protocol MUST NOT be implemented in Layer 8a. Layer 8a ships the core hurray.Tensor, DLPack, NumPy interop, and strict mode only. The hurray-ffi C ABI is not yet exposed to Python through any documented interface in Layer 8a.

The native buffer protocol logically belongs in a layer downstream of Layer 8b (file I/O bridge) and downstream of the work that exposes hurray-ffi to Python. This ADR designates that layer Layer 8c — Native buffer protocol. Layer 8c may run in parallel with or after the relaxed-mode implementation; it has no dependency on relaxed mode.

In Layer 8a and 8b:

  • __hurray_buffer__ MUST NOT be present on hurray.Tensor. hasattr(t, '__hurray_buffer__') MUST return False.
  • hurray.from_hurray_buffer MUST NOT exist as a public name.

The names are reserved by this ADR to prevent third-party squatting.

3. Mode gating: neither strict nor relaxed; available unconditionally in Layer 8c

The native buffer protocol is not an Array API construct, and ADR-022's mode scope explicitly excludes everything except __array_namespace__ visibility and Tier 2 dtype admission. Therefore:

  • Once Layer 8c is shipped, __hurray_buffer__ MUST be present on hurray.Tensor instances of all dtypes (Tier 1, Tier 2, quantized) in both strict and relaxed modes.
  • The Array API conformance test suite (array-api-tests) does not probe __hurray_buffer__ and is therefore unaffected.

A Hurray-aware consumer that needs a Tier 2 / quantized tensor's buffer in strict mode still has a path: the native protocol. A non-Hurray-aware consumer that probes __array_namespace__ still sees False for Tier 2 in strict mode, as required by ADR-022. The two surfaces are orthogonal.

4. Spec placement: implementation-only

The native buffer protocol MUST be documented only in docs/impl/python-bindings.md. It MUST NOT appear in docs/spec/buffer-protocol.md or any other file under docs/spec/.

Justification:

  • The protocol is a Python-only transport — it does not affect the wire format, the binary descriptor, the C ABI, or any non-Python binding.
  • The wire payload of the capsule is the HurrayBuffer pointer defined in hurray-ffi; the C FFI implementation guide is already authoritative for that handle.
  • Putting the protocol in the format spec would create a normative obligation on all bindings (C, Java, JS, etc.) to expose a "native" protocol — out of scope for non-Python bindings that already pass HurrayBuffer directly through C.

5. Capsule lifetime: same discipline as DLPack, with Hurray-specific deleter

Amended by ADR-034: the capsule context is a HurrayTensorContext from hurray-ffi, readable from any language, and the ABI version is read through hurray_tensor_context_abi_version. C ABI version 4.

The PyCapsule lifetime rules MUST match DLPack semantics:

  • Capsule name on creation: "hurray_buffer".
  • Capsule name after consumption: "used_hurray_buffer". The consumer MUST rename the capsule before transferring ownership, exactly as DLPack consumers rename "dltensor" to "used_dltensor".
  • Capsule destructor (producer side): If the capsule is destroyed while its name is still "hurray_buffer" (the consumer did not take ownership), the destructor MUST call hurray_buffer_destroy on the wrapped HurrayBuffer pointer, which invokes the registered release callback exactly once.
  • Consumer-side responsibility: Once the capsule has been renamed to "used_hurray_buffer", the consuming hurray.Tensor owns the HurrayBuffer and MUST call hurray_buffer_destroy exactly once at its own finalisation.
  • Source hurray.Tensor reference counting: When __hurray_buffer__() is called, the source Tensor's Python refcount MUST be incremented and stored as the capsule context; the destructor MUST decrement it. This mirrors the rule required for __dlpack__ in § Buffer Lifetime and Ownership.

Note (non-normative): The Python refcount on the source Tensor and the HurrayBuffer internal release callback are independent. The Python refcount keeps the producer-side Python object alive while the capsule exists; the release callback governs the underlying buffer memory. HurrayBuffer is not internally refcounted at the C ABI; producers wanting multi-consumer fan-out MUST issue distinct HurrayBuffer handles per consumer.

6. stream parameter: same semantics as DLPack

__hurray_buffer__(stream=None) MUST accept an optional stream parameter with the same semantics as __dlpack__(stream=None) in § Stream parameter semantics:

streamRequirement
NoneThe tensor MUST have SyncMode::ProducerSynced.
-1The binding layer MUST perform a device-level synchronisation before returning the capsule.
Positive integer (stream handle)If ProducerSynced, the stream is ignored. For SyncMode::Event or SyncMode::ConsumerStream, the binding MUST raise BufferError.

7. Discovery: hasattr(tensor, '__hurray_buffer__')

Amended by ADR-033: the protocol is named __hurray__ / from_hurray, and the capsule is named "hurray_tensor" / "used_hurray_tensor". The original text follows unchanged.

Consumers MUST discover support by probing hasattr(obj, '__hurray_buffer__'). There MUST NOT be a capability flag on the hurray namespace. This matches the discovery convention of __dlpack__, __array__, and __cuda_array_interface__. A consumer that detects __hurray_buffer__ MAY still fall back to __dlpack__ if it does not link hurray-ffi. The two probes are independent.

8. Error semantics

Amended by ADR-034: the capsule context is a HurrayTensorContext from hurray-ffi, readable from any language, and the ABI version is read through hurray_tensor_context_abi_version. C ABI version 4.

Amended by ADR-033: the protocol is named __hurray__ / from_hurray, and the capsule is named "hurray_tensor" / "used_hurray_tensor". The original text follows unchanged.

  • A consumer receiving an object lacking __hurray_buffer__ MUST raise TypeError.
  • If the wrapped HurrayBuffer pointer is null or the capsule name is not "hurray_buffer" (already consumed), hurray.from_hurray_buffer MUST raise hurray.BufferError.
  • ABI version mismatch between producer and consumer MUST raise hurray.UnsupportedError. The capsule context MUST include HURRAY_C_ABI_VERSION from the producer; the consumer MUST verify it before dereferencing the handle.

Alternatives Considered

Option A: Extend DLPack upstream. Lobby for kDLROCMManaged, peer-memory enum values, and a private-tag escape hatch in DLPack itself. Rejected: upstream evolution is slow; DLPack's structural flat-enum constraint cannot absorb Hurray's full (device_tag, memory_class, sync_mode) space without losing the protocol's cross-library simplicity.

Option B: Reuse __dlpack__ with a sentinel device-type integer. Allocate a private DLDeviceType value for "Hurray native" and embed the HurrayBuffer pointer in DLTensor.data. Rejected: misuses a published cross-library protocol; silently breaks DLPack consumers that do not recognise the sentinel value.

Option C: Per-instance opt-in via a constructor flag (Tensor(..., expose_native=True)). Only tensors created with expose_native=True carry __hurray_buffer__. Rejected: protocol availability belongs to the call site, not the tensor instance — consistent with ADR-022's mode-scope decision. An always-on dunder (once Layer 8c ships) is the uniform rule.

Option D: Implement in Layer 8a. Rejected: hurray-ffi is not yet reachable from Python in Layer 8a. Implementing against a moving target adds risk with no corresponding user value.

Option E: Different capsule lifetime discipline. For example refcount-only, no name change. Rejected: the "name" → "used_name" rename is what makes DLPack's capsule destructor safe under all consumption paths. Reinventing the discipline creates a divergent mental model for binding authors.

Option F: Capability flag on the hurray namespace (e.g., hurray.NATIVE_BUFFER_PROTOCOL_VERSION = 1). Rejected: redundant atop the hasattr probe; creates a synchronisation burden between the flag and the dunder.

Consequences

  • Closes the DLPack representability gaps for ROCm UNIFIED, all PEER memory, and private device tags 0xF0–0xFE. Hurray-aware peers can transfer these buffers losslessly.
  • Preserves DLPack as the cross-library protocol. Non-Hurray consumers (PyTorch, JAX, NumPy) continue to use __dlpack__ unchanged.
  • Mode-orthogonal. Tier 2 / quantized tensors gain a buffer-sharing path in strict mode without relaxing Array API conformance.
  • No spec churn. docs/spec/buffer-protocol.md is untouched. Non-Python bindings are unaffected.
  • Layer 8c added to the roadmap. Scope: one dunder, one constructor function, one capsule destructor, ABI version check, and associated tests. Small, contained, and user-approvable independently.
  • ABI version coupling. Changes to HurrayBuffer in hurray-ffi affect the capsule payload. Mitigated by HURRAY_C_ABI_VERSION embedding and consumer-side verification.

Required Spec Amendments

The following amendments to docs/impl/python-bindings.md are required as a follow-up by format-spec-writer. Do NOT apply them here.

  1. New section ## Native Buffer Interchange Protocol (after ## DLPack Interoperability, before ## Buffer Lifetime and Ownership). Covers: dunder name, constructor, capsule names, lifetime rules, stream semantics, ABI versioning, mode independence, discovery, and reference to ADR-023.

  2. Amendment to ## DLPack Interoperability § mapping table notes: add a note pointing the hurray.UnsupportedError rows (ROCm UNIFIED, all PEER, private tags) to the native protocol as the recommended fallback for Hurray-aware consumers.

  3. Amendment to ### Layer 8a status: add bullets stating that __hurray_buffer__ and hurray.from_hurray_buffer are reserved but not implemented in Layer 8a; hasattr returns False for the dunder.

  4. Amendment to ## Error Handling: add hurray.BufferError for null / already-consumed capsules passed to from_hurray_buffer; and hurray.UnsupportedError for ABI version mismatches.

  5. Amendment to hurray-python/COMPAT-MATRIX.md: add a column recording, per release, whether the native buffer protocol is available and the minimum HURRAY_C_ABI_VERSION required.

Open Questions Deferred

  • Multi-buffer tensors (SparseTensor). Should __hurray_buffer__ return a tuple of capsules or be restricted to dense tensors? RESOLVED by ADR-030: one capsule carries every buffer, wrapping a HurrayBufferList. The initial recommendation recorded here — restrict to dense and add __hurray_sparse_buffer__ later — is superseded; it conflated multi-buffer with sparse, while dense per-channel-quantized, block-paged, and composite tensors are multi-buffer too.
  • Cross-process (IPC) variant. A capsule only works in-process; cross-process Hurray-native exchange would need an OS-handle-based protocol. Deferred to a later layer.
  • Async counterpart. Whether an __ahurray_buffer__ async variant is needed. Deferred. Initial recommendation: no; sync_mode already handles the synchronisation contract per-buffer.

ADR-024: Block-Paged Layout as an Indirect-Dense Whole-Batch Snapshot

Status

Draft

Context

Disaggregated LLM inference is, at its core, a data-plane problem: the KV cache must move between prefill and decode workers. The systems surveyed in docs/prior-art.md §3 — DistServe, Mooncake, vLLM's KVConnector, NVIDIA Dynamo / TensorRT-LLM, llm-d, and LMCache — all move the KV cache continuously, and all of them agree shape, dtype, paged layout, and quantization out-of-band, shipping only opaque blocks plus IDs. §3.7 establishes this metadata gap; §3.8 names a Hurray block-paged layout as the planned fix.

The KV cache is stored paged (PagedAttention): a flat pool of fixed-size pages plus a per-sequence block table mapping logical token positions to physical page IDs. Different sequences can share physical pages (prefix sharing / copy-on-write), which the engines manage with internal reference counts.

Hurray's job is to describe the static snapshot on the wire, not live allocator state. It must do so within the format's existing constraints:

  • Streamability (README.md, interchange.md): descriptors precede their data, the format is self-delimiting, and there are no back-references and no end-of-file index.
  • Single-owner buffers (buffer-protocol.md; ADR-009 keeps reference counting engine-internal and non-normative at the ABI boundary).
  • Hyperrectangle shape model (data-model.md): the logical shape is always a rectangular index space.
  • Reuse of the existing quantization schemes (quantization.md) rather than a paged-specific scheme.

This ADR records the design of a new block-paged layout that closes the §3.8 gap within those constraints. Three open questions raised during design have been resolved by the user and are folded into the Decision and Consequences below; no open questions remain.

Decision

1. A new addressing category: Indirect

block-paged introduces a third layout addressing category alongside Dense and Sparse: Indirect. In an indirect layout every logical element exists (there are no implicit zeros, unlike Sparse), but the mapping from a logical index to a physical buffer position is non-affine — it is resolved through a block table rather than an affine stride formula. The layout is assigned Tier-1 tag 0x0B (tag 0x0A is reserved for the future CSF — Compressed Sparse Fiber — layout, keeping the sparse family contiguous).

2. Whole-batch snapshot, not per-sequence descriptors

A single block-paged descriptor encodes one whole batch: one page pool, one flat block table, and a CSR-style seq_ptr offset array delimiting each sequence's slice of the block table. A per-sequence descriptor was rejected because cross-sequence prefix sharing would then require a cross-tensor back-reference (one sequence's descriptor pointing into another's pages), which the streamability contract forbids. Encoding the whole batch turns prefix sharing into internal aliasing — two block-table entries naming the same physical page ID — which is self-contained and streamable.

3. Ragged structure via seq_ptr; the shape stays a hyperrectangle

Per-sequence lengths are carried by seq_ptr, exactly as CSR carries ragged rows by row_ptr. The logical shape [total_tokens, num_heads, head_dim] remains a hyperrectangle, so the data model in data-model.md is unmodified. A padded dense shape ([num_seqs, max_seq_len, ...]) was rejected: it wastes memory on padding and misrepresents the snapshot. A conforming implementation MUST require rank == 3 in this version, mirroring CSR's rank-2-only restriction (see Consequences, OQ-3).

4. One descriptor per {kv_role, layer_index}

A full transformer KV cache ([layers, 2, ...]) is transmitted as a stream of descriptors, one per key/value role per layer. This matches how the engines transfer the cache — layer by layer (DistServe; vLLM's save_kv_layer / wait_for_layer_load) — and keeps each descriptor a self-contained, streamable unit. A single whole-cache descriptor was rejected because it would defeat layer-by-layer streaming.

5. Buffer table

block-paged is the first layout to mix a values buffer with two index/pointer buffers and optional quantization-parameter buffers:

IndexBufferNotes
0page_poolflat pool of fixed-size pages
1block_tableflat per-sequence concatenation of physical page IDs; uint32 by default, uint64 opt-in
2seq_ptrCSR-style offset array, num_seqs + 1 entries
3+quant-param bufferspresent only when the tensor is quantized, per quantization.md placement rules

Scalar descriptor fields: page_size, num_pages, paged_axis (= 0 in this version), num_seqs, kv_role, layer_index, and block_table_index_type.

6. Quantization reuses existing schemes, paged through the same block table

Quantized paged KV caches reuse the schemes already defined in quantization.md — no paged-specific scheme is introduced. Per-page-slot scales (and zero points, for asymmetric schemes) are paged through the same block table as the values: a page carries its own scales, so a shared page remains numerically coherent for every sequence that aliases it. Quantization-parameter buffers occupy buffer indices 3 and up, per the placement rules in quantization.md.

The composition rules are now specified, not merely asserted (see docs/spec/layouts/block-paged.md § Quantization Compatibility): scales are per page slot (num_pages * page_size entries, paged through block_table); the scale-buffer size MUST be computed from the page structure rather than the standard per-block-affine formula; per-tensor and per-channel (along num_heads or head_dim) schemes compose normally; and per-block-affine (scheme_tag = 0x03) composes only when block_size == page_size with the paged/token axis as the quantization axis.

7. Ownership is unchanged

block-paged requires no amendment to buffer-protocol.md or ADR-009. Aliasing is data inside the block_table buffer; it does not create shared buffer ownership and introduces no wire-level reference count. The engine-internal copy-on-write reference counts that govern live page lifetime are out of scope: Hurray describes the snapshot, not the allocator.

Alternatives Considered

Per-sequence descriptor (one descriptor per sequence). Rejected: cross-sequence prefix sharing would require a forbidden cross-tensor back-reference, violating the streamability contract.

Padded dense shape [num_seqs, max_seq_len, num_heads, head_dim]. Rejected: wastes memory on padding for ragged batches and cannot express page sharing across sequences.

Classifying block-paged as a Sparse layout. Rejected: a sparse layout implies implicit zeros for unstored coordinates. A paged KV cache has no implicit zeros — every logical element along the paged axis is materialised in some page. The mismatch would mislead readers about the data model.

A single whole-cache descriptor ([layers, 2, ...]). Rejected: it defeats layer-by-layer streaming, which is the dominant transfer pattern in disaggregated serving.

A paged-specific quantization scheme. Rejected: it would duplicate the per-block machinery already defined in quantization.md. Reusing the existing schemes and paging the scales through the block table is sufficient.

Wire-level reference counts on shared pages. Rejected: it contradicts ADR-009 (reference counting is engine-internal and non-normative at the ABI) and the snapshot framing — a snapshot has no live lifetime to count.

Consequences

  • docs/spec/memory-layout.md gains the Indirect type, the 0x0B row in the Named Layout Tags table, and a buffer-table clause covering indirect layouts. Tag 0x0A is reserved for the future CSF (Compressed Sparse Fiber) layout so the sparse family (COO 0x07, CSR 0x08, CSC 0x09, CSF 0x0A) stays contiguous.
  • A new layout file docs/spec/layouts/block-paged.md is added.
  • block-paged is the first layout to mix a values buffer with two index/pointer buffers plus optional quantization buffers — a new buffer-table shape for the format.
  • A new reader validation surface is introduced: page-ID bounds checking (0 <= block_table[k] < num_pages) and seq_ptr monotonicity. Readers SHOULD validate these and MUST reject violations unless operating in permissive mode.
  • OQ-1 (partial trailing page) — resolved. Slots beyond a sequence's valid token count are left undefined. A reader MUST NOT read past a sequence's valid token count (bounded by seq_ptr); a writer SHOULD zero unused slots when transferring across a trust or tenant boundary. No padding-value field is added.
  • OQ-2 (aliasing) — resolved. Aliasing is expressible, not validated in this version. A reader validates page-ID bounds but is NOT required to verify that aliased pages hold identical content. No content-hash mechanism is added in v1.
  • OQ-3 (rank) — resolved. rank == 3 is required in this version ([total_tokens, num_heads, head_dim]), mirroring CSR's rank-2-only restriction. Generalisation is deferred to a future revision.
  • No ownership impact (confirmed). buffer-protocol.md and ADR-009 are untouched; aliasing creates no shared buffer ownership and no wire-level reference count.
  • Sharding forbidden in v1. A shard descriptor MUST NOT be applied to a block-paged tensor in this version (docs/spec/layouts/block-paged.md § Sharding). Tensor-parallel / multi-GPU sharding of a paged KV cache (e.g. along num_heads) is an open question: the interaction between a shard's shard_offset and the absolute seq_ptr offsets into the whole-batch block_table must be resolved before sharding can be permitted.

Date: 2026-06-23.

ADR-025: CSF (Compressed Sparse Fiber) as the Rank-N Sparse Layout

Status

Draft

Context

Hurray already defines three sparse layouts: COO (0x07, any rank), CSR (0x08, rank-2), and CSC (0x09, rank-2). CSR/CSC compress one mode with a dense outer pointer array — compact and interop-canonical (SciPy, cuSPARSE, MKL, Eigen; hurray-python already performs SciPy zero-copy sparse interop) — but they do not generalise past rank 2. COO generalises to any rank but stores a full coordinate tuple per non-zero with no hierarchical structure.

TACO uses CSF (Compressed Sparse Fiber): the rank-N generalisation of CSR/CSC. A CSF tensor is a tree of rank levels, each compressing one mode with a (pos, crd) pair, plus one shared values array. CSF is the natural higher-rank complement to COO for structured sparsity — attention masks, sparse activations, and higher-order factorisations. TODO.md names CSF, and tag 0x0A was reserved for it in ADR-024 to keep the sparse family contiguous (COO 0x07, CSR 0x08, CSC 0x09, CSF 0x0A).

The settled framing is that CSR/CSC/COO are kept; CSF complements them. CSR's dense outer row_ptr is more compact and more interop-canonical for rank-2 matrices, and an all-compressed CSF tree is a physically different layout. Writers SHOULD prefer CSR/CSC for rank-2 data.

This ADR must respect the format's existing constraints:

  • Streamability / self-delimitation (README.md, interchange.md): descriptors precede their data, all buffer lengths derive from the pos chain, and there are no back-references and no end-of-file index.
  • Single-owner buffers (buffer-protocol.md; ADR-009).
  • Hyperrectangle shape model (data-model.md): the logical shape is always a rectangular index space.
  • byte_offset = 0 for sparse layouts (memory-layout.md).
  • Reuse of the existing quantization schemes (quantization.md).

This is the pre-1.0 draft period, so assigning the already-reserved tag 0x0A requires no version_minor bump, exactly as ADR-024 established for 0x0B.

Decision

1. All-compressed CSF, not the full per-mode model

Every level of the CSF tree is a compressed (pos, crd) pair. The TACO per-mode dense/compressed model is not adopted in this version: dense-outer rank-2 cases are already covered by CSR/CSC, and a per-level mode_format[rank] array is reader burden Hurray does not need yet. This is forward-compatible: a future revision MAY add a mode_format: uint8[rank] field to admit dense levels, and a v1 reader rejecting that unknown field is intended versioning behaviour.

2. Explicit mode ordering

A CSF descriptor carries mode_order: uint32[rank], a permutation of 0..rank-1. mode_order[L] is the logical dimension stored at level L (level 0 is outermost; level rank-1 is the leaf level directly above values). The bounding size for level L is shape[mode_order[L]]. The logical shape is unchanged — mode_order affects storage traversal only.

mode_order is a performance knob: it lets a writer match the tree's nesting to its access pattern. A conforming reader MUST honour any valid mode_order permutation for lookup and iteration, and MUST NOT reject a descriptor merely because mode_order is non-identity — permutation support is mandatory for an interchange format. When a writer has no access-pattern preference, the identity ordering [0, 1, ..., rank-1] (row-major, outer-to-inner) is the RECOMMENDED default, for reproducibility and cache-friendliness.

3. Buffer table = 2 × rank + 1

values is at index 0 (consistent with CSR/CSC/COO). Each level L contributes a pos_L buffer at index 2L + 1 and a crd_L buffer at index 2L + 2. The top level retains pos_0 = [0, n_0] (length 2) for structural uniformity; omitting the top pos was rejected. All pos/crd buffers are uint64. Quantization-parameter buffers, when present, occupy indices 2·rank + 1 and up.

4. Rank scope: rank >= 3

CSR, CSC, and COO own rank ≤ 2. A conforming reader MUST reject a CSF descriptor with rank < 3. Rank remains capped at 64 by data-model.md.

5. Element lookup by per-level descent

Lookup descends the tree level by level, binary-searching each level's crd slice delimited by the parent's pos entry. A coordinate not found at any level means the element is implicitly zero.

6. Storage invariants mirror CSR, per level

For each level L: pos_L[0] = 0; pos_L is non-decreasing; the terminal pos entry equals the level's stored count (pos_0[1] = n_0, pos_L[n_{L-1}] = n_L, with n_{rank-1} = nnz); within each parent slice the crd values are strictly increasing (no duplicate siblings); and 0 <= crd_L[i] < shape[mode_order[L]]. In addition, mode_order MUST be a valid permutation of 0..rank-1.

7. Cross-cutting properties

byte_offset MUST be 0 (sparse). The layout is self-delimiting: every buffer length derives from the pos chain, so no back-references or EOF index are needed. Quantization decorates the values leaves (values is buffer 0); per-tensor, per-channel, per-block, NF4, and MXFP schemes compose exactly as they do for COO/CSR, with quant-parameter buffers at index 2·rank + 1 and up. Sharding is forbidden in this version: the interaction between a shard's shard_offset and the per-level pos/crd tree is unresolved. CSF is classified as Sparse (implicit zeros).

8. Tag assignment

CSF takes Tier-1 tag 0x0A, classified Sparse. The memory-layout.md row for 0x0A is promoted from "(reserved — planned)" to a link to layouts/csf.md.

Alternatives Considered

Full per-mode dense/compressed model (TACO mode formats). Rejected for v1: dense-outer rank-2 cases are covered by CSR/CSC, and a per-level mode_format array is reader burden Hurray does not need yet. Deferred and forward-compatible via a future mode_format field.

Replacing CSR/CSC with CSF. Rejected (settled framing): CSR's dense outer pointer is more compact and interop-canonical for rank-2, and all-compressed CSF is a physically different layout.

Omitting the top-level pos_0. Rejected: structural uniformity across all levels is worth the 16 bytes of a length-2 pos_0.

Allowing CSF at rank 2 and up. Rejected: it would duplicate CSR/CSC/COO. CSF is restricted to rank >= 3.

uint32 indices. Rejected for v1 to match the uint64 indices used by CSR/CSC/COO; an opt-in narrower index type may be added later.

Non-Sparse classification. Rejected: CSF has implicit zeros, so it is Sparse.

Consequences

  • docs/spec/memory-layout.md: the 0x0A row is promoted from reserved to a link to layouts/csf.md, and the sparse buffer-table clause notes CSF's variable rank-dependent count of 2·rank + 1.
  • A new layout file docs/spec/layouts/csf.md is added.
  • CSF is the first layout with a variable, rank-dependent buffer count and the first sparse layout with a permutation descriptor field (mode_order).
  • A new reader validation surface is introduced: mode_order permutation validity, per-level pos monotonicity and terminal checks, and per-level crd bounds. Readers SHOULD validate these and MUST reject violations unless operating in permissive mode.
  • The forward-reference note in csr.md (and the analogous mention) is now satisfied and updated to point at csf.md (editorial).
  • Writers SHOULD prefer CSR/CSC for rank-2; CSF MUST NOT be used below rank 3.
  • No version_minor bump (the tag was already reserved; pre-1.0 draft period).
  • The TODO.md CSF entry can be marked done once csf.md lands.

Date: 2026-06-27.

ADR-026: General Subpaving as Nested Region Descriptors with Per-Region Buffer Sub-Tables

Status

Superseded by ADR-027 (Composite Tensors — Head + Members + Composition Rule)

Superseded while still in Draft; the "Supersedes: ADR-015" intent below therefore never took effect. ADR-015 remains the record of the currently implemented inline region encoding until ADR-027's implementation lands.

Supersedes: ADR-015 (Subpaving Region Inline Layout Encoding)

Context

A spec-checker audit of the General Subpaving layout (docs/spec/layouts/subpaving.md, tag 0x06) surfaced five design-level findings (F-1, F-2, F-4, F-5, F-10). Their common root cause: subpaving is classified Dense and inherits the dense-layout rule buffer_count == 1, yet each RegionDescriptor (per ADR-015) carries a single buffer_index + region_byte_offset and its region_layout_payload carries only the inner layout's scalar descriptor fields — never buffer references. Sparse (0x07–0x0A) and indirect (0x0B) inner layouts bind their component arrays (values, indices, pointers, page pool, block table) positionally to a buffer table with a mandated exact count, so a sparse or indirect inner region is syntactically encodable but semantically uninterpretable (F-1). The per-region buffer_index also contradicts the buffer_count == 1 rule (F-2); private-extension inner tags have undefined buffer needs (F-4); per-region quantization is undefined (F-5); and the tensor-level byte_offset is unused by subpaving addressing (F-10).

The project's array-database vision treats heterogeneous chunked tensors as first-class: one very large logical tensor whose regions differ in structure — dense tiles beside sparse blocks beside paged blocks — is a target use case, not an edge case. A prior draft of this ADR proposed a dense-only inner-layout whitelist (deferring sparse-in-subpaving); that direction is rejected. This ADR adopts full support for sparse and indirect inner regions in v1.0 via a nested descriptor per region, each carrying its own buffer sub-table.

Constraints in force: streamability (descriptors precede data; self-delimiting; no back-references; no end-of-file index — README.md, interchange.md); the flat buffer-table model in which handle properties live in the descriptor and buffer locations are supplied positionally by the transport (file-format data region walked in table order at data_buffer_alignment; streaming TENSOR_DATA frames; in-process C ABI); the uint8 (max 255) buffer-count wire ceiling; the 64-byte minimum per-buffer alignment and the tensor-wide device-colocation rule (buffer-protocol.md); the ADR-017/019 extensibility and evolvability contracts.

Decision

The general subpaving layout is redefined as a container of nested region descriptors. Each region carries a self-delimiting descriptor body that includes the region's own layout-specific fields, its own buffer sub-table, and an optional per-region quantization section. Any layout tag — dense, sparse, indirect, recursive subpaving, or private extension — MAY be a region layout, because each region declares and owns the buffers its layout requires.

D1 — Region descriptor is a trimmed "descriptor-tail" profile, not a byte-for-byte full TensorDescriptor

A region does not repeat the full tensor-descriptor frame. Fields that a full TensorDescriptor carries but that would be pure redundancy or a new mismatch failure mode per region are omitted and inherited from the outer descriptor:

  • magic, version_major, version_minor — inherited (a nested magic per region would waste 4 bytes/region and add a consistency check).
  • rank — inherited; origin and region_shape are already uint64[rank].
  • shape — a region's shape is its region_shape; there is no separate nested shape field, so the "nested shape MUST equal region_shape" hazard cannot arise by construction.
  • element_type — inherited. A subpaving tensor has exactly one element type in v1.0; per-region element types are out of scope (a region's sparse values buffer uses the outer element type, index/pointer buffers use their layout-defined uint64, exactly as a top-level sparse tensor).
  • shard, statistics, extension_type — forbidden per region (see D5).

Each region is encoded as a fixed prefix followed by a length-delimited body:

Region prefix:
| origin              | uint64[rank] | region start index, inclusive            |
| region_shape        | uint64[rank] | region extent; every value > 0           |
| region_layout_tag   | uint8        | any valid layout tag (0x01–0x0B, 0x40,   |
|                     |              | 0xF0–0xFE); MUST NOT be 0x00/0xFF        |
| region_flags        | uint8        | bit 0 = HAS_REGION_QUANTIZATION;         |
|                     |              | bits 1–7 reserved, MUST be 0             |
| _reserved           | uint8[2]     | MUST be 0x00                             |
| region_body_length  | uint32       | byte count of the body that follows      |

Region body (region_body_length bytes):
| byte_offset         | uint64       | per the region layout's own byte_offset  |
|                     |              | rule (see D4)                            |
| layout-specific     | variable     | fields for region_layout_tag, encoded as |
|   fields            |              | in metadata.md § Layout-Specific Fields, |
|                     |              | tag byte omitted (recursive for 0x06)    |
| buffer_count        | uint8        | region sub-table size (see D2)           |
| buffer_handles      | 16 × count   | the region's own buffer handles          |
| quantization        | present iff  | uint32 length + quantization_descriptor  |
|   section           | flags bit 0  | bytes (see D5)                           |

The outer subpaving layout-specific field remains region_count: uint32 (> 0), followed by region_count region descriptors.

region_body_length generalises ADR-015's region_layout_length: it enables a reader that does not recognise a region's inner layout to skip the whole region body and continue parsing subsequent regions (permissive mode); a strict-mode reader MUST reject an unrecognised region layout tag.

D2 — Buffer binding: outer table empty, effective table is the flattened region sub-tables

The buffer properties of every region live in that region's sub-table inside its body; buffer locations continue to be supplied positionally by the transport. The binding rule:

  • The outer subpaving descriptor's top-level buffer table is empty: buffer_count = 0. This is a deliberate carve-out from the current metadata.md rule "buffer_count MUST be at least 1", which is amended to admit 0 for tag 0x06.
  • The tensor's effective buffer list is the depth-first, region-order concatenation of every region's sub-table, recursing into nested subpavings. For each region in region order: if the region layout is a leaf, emit its sub-table buffers in sub-table order (layout data buffers first, then quantization-parameter buffers per D5); if the region is itself a subpaving, recurse. This ordering is fully determined by the descriptor, contains no back-references, and is the order in which the file-format data region and streaming TENSOR_DATA frames lay the buffers down. Streamability is preserved.
  • Per-region sub-table size is exact. A region's buffer_count MUST equal the number of buffers its layout requires (dense = 1, COO = 2, CSR/CSC = 3, CSF = 2·rank+1, block-paged ≥ 3, private extension = whatever the region declares) plus the number of quantization-parameter buffers required by the region's active scheme when HAS_REGION_QUANTIZATION is set. A reader MUST reject a region whose sub-table is over- or under-supplied (this is F-2's bounds and no-dangling safety rules, reborn per-region).

The 255-buffer ceiling is fully dissolved. The uint8 cap now applies per region (≤ 255 buffers per region), while region_count is uint32. A subpaving of thousands of rank-3 CSF regions (7 buffers each) is representable; the effective buffer count is bounded only by region_count × 255, i.e. effectively unbounded. This resolves the CSF-exhaustion concern that made the per-region-buffer-list alternative (old Option B) unattractive.

D3 — Any layout tag may be a region layout (resolves F-1, F-4)

Because each region owns its buffers, the ADR-015-era restriction is removed: region_layout_tag MAY be any valid layout tag — dense (0x01–0x06, 0x40), sparse (0x07–0x0A), indirect (0x0B), or private extension (0xF0–0xFE) — subject to that layout's own rank and shape constraints validated against region_shape (e.g. a block-paged or CSF region forces the whole tensor to the rank that layout requires). A private-extension region declares its own buffer_count in its sub-table; the sub-table count is authoritative for extension layouts whose needs are otherwise out-of-band. The standard private-tag interoperability caveat (no cross-implementation exchange without out-of-band agreement) applies unchanged (F-4 resolved: permitted, with that caveat).

D4 — byte_offset (resolves F-10)

The outer subpaving descriptor's tensor-level byte_offset MUST be 0x0000000000000000 (there is no single first element at a fixed offset; element [0,…,0] is located through region lookup). Each region body carries its own byte_offset governed by that region layout's own rule: for dense region layouts it MAY be non-zero and MUST be ≤ the region's buffer-0 size; for sparse and indirect region layouts, and for a nested subpaving region, it MUST be 0, exactly as those layouts require at top level.

D5 — Per-region quantization falls out for free (resolves F-5)

A region MAY carry a quantization section in its body, gated by region_flags bit 0. The quantization.md § Buffer Table Placement Rules apply within the region's sub-table unchanged: the region's quantization-parameter buffers occupy the sub-table indices after its layout data buffers, MUST NOT alias the data buffer, and MUST share the region's device_tag and memory_class. Heterogeneous per-region quantization (different schemes in different regions) is therefore expressible in v1.0 at no extra machinery cost, because the quantization descriptor is carried as opaque length-prefixed bytes (as it already is at the top level).

HAS_SHARD, HAS_STATISTICS, and HAS_EXTENSION_TYPE are forbidden per region in v1.0 (region_flags bits 1–7 MUST be 0): a region is not independently a shard of a parent (the whole subpaving MAY be a shard), per-region statistics are deferred, and element type is inherited so per-region extension-type descriptors are meaningless.

D6 — Validation set

Coverage and non-overlap are unchanged (they are properties of origin/region_shape only, independent of region contents; the volume-sum coverage check remains valid). New normative rules:

  1. Each region's layout MUST validate against its own region_shape (validate_against_shape applied recursively).
  2. Each region's sub-table buffer_count MUST exactly equal its layout requirement plus its quantization-parameter requirement (D2). Unconditional MUST-reject on mismatch (memory safety).
  3. Device colocation is tensor-wide over the flattened buffer set. All buffers of all regions MUST share the same device_tag and memory_class (buffer-protocol.md § Device Colocation, applied to the effective buffer list). A heterogeneous-device subpaving is out of scope in v1.0 and noted as a future open question.
  4. Recursion depth: a subpaving region nested inside a subpaving increments depth; a reader MUST reject nesting deeper than 8 levels (the existing MAX_SUBPAVING_DEPTH / MAX_TILED_DEPTH guard, on both encode and decode).
  5. region_flags reserved bits and region _reserved bytes MUST be 0.

D7 — Addressing API is redefined to region-resolution + per-region delegation

A subpaving element address can no longer be a single pure-arithmetic offset, because a sparse or indirect region's value lookup is data-dependent: locating element [i,j] in a COO region requires searching that region's indices buffer contents, yielding either values[p] or an implicit zero — this cannot be expressed as a byte offset without reading buffer bytes, which the descriptor-only addressing layer does not possess. Therefore:

  • SubpavingLayout::locate_element is redefined to resolve the containing region and the local index within it (pure arithmetic, recursing through nested subpavings), returning a handle: { region_index, local_index, &RegionDescriptor }.
  • For a dense region, addressing then returns the element's byte offset within the region's sub-table buffer 0 (via the region layout's existing element_offset).
  • For a sparse or indirect region, addressing returns a "requires buffer lookup" result carrying the region index, local index, and the region's buffer sub-table; the caller (a higher layer that holds actual buffer memory) performs the value lookup using that layout's Element Lookup algorithm (coo.md/csr.md/csc.md/csf.md/block-paged.md).

This is consistent with the existing model: top-level sparse tensors already return Error::LayoutRequiresMultiBuffer from pure-offset addressing. Subpaving simply delegates per region. It is nonetheless a genuine public-API change to SubpavingLocation and is called out as the single largest code cost below.

Alternatives Considered

Dense-only inner-layout whitelist (prior draft's Option A). Restrict region_layout_tag to dense tags and defer sparse-in-subpaving. Rejected by decision: it forecloses the heterogeneous-sparsity array-database use case the project explicitly wants in v1.0.

Per-region buffer list (Option B): replace buffer_index with buffer_index_count + buffer_index[] indexing a single flat outer buffer table. Rejected: the flat outer table is uint8-counted, so the 255-buffer ceiling caps the whole tensor — a handful of CSF regions exhaust it. Nesting per-region sub-tables (this ADR) moves the cap per-region and dissolves it. Option B also still could not carry per-region quantization without further extension.

Packed single-buffer sub-format (Option C): concatenate a sparse region's component arrays into one buffer slice at computed offsets. Rejected: it re-creates the "single buffer with offsets" approach ADR-002 rejected for sparse — heterogeneous element types in one slice, computed sub-offsets that break the 64-byte per-component alignment guarantee, and loss of independent zero-copy component sharing.

Byte-for-byte full TensorDescriptor per region. Rejected in favour of the trimmed descriptor-tail profile (D1): repeating magic/version/rank/element_type/shape per region wastes bytes across potentially millions of regions and manufactures a "MUST equal the outer value" consistency check for each repeated field. The trimmed profile inherits those fields and keeps only what genuinely varies per region (layout, byte_offset, buffers, quantization).

Keep the outer buffer table non-empty as the flattened list. Rejected: it would place the flattened list back under the uint8 outer count, reinstating the 255 ceiling. The outer buffer_count = 0 carve-out (D2) is the price of an unbounded effective count.

Consequences

Positive

  • Heterogeneous-sparsity tensors — dense tiles, sparse blocks, and paged blocks in one logical tensor — are first-class in v1.0, serving the array-database vision.
  • The 255-buffer ceiling is dissolved for subpaving (per-region cap, uint32 region count).
  • Per-region quantization and per-region layout diversity fall out of one mechanism; F-1, F-2, F-4, F-5, F-10 are all resolved coherently.
  • Streamability, self-delimitation, and no-back-reference properties are preserved: the effective buffer order is a pure function of the descriptor, laid down in region order.

Negative / obligations created

  • New wire format for regions. ADR-015's RegionDescriptor encoding (buffer_index + region_byte_offset + region_layout_length + layout-only payload) is replaced by the descriptor-tail profile. ADR-015 is superseded. Every existing subpaving descriptor byte layout, doc-comment, and round-trip test is invalidated.
  • metadata.md invariant relaxed. "buffer_count MUST be at least 1" gains a subpaving exception (0). Every reader that assumes ≥ 1 for all layouts must special-case 0x06. TensorDescriptor::new/decode's EmptyBufferTable check must exempt subpaving.
  • Transport must flatten. The file-format reader/writer, the index data_length computation, and the streaming TENSOR_DATA walk must iterate the effective (flattened) buffer list for subpaving rather than the top-level table, and must handle > 255 effective buffers.
  • Addressing API redesign (D7): SubpavingLocation changes from a pure offset to a region-resolution enum; sparse/indirect regions return a "requires buffer lookup" result. Downstream callers of locate_element must adapt.
  • Codec layering change. decode_region/encode_region must now encode a buffer sub-table and an optional quantization section inside the layout payload. Buffer-handle codec and quantization-section codec currently live in the descriptor-level encode.rs/decode.rs; they must be factored into shared helpers that layout_codec.rs can call. layout_codec gains a dependency on the buffer-table codec.
  • Per-region wire overhead. Each region costs 16·rank bytes (origin + region_shape) plus the body; for tensors with millions of regions this is significant. This design trades wire compactness for generality and streamability.

Risks

  • Effective-buffer flattening bugs. The flattening order is load-bearing (it defines the on-disk / on-wire data order). Mitigation: a single normative flattening algorithm in memory-layout.md, one shared implementation, and round-trip tests through the file format with mixed dense/sparse regions.
  • Naive reader mis-reads buffer_count = 0. A reader that does not understand tag 0x06 sees an empty top-level table; it MUST already reject unknown layout tags in strict mode, and MUST NOT dereference data in permissive mode, so 0 is safe.
  • Device-colocation over-restriction. Tensor-wide colocation forbids per-region devices; the array-DB use case may eventually want per-region device placement. Deferred as an OQ, not closed off (an additive relaxation under the evolvability contract).

Compatibility Impact

During the pre-1.0 draft period this redefines the region wire format and relaxes the buffer_count >= 1 invariant for tag 0x06. No previously interpretable descriptor is silently changed (sparse/private inner regions were never interpretable). Under ADR-019, the additive features left open here (per-region statistics, per-region element type, heterogeneous-device subpaving) arrive later as gated additive changes without rebinding any 1.x value. Supersedes ADR-015.

Date

2026-07-05

ADR-027: Composite Tensors — Head + Members + Composition Rule

Status

Accepted — scope limited to partition, group, and sealed overlay.

Versioned overlay is descoped from v1.0, deferred to a future ADR: member_version, the 0xFFFFFFFF open-composite sentinel, file append + footer regeneration for overlays, and time-travel reads. Rationale: that machinery's value is driven almost entirely by the array-database vision, which is explicitly long-term/not-current-sprint; it introduces unbounded, indefinitely-open cross-descriptor state (no closing verdict, ever) versus the bounded, ADR-026-precedented state partition/sealed-overlay already require; and it adds file-mutation (append + footer regen) and version-monotonicity machinery to Layers 5–8 for a use case not yet on the roadmap. Sealed overlay (definite count, stream-order precedence, no member_version) ships now — it is cheap and delivers SpQR/KVQuant-style outlier quantization, an immediate, published use case. See the sections below, each annotated where content was trimmed accordingly.

Supersedes: ADR-026 (Subpaving Nested Region Descriptors) — see § Consequences Amends the deferral scope of: ADR-010 (Multi-Tensor Collections Deferred)

Amended 2026-07-23 (layout-tag renumber): The composite head's layout tag is reassigned 0x0C → 0x0B. The General Subpaving layout (former tag 0x06) was retired from v1.0 entirely, and the tags that followed it — COO through Composite — were shifted down by one to keep the Tier-1 named layout range contiguous (0x01–0x0B, no hole), since Hurray is pre-release with no compatibility obligation. The head is therefore now a named Tier-1 tag (0x0B), not a tag borrowed from the reserved range 0x0C–0x3F; and tag 0x06 is now permanently COO's. D1 and § Disposition of ADR-026 below are updated to match. Other in-text references to 0x0C (as the head tag) and to "inline 0x06" compaction elsewhere in this ADR predate this amendment and are retained as the historical record, superseded by this note.

Context

Three capabilities that Hurray has treated as distinct are, on inspection, one idea seen from three angles:

  • Subpaving (layout 0x06, ADR-026): one logical tensor whose index space is a partition of heterogeneous regions, each with its own layout, buffers, and (per ADR-026 D5) quantization.
  • Sharding (shard section, ADR-004): a tensor that declares itself a rectangular sub-region (shard_offset + shape) of a larger logical parent_shape.
  • Tensor grouping (ADR-010, deferred): several tensors delivered together under one logical identity (multi-output inference; weight collections).

ADR-026's design work drove the recognition. To make sparse/paged regions work, ADR-026 had to (a) let regions carry their own buffer sub-tables, (b) invent a buffer_count = 0 head carve-out, and (c) forbid or defer per-region statistics, per-region element type, and per-region device placement (D5). But a region that carries its own layout, buffers, and quantization is very nearly a full tensor descriptor, and a region positioned in a parent index space is exactly a shard. If a region simply were a full tensor descriptor with a shard section, every capability ADR-026 had to hand-build — and several it had to forbid — would fall out of machinery that already exists (ADR-004 shard section; ordinary member descriptors carry dtype, layout, buffers, quantization, statistics, and device tags for free).

Prior art (docs/prior-art.md § 8) confirms two composition models a single-partition layout cannot span:

  • Partition (exact-cover, non-overlap): AMReX/Chombo DisjointBoxLayout, OpenVDB tiles, HDF5 Virtual Datasets. This is what subpaving implements.
  • Overlay (overlapping composition: a base spanning the space plus scattered corrections at shared indices): SpQR / KVQuant outlier quantization, and — the case that makes overlay strategically important — TileDB timestamped fragments, i.e. tensor data versioning: region/partial updates and time-travel reads over a fixed index space. This directly serves the array-database vision.

This ADR unifies subpaving, sharding, and grouping under one primitive — the composite tensor — and adds the overlay model. Originally proposed (2026-07-07 Draft) to also specify versioned overlay (partial updates + time travel) as a first-class v1.0 feature; on review (see § Status) that half is descoped to a future ADR, since the array-database use case it serves is explicitly long-term rather than current, and it is the one part of this design that introduces unbounded, file-mutating state. What ships in v1.0 is partition, group, and sealed (non-versioned) overlay.

Constraints preserved: streamability (descriptors precede data; self-delimiting; no back-references; no end-of-file index); zero-copy; 64-byte alignment; language-agnostic naming; RFC 2119; the ADR-017/019 extensibility and evolvability contracts (for future, post-1.0 additions — everything in this ADR's v1.0 scope ships in the initial v1.0 format, as the format is pre-release).

Decision

A composite tensor is a head descriptor plus an ordered set of member tensors, combined by a declared composition rule.

D1 — The head is a virtual (data-less) tensor descriptor under layout tag 0x0B

Amended 2026-07-23: head tag 0x0C → 0x0B (see § Status).

The head is an ordinary tensor descriptor with layout_tag = 0x0B ("Composite / Virtual", Tier 1, a new addressing category Virtual alongside Dense / Sparse / Indirect). It:

  • carries the composite's logical shape (shape) and logical element type (type_tag) — the view the composite presents to a consumer;
  • owns no data: buffer_count MUST be 0 and byte_offset MUST be 0.

Tag 0x0B is a named Tier-1 layout tag, not one borrowed from the reserved range: it was assigned when the General Subpaving layout was retired from v1.0 and COO through Composite shifted down by one to close the gap (see § Status). Because Hurray is pre-release, it is allocated as part of the initial v1.0 format (no version-increment ceremony). A strict reader rejects an unrecognised 0x0B; a permissive reader may read the head's shape and dtype but MUST NOT dereference data (there is none).

The head's layout-specific fields encode the composition rule:

FieldTypeDescription
composition_ruleuint80x01 partition, 0x02 overlay, 0x03 group. 0x00 and 0x04–0xEF reserved; 0xF0–0xFE private; 0xFF invalid.
combine_opuint8Overlay only: 0x01 replace (last-wins), 0x02 add. MUST be 0x00 for partition and group.
_reserveduint8[2]MUST be 0x00.
member_countuint32Number of member tensors that immediately follow. v1.0: MUST be a definite count for all composition rules, including overlay. The sentinel 0xFFFFFFFF (an open composite, see D3) is RESERVED — a strict v1.0 reader MUST reject it; open composites are deferred to a future ADR.

D2 — Members are ordinary tensor descriptors positioned by the shard section

A member is a complete, ordinary TensorDescriptor (its own layout — dense, sparse, paged, or a nested composite — its own buffers, quantization, statistics, device tags). For partition and overlay composites, each member MUST carry a shard section (ADR-004; metadata.md § Shard Section) whose parent_shape equals the head's logical shape; the member's shard_offset and its own shape define its box in the head's index space. For group composites, members MAY omit the shard section.

Overlay members additionally carry a Composite Member section (a new optional descriptor section gated by descriptor flag bit 4, HAS_COMPOSITE_MEMBER, appended after the Extension Type section):

FieldTypeDescription
member_roleuint80x00 correction, 0x01 base. 0x02–0xFF reserved (see § Deferred: tombstones).
_reserveduint8[15]MUST be 0x00.

v1.0 carries member_role only. member_version is deferred: a sealed overlay's precedence is plain stream/emission order (last-wins under combine_op = replace), so no explicit version field is needed until versioned overlay (time-travel) is taken up. The reserved padding leaves room for a future ADR to add member_version as an additive field under the ADR-017/019 evolvability contract, without reallocating the section.

Partition and group members do not carry this section. This is the crux of the unification: a region ≡ a shard ≡ a member. Per-member layout, buffers, quantization, statistics, and device placement all come from the ordinary descriptor machinery — including the three things ADR-026 D5 had to forbid or defer.

Plain sharding (ADR-004 / interchange parallel transfer) is the status quo: members without a head. The head upgrades an ephemeral shard set into a persistent, composition-typed collection.

D3 — Binding: forward stream adjacency, no new namespace, all v1.0 composites definite-count

A head with a definite member_count = N binds the next N self-delimiting tensors in stream / file write order as its members. This is a forward promise (head precedes members precede their data), not a back-reference, so it is streamable for readers and writers and introduces no name namespace. It works uniformly across transports:

  • In-process: the head handle plus an array of member handles (in-memory; no wire concern).
  • IPC / network streaming: the head's TENSOR_DESCRIPTOR, then each member's TENSOR_DESCRIPTOR → TENSOR_DATA → TENSOR_DATA_END.
  • File: the head, then the members' descriptors+data, written contiguously in the tensor region; every tensor gets a footer-index entry; membership is recovered from the head's member_count plus descriptor-offset order (preserved regardless of SORTED_INDEX).

Open composites — deferred. The 0xFFFFFFFF open-composite sentinel and its append-oriented membership-delimitation rules (maximal run of Composite-Member-tagged tensors; file tensor-region delimitation between heads) are reserved for a future ADR (versioned overlay). All v1.0 composites — partition, group, and overlay — use a definite member_count, bound uniformly by the forward-adjacency rule above.

Nested composites are permitted for definite-count composites (pre-order parse, depth cap 8). Explicit member identifiers for out-of-order random access are not defined in this version (see Deferred).

D4 — Composition semantics

Partition (0x01). Members' shard boxes MUST exactly cover the head's index space with no overlap (ADR-026 D6 validation, evaluated across members). Each logical index belongs to exactly one member, so the composite view is zero-copy: value lookup = box selection + that member's own addressing. This is subpaving semantics as a collection.

Overlay (0x02, v1.0: sealed only). One member is the base (member_role = 0x01, the first member, box spanning the whole space); the rest are corrections whose boxes MAY overlap. See D6 for precedence and reads. Per-member storage is zero-copy; the merged logical view is computed by the consumer (exactly as SpQR/KVQuant-aware and array-DB kernels operate).

Group (0x03). Unordered, no spatial semantics; members are independent tensors under one head identity (multi-output inference). The head's shape/type_tag are advisory; members MAY differ arbitrarily. This occupies ADR-010's grouping gap using adjacency, not naming.

Element type across members. For partition and overlay, each member MAY declare a different stored element type and its own quantization, but each member's decoded value type MUST equal the head's type_tag. Example (SpQR): head type_tag = float16; base = int4 + per-block quantization → decodes to float16; outlier correction = float16 sparse (COO). Overlay combine happens in float16. Dequantization already yields a canonical real-valued view, so this needs no new machinery. Composite versioning changes values over a fixed index space; shape evolution is out of scope.

D6 — Sealed overlay: precedence, combine, and reads (v1.0)

Precedence (single-pass friendly, no explicit version). Precedence is emission/stream order — later wins: writers emit the base first, then corrections in the order they take effect. A single-pass reader applies members as they arrive; no version field or reordering is needed. (A future ADR may reintroduce an explicit member_version for time-travel — see Status.)

Reads. Apply the base, then all corrections in emission order, under combine_op (replace: the topmost member covering an index wins within its box; add: base plus the sum of covering corrections), evaluated in the head's element type. A correction's box replaces/adds within its box only; outside it, lower-precedence members (down to the base) show through.

combine_op for v1.0. Both 0x01 replace and 0x02 add are defined. Replace is the default (a region overwrite); add serves residual/outlier overlays (e.g. SpQR/KVQuant).

Sealed only. A v1.0 overlay is a complete snapshot: definite member_count, not appendable without rewriting the head (D3). Versioned/appendable overlay is deferred.

Deletes. Logical delete (reverting a region to base, or masking it) is out of scope for v1.0, stated explicitly. A region is reverted by writing a new correction carrying the desired values. A tombstone member kind (member_role = 0x02, data-less, revert-to-base- within-box) is reserved for a future addition (the role byte is already present).

D5 — Validation: cross-member, stateful, bounded

Per-member checks are immediate: shard parent_shape == head shape; box in bounds; decoded dtype == head type_tag; combine_op legal for the rule; for overlay, the Composite Member section is present with a valid member_role; the first overlay member is the base (role = 0x01) and spans the index space.

Close-time checks depend on the rule:

  • Partition (definite count): on receiving the Nth member, run exact-cover + non-overlap over the N boxes (ADR-026 D6: volume-sum + sweep/pairwise). Overlap or a gap → reject.
  • Sealed overlay (definite count): close at the Nth member; base-spans is checked at the first member; overlap is legal; no exact-cover.
  • Group (definite count): close at the Nth member; no coverage or overlap check (members MAY differ arbitrarily, D4).

All v1.0 composition rules use a definite count, so all validation is bounded: a reader accumulates state only up to the known N, reaches one verdict at the Nth member, and is done. (Open, unbounded, every-prefix-valid overlay validation is deferred — see Status.)

Failure semantics: a per-member violation MUST cause rejection of the composite (network stream: ERROR + close). A torn definite-count composite (stream ends before N members, of any composition rule) is incomplete: a strict reader MUST reject it; a permissive reader MAY expose the arrived members as independent shard tensors but MUST NOT present the composite as complete.

Alternatives Considered

Head via a HAS_COMPOSITION flag on an ordinary descriptor (no new tag). Rejected: the head has no data, so any real layout_tag would misdescribe it; a Virtual tag 0x0C with buffer_count = 0 is honest and reuses the descriptor frame.

A new message/container kind outside the tensor descriptor. Rejected: breaks the "everything is a self-delimiting tensor descriptor" uniformity and forces new framing in interchange.md and file-format.md.

Explicit group IDs (a group_id namespace) as the binding. Rejected for v1: reintroduces the namespace ADR-010 warned against; forward adjacency binds streamably without it. Left as a Deferred item for out-of-order random access.

Wall-clock timestamps as the version axis (TileDB-style). Considered for a future versioned-overlay ADR; not part of v1.0 (no version axis ships at all — see Status). A writer-controlled logical sequence number would be simpler, reproducible, and skew-free than wall-clock time if/when versioning is taken up; wall-clock time, if needed, would go in KV metadata.

Explicit member_version for v1.0 (implicit version = position, rejected in the other direction). Considered and descoped: an explicit version field would give a stable, addressable time-travel key, but time-travel is not a v1.0 feature, so the field would be dead weight. v1.0 uses plain emission order (D6). Left as future work alongside the open sentinel.

Sealed fixed-count composites only, with versioning via external file conventions. This is effectively what v1.0 ships (sealed-only overlay, no version axis). A true append-only version log (open sentinel, immutable head, footer-regeneration append) remains the better design if and when versioning is taken up — descoped here, not rejected outright.

Keep ADR-026's trimmed "descriptor-tail" region profile for inline 0x06. Rejected in favour of a region being a full nested descriptor with a shard section, so inline-region ≡ member exactly.

Leave subpaving (ADR-026) and composites as two mechanisms (composites in 1.1). Rejected: two overlapping mechanisms would diverge; the value here is unification.

Consequences

Disposition of ADR-026

ADR-026 is marked Superseded by ADR-027. Its durable insights survive and are generalised: nested descriptors, the data-less head, and full per-region capability. What changes:

  • The bespoke ADR-026 D1 "trimmed region tail" wire is dropped. A partition composite's regions are members = full tensor descriptors with shard sections (the "uniform full-descriptor variant"). This removes the trimmed-profile consistency rules, the bespoke addressing-API redesign, and the layout_codec→buffer/quant relayering ADR-026 required.
  • ADR-026 D5's forbidden/deferred items (per-region statistics, per-region element type, per-region device) become supported for free (a member is an ordinary descriptor).
  • Inline subpaving compaction is dropped, and tag 0x06 is permanently reassigned to COO (amended 2026-07-23; see § Status). ADR-026's inline single-frame compaction of a partition composite is no longer associated with tag 0x06 and is not available for any future subpaving-compaction use; reviving that idea would require a fresh layout tag allocated from the reserved range 0x0C–0x3F. The region ↔ member equivalence it relied on (inline region origin ↔ member shard_offset; region within head shape ↔ member parent_shape) survives conceptually, but the collection form (members) is the v1.0 deliverable for partition.

Net: ADR-027's core is smaller than ADR-026's would have been (reuses the shard section and ordinary members), at the cost of a persistent grouping concept threaded through the transport layers (below).

Reopening ADR-010 (addressed head-on)

ADR-010 deferred a named/indexed archive container; ADR-027 is a streamable composition primitive, not that. Against ADR-010's four deferral reasons: (1) no string names/uniqueness policy — binding is member_count + adjacency; (2) no general namespace — a composite is a flat, bounded, single-level (optionally nested, depth-capped) grouping tied to one head; (3) the header/footer-index-vs-streamability tension — resolved by forward adjacency (no header index, no footer index, no back-reference); (4) no KV-metadata pressure — composites carry composition structure, not arbitrary metadata. ADR-010's core decision (no named hurray-archive in v1) stands; ADR-027 fills only the grouping gap, by composition.

Scope, layers touched, and schedule cost (honest)

Lands in v1.0 (spec + hurray-core): the 0x0C head descriptor; the composition-rule payload codec; the HAS_COMPOSITE_MEMBER section and its codec (member_role only); shard-based member positioning (shard section already exists); the cross-member stateful validator (a CompositeValidator accumulating member boxes, bounded to a definite count); and the sealed-overlay read model (current view only) as a specified semantic (the merge itself is a consumer concern). This is a bounded addition to the Layer-4 core; it does not materially delay Layers 0–4.

Staged with their layers (not pulled forward):

  • Layer 5 (streaming): head→member adjacency; composite "close" (definite counts) on the Nth TENSOR_DATA_END; reuse of shard-consistency validation.
  • Layer 6 (file): head + members as consecutive index entries.
  • Layer 7 (FFI): a composite handle kind (head handle + member iterator).
  • Layer 8 (Python): a CompositeTensor view; __dlpack__ per member; overlay current view materialised on demand.

Deferred (Open Questions): versioned/open overlay in full — member_version, the 0xFFFFFFFF sentinel, append + footer-regeneration, time-travel reads (see Status; the primary deferral); explicit member IDs / out-of-order random access; wall-clock timestamp versioning; tombstone / logical-delete (member_role = 0x02); inline 0x06 compaction; nested composites inside open overlays; heterogeneous per-member device placement.

Schedule statement: ADR-027 replaces (does not add to) the ADR-026 partition implementation budget and is smaller there. With versioned overlay descoped, it introduces a persistent grouping concept (bounded, definite-count binding + close-time validation) across Layers 5–8, but not the open-ended file append/footer-regeneration or version-cutoff read paths — those are deferred with the feature that needed them. The v1.0 transport/binding cost is therefore comparable in shape to partition's, not materially larger.

Positive

  • One primitive unifies subpaving, sharding, and grouping; overlay (SpQR/KVQuant) is first-class as a sealed snapshot.
  • Per-member dtype/layout/quantization/statistics/device come from existing machinery.
  • Streamable (forward adjacency), zero-copy per member, no new namespace.
  • Bounded, definite-count validation for every v1.0 composition rule (D5) — no open-ended state, no file-mutation story, kept out of v1.0 until actually needed.

Negative / obligations

  • A persistent, cross-descriptor, stateful validation + framing concept (head→member binding; partition's coverage check; sealed-overlay's base-span check) is new to Layers 5–8, though bounded to a definite count in every case.
  • Overlay's merged view is consumer-computed, not zero-copy at the composite level.

Risks

  • Overlay misread as zero-copy — mitigated by the explicit "structure described, merge computed" statement (D4/D6).
  • Scope creep into a general versioned DB — mitigated directly by descoping the version axis, the open sentinel, and file append/footer-regeneration from v1.0 entirely (see Status), not merely by scoping out deletes and wall-clock timestamps as before.
  • Deferred work resurfaces as a rushed addition later — versioned overlay is real, array-DB-vision-serving work, not abandoned; when it's picked up it should get its own ADR, research pass, and spec-checker audit rather than being reconstituted ad hoc.

Compatibility Impact

Hurray is pre-release; the scope of this ADR that ships within the initial v1.0 format is: tag 0x0C, descriptor flag bit 4 (HAS_COMPOSITE_MEMBER) and its section (member_role only), the buffer_count = 0 head rule, and combine_op — for partition, group, and sealed overlay only. No minor-version increment is involved. The 0xFFFFFFFF open-composite sentinel and member_version are RESERVED, not usable in v1.0, and are deferred to a future ADR alongside the rest of the Deferred list (member IDs, tombstones, inline 0x06, wall-clock timestamps) — each an additive minor under the ADR-017/019 evolvability contract that rebinds no v1.0 value. Supersedes ADR-026; leaves ADR-004 and ADR-010's core decisions intact.

Date

2026-07-07 (Draft); scope narrowed and Accepted 2026-07-23 (versioned overlay descoped — see § Status)

ADR-028: Documentation Website — mdBook + Zola, Per-Tag Versioning, GitHub Pages

Status

Accepted.

Context

Hurray needs a public HTML site, in the mould of arrow.apache.org: a mostly-technical site targeting reference-implementation users, format implementers, and ML/inference engineers. It must carry two kinds of content:

  1. Versioned technical documentation — the format specification (docs/spec/), implementation requirements (docs/impl/), cookbook (docs/cookbook/), tutorials, and the Rust API reference (cargo doc) for the hurray-* crates.
  2. An unversioned outer site — landing/overview, FAQ, a blog (posts by core contributors), and a community section (contributing guidelines, code of conduct, governance, mailing lists).

Confirmed requirements from the project owner:

  • Versioning is by spec version, which git tags/releases follow, and the full history must be browsable — not just the two or three most recent versions.
  • Fully automated in CI, and simple. Content must be easy to reorganize and extend.
  • GitHub Pages initially; a custom domain will come later (none is owned yet); the host may change — the pipeline must stay portable.
  • Default landing on the latest stable release, with the unreleased development version reachable but clearly marked.
  • Per-version search is sufficient for v1.
  • The Rust API reference is in scope and published alongside each version.

The project is a Rust workspace with a strong "single binary, no supply chain" ethos. At the time of writing there are zero release tags and the spec is 0.1.0-draft.

Decision

Build the site from two Rust single-binary generators, deployed together to GitHub Pages as one static tree:

  • mdBook renders the versioned technical book (spec + impl + cookbook + tutorials). The book is a view over the existing in-repo Markdown (authored in place under docs/), curated by a maintained SUMMARY.md, so reorganizing content stays a Markdown-and-table-of-contents edit.
  • Zola renders the unversioned outer shell (landing, FAQ, blog, community). Markdown plus a folder-per-section layout keeps adding content trivial.

Versioning strategy — build per git tag. CI reconstructs the full multi-version tree on every deploy: for each release tag it checks out that tag and builds its book and API reference into a version-scoped path; main is additionally built as the dev version. A generated versions.json manifest drives a version-selector dropdown injected into the book. This maps one-to-one onto "spec versions which git tags follow, full history," keeps main free of frozen doc snapshots, and renders every version faithfully from its own tag.

Rust API reference is produced by cargo doc per version and published under that version's path, linked from the book's navigation.

Hosting is GitHub Pages via GitHub Actions. Because the generators emit a plain static tree and nothing depends on Pages-specific features, the same artifact deploys to any static host later.

Concrete URL scheme, directory layout, CI pipeline stages, the versions.json schema, and the content model are specified in Documentation Website Spec.

Alternatives Considered

  • Docusaurus (single tool for everything). Docs + native versioning + blog + search + landing under one theme is the most turnkey option and was the strongest single-tool candidate. Rejected as the default for two reasons: (1) it requires a Node/npm toolchain, against the project's single-binary, minimal-supply-chain ethos; (2) its versioning snapshots docs into the main branch (versioned_docs/), which bloats main as history grows and does not build from tags — a poor fit for "versions are git tags, full history." It remains the fallback if a single unified theme ever outweighs tag-faithful history.

  • Starlight (Astro). Similar unified-theme appeal, but still a Node toolchain, and its versioning is a third-party plugin that is also snapshot-based. No advantage over Docusaurus for our constraints.

  • mdBook only, minimal (defer blog/community/FAQ). Simplest, but does not deliver the Arrow-style outer site the owner asked for. Rejected as under-scoped.

  • Zola only (book as a Zola section). One tool, single binary, but re-implements the book's sidebar/nav/search UX that mdBook gives for free and would require fully custom versioning. More work for a worse book experience.

  • Snapshot-in-repo versioning (regardless of tool). Copy current docs into a per-version folder committed to main on each release. Rejected: bloats main, makes historical edits awkward, and is less faithful than rebuilding from the tag itself.

  • Incremental version builds (only build the new/changed version each deploy). Faster than full rebuild-from-tags as history grows, but stateful (must carry prior builds forward). Deferred as a future optimization; v1 rebuilds all versions each deploy for a stateless, simpler pipeline.

Consequences

  • Two themes to keep visually consistent. The mdBook book and the Zola shell are themed independently; a modest, ongoing investment is needed to keep them coherent. Apache Arrow accepts the same split (Jekyll site + Sphinx docs); this is a known, tolerable trade-off.

  • Build time grows with tag count. Rebuilding every tag's book and cargo doc on each deploy is O(number of releases). Acceptable at current scale; the incremental-build optimization above is the escape hatch when it hurts.

  • No stable version until the first tag. With zero release tags today, only dev exists initially; /docs/stable/ and the default "Docs" entry point MUST fall back to dev until the first release tag is cut. The spec defines this fallback.

  • Search is per-version. mdBook's built-in search covers a single book; there is no cross-version or whole-site search in v1. Revisit if users need it.

  • A new authoring surface appears: docs/tutorials/ and a maintained book SUMMARY.md. The ADRs (docs/adr/) and docs/prior-art.md are published as a book appendix in v1. Release tags use MAJOR.MINOR.PATCH with no leading v. The doc-site spec owns these conventions.

  • New CI responsibility. A GitHub Actions workflow builds and deploys the whole tree; it needs full git history/tags (fetch-depth: 0) and Pages deploy permissions.

  • Relationship to the array-database vision. A browsable full-history spec site is directly useful to the long-term versioned-data direction; nothing here forecloses it.

ADR-029: hurray-python is interchange-first — drop the Array API conformance claim

Status

Proposed (2026-08-06)

Supersedes ADR-022 (hurray-python Runtime Compliance Modes).

Context

hurray-python currently declares itself a strict reference implementation of the Python Array API Standard for Tier 1 element types (see docs/impl/python-bindings.md and ADR-022). A review of the actual surface and of the standard's conformance model shows this claim is not tenable and does not serve the format's purpose:

  1. The claim is unmet. hurray-python implements the Array API's creation and inspection surface (and returns the hurray module from __array_namespace__ advertising version 2025.12), but implements none of the mandatory compute core — no elementwise functions, reductions, manipulation, linear algebra, searching/sorting/set functions, indexing (__getitem__), or operator dunders. A consumer that obtains the namespace via __array_namespace__() and calls, e.g., xp.reshape, xp.sum, xp.matmul, or uses x[0], fails.

  2. Partial implementation is only sanctioned along designated seams. The standard permits omitting the optional extensions (linalg, fft — "Each array library supporting this standard may, but is not required to, implement an extension") and negotiating capabilities/dtypes/devices (__array_namespace_info__().capabilities()). It does not sanction dropping the mandatory core while still presenting a conforming namespace. Conformance is defined operationally by the array-api-tests suite, which exercises the full specified surface.

  3. Exposing __array_namespace__ without the core is actively harmful. Array-agnostic consumers (scikit-learn, SciPy's array-api support, einops, …) treat __array_namespace__ as the promise that the full core exists; a hurray.Tensor breaks them at runtime rather than being cleanly rejected.

  4. Compute is not hurray's purpose. hurray-python is the Python face of the Hurray interchange format — a codec and zero-copy bridge (produce / consume / hand off), in the same spirit as the Python packages of other data formats. It is not, and should not become, a numerical library. Numerical work belongs to the frameworks the buffer is handed to (NumPy, PyTorch, JAX, …).

  5. DLPack is not the Array API. DLPack is an independent, header-only ABI + PyCapsule protocol that the Array API merely adopts. from_dlpack works on any object exposing __dlpack__ without requiring __array_namespace__. The valuable zero-copy interop hook and the conformance claim were never coupled; dropping the claim does not cost us DLPack reach.

  6. The runtime modes exist only to serve the claim. ADR-022's strict/relaxed modes (set_strict/is_strict/strict/relaxed, modes.rs) exist for the sole purpose of gating __array_namespace__ visibility by dtype tier (Tier 1 vs Tier 2 / quantized). With the claim removed, they gate nothing.

Decision

hurray-python is positioned as interchange-first: a codec and zero-copy bridge for the Hurray format, not an Array API implementation.

  1. Drop the Array API conformance claim. hurray-python MUST NOT describe itself as an Array API implementation, reference implementation, or conforming namespace.

  2. Remove __array_namespace__. hurray.Tensor MUST NOT implement __array_namespace__ (for any dtype tier). The hurray module is not an Array API namespace.

  3. Remove the runtime compliance modes. set_strict, is_strict, strict, relaxed, StrictCtx, RelaxedCtx, and the _strict_mode carrier are removed. ADR-022 is superseded. (Pre-1.0, no compatibility guarantee applies; see docs/spec/versioning.md.)

  4. Keep the interchange and producer/consumer surface, which never depended on the claim:

    • Zero-copy interop protocols: __dlpack__ / __dlpack_device__, __array__ / __array_interface__, and the native __hurray_buffer__ / from_hurray_buffer.
    • Structural/inspection surface on Tensor: shape, dtype, device, ndim, size, T.
    • Construction and ingest: zeros/ones/full/empty(+_like), arange/linspace/eye, asarray, from_dlpack, from_numpy, from_torch, to_torch, from_scipy, save/load. These are framed as standalone interop protocols, not as Array API surface.
  5. Dtype identity. Tier 1 and Tier 2 / quantized dtypes remain first-class hurray.dtype.* objects. They are no longer described in terms of "Array API dtype" mapping; the NumPy/DLPack dtype correspondence is documented purely as an interop detail (what a given Hurray type becomes when handed to NumPy/PyTorch, and which types cannot cross a given bridge — e.g. bool over DLPack).

  6. Validation. The conformance anchor for hurray-python is the shared golden test-vector corpus (conformance/vectors/, cross-checked Rust ↔ Python) plus the binding's own unit/integration tests. array-api-tests is not used — it targets a whole conforming namespace and presupposes the compute core, which is the wrong shape for a producer/consumer-only surface.

Consequences

Positive

  • The binding's advertised behaviour matches its actual behaviour; no consumer is misled by __array_namespace__.
  • Smaller, more coherent surface; the modes machinery and its thread-safety caveats disappear.
  • DLPack / __array__ reach to NumPy/PyTorch/JAX/CuPy is fully retained.
  • Positioning is honest and defensible: "Array-API-interoperable (via DLPack), not Array-API-implementing."

Negative / cost

  • User-facing API removal: __array_namespace__ and the set_strict/relaxed family are gone. Acceptable pre-1.0 (no compatibility guarantee), but must be called out in the changelog.
  • A consumer that wants an Array API namespace from Hurray data must first hand the buffer to a real backend (zero-copy), e.g. xp = array_namespace(np.from_dlpack(t)). This is documented as the recommended pattern.

Follow-up work (sequenced; each user-approved)

  1. This ADR.
  2. Spec / impl docs: docs/impl/python-bindings.md (rewrite the normative Array API sections), docs/impl/README.md, docs/spec/element-types.md and docs/spec/README.md (reframe Tier-1 "Array API dtype" language as interop), docs/SUMMARY.md.
  3. Code: remove __array_namespace__ and modes.rs; keep interop; drop dependent tests.
  4. Cookbook / examples: retire or rewrite docs/cookbook/hurray-python-array-api.md and docs/cookbook/hurray-python-runtime-modes.md, and the examples/array_api.py example; refresh hurray-python/COMPAT-MATRIX.md.

Notes (non-normative)

The docs/impl/python-bindings.md Rationale section (added 2026-08-06) already states the interchange-first motivation; this ADR makes it normative and reconciles the surrounding requirements.

ADR-030: Multi-buffer native buffer protocol — one capsule carrying a HurrayBufferList

Status

Proposed (2026-08-11)

Resolves the Multi-buffer tensors open question deferred in ADR-023 (hurray-python Native Buffer Interchange Protocol) and supersedes its initial recommendation of a separate __hurray_sparse_buffer__ protocol.

Context

hurray-python can hold, transport, save, and load exactly one buffer per tensor, while the format, hurray-core, and hurray-io are all multi-buffer. Every format feature whose descriptor references a second buffer is therefore unreachable from Python (issue #146):

PathCurrent limit
__hurray_buffer__ capsuleone data_ptr + byte_size
hurray.save()passes exactly one buffer to FileWriter::write_tensor
hurray.load()raises UnsupportedError on any tensor with more than one buffer
SparseTensordoes not implement __hurray_buffer__ at all

hurray_io::FileWriter::write_tensor already accepts a slice of buffers; only the Python binding narrows it.

Three observations drive this decision.

  1. The descriptor channel is already lossless. The capsule context carries the encoded TensorDescriptor, and from_hurray_buffer reconstructs it with TensorDescriptor::decode. Quantization, statistics, shard, and extension type all survive the hop today. The gap is the buffer channel alone.

  2. A single-buffer transport turns a valid descriptor into silent corruption. A PerChannelAffine, Nf4, or Mxfp descriptor references a scale_buffer_index. Such a descriptor encodes and decodes perfectly well, so a consumer would receive a descriptor pointing at a scale buffer that was never transported — a dangling buffer index rather than a clean error.

  3. Multi-buffer is not a synonym for sparse. ADR-023's initial recommendation (restrict __hurray_buffer__ to dense tensors; add __hurray_sparse_buffer__ later) assumed the two coincide. They do not: a dense per-channel-quantized tensor is multi-buffer, as are block-paged and composite tensors. A sparse-specific protocol would solve the narrower half of the problem and leave quantization unreachable.

This also stands against the standing requirement (issue #147) that hurray-python must fully expose what hurray-core and hurray-io can express.

Decision

1. One capsule carries every buffer

__hurray_buffer__() MUST return a single PyCapsule carrying all of the tensor's buffers. Capsule names are unchanged: "hurray_buffer" on creation and "used_hurray_buffer" after consumption. There is one deleter and one lifetime, exactly as in ADR-023 § 5.

A single-buffer tensor is the N = 1 case of this protocol, not a separate path.

2. The capsule pointer is a HurrayBufferList

hurray-ffi MUST gain an opaque HurrayBufferList handle holding an ordered collection of HurrayBuffer handles, with C accessors:

FunctionPurpose
hurray_buffer_list_lennumber of buffers in the list
hurray_buffer_list_getborrow the HurrayBuffer at index i
hurray_buffer_list_destroydestroy the list and every handle it owns

The capsule pointer MUST be *mut HurrayBufferList. This preserves ADR-023's decision D-NB1 — a C consumer can call hurray-ffi accessors on the capsule pointer without linking PyO3 — while extending it from one buffer to N.

hurray_buffer_list_get MUST return a borrowed handle: ownership stays with the list, and the consumer MUST NOT call hurray_buffer_destroy on it. Destroying the list destroys every handle it owns, exactly once.

3. Buffer order is descriptor order

Element i of the list MUST be the buffer at index i of the descriptor's buffer table. Every buffer index appearing in a quantization descriptor (scale_buffer_index, zero_point_buffer_index), a layout descriptor, or a composite member therefore indexes the list directly.

A consumer MUST reject a capsule whose list length does not match the descriptor's buffer count, and MUST validate that every referenced buffer index resolves — the check hurray_core::validate_buffer_placement performs.

4. C ABI version becomes 3

HURRAY_C_ABI_VERSION MUST be raised from 2 to 3. The capsule context MUST carry the producer's version, and the consumer MUST verify it before dereferencing the pointer, as already required by ADR-023 § 8. A consumer built against version 2 that receives a version 3 capsule raises hurray.UnsupportedError rather than misinterpreting a HurrayBufferList as a HurrayBuffer.

Pre-1.0, no compatibility guarantee applies (see docs/spec/versioning.md); the version check exists so the mismatch is diagnosed rather than dereferenced.

5. __hurray_sparse_buffer__ is not introduced

Amended by ADR-033: the one probe is now hasattr(obj, '__hurray__'). One protocol, one probe — only its spelling changed.

The separate sparse protocol floated in ADR-023 is superseded. SparseTensor MUST implement __hurray_buffer__ using this protocol, with its values and index buffers in descriptor order. Consumers discover support exactly as before, via hasattr(obj, '__hurray_buffer__') (ADR-023 § 7) — one protocol, one probe.

6. File I/O accepts multi-buffer tensors

hurray.save() MUST pass every buffer of a tensor to FileWriter::write_tensor, and hurray.load() MUST accept multi-buffer tensors — the current buffer-count rejection is removed. load() MUST validate buffer placement before handing a tensor to the caller.

7. Spec placement is unchanged

ADR-023 § 4 stands: the native buffer protocol remains documented only in docs/impl/python-bindings.md and MUST NOT appear under docs/spec/. This decision changes a Python-side transport and adds a C ABI type; it does not touch the wire format, the binary descriptor encoding, or any layout definition. The HurrayBufferList type is documented in docs/impl/c-ffi.md, which is authoritative for the C ABI.

Alternatives Considered

A tuple of capsules, one per buffer. Rejected. It admits partially-consumed states where some buffers have been taken and others not, multiplies the deleter logic by N, and forces the encoded descriptor to ride in one arbitrarily-chosen capsule, making that capsule privileged and the others meaningless on their own.

Keep the capsule pointer as *mut HurrayBuffer (buffer 0) and hide buffers 1..N in the capsule context. Rejected. It is source-compatible for existing consumers, which is precisely the danger: a C consumer that reads only the capsule pointer — the access pattern D-NB1 exists to support — silently sees a one-buffer tensor and reads a quantized tensor as if it were unquantized. A silent wrong answer is worse than an ABI bump.

__hurray_sparse_buffer__ as ADR-023 initially recommended. Rejected, per Context § 3: it conflates multi-buffer with sparse, leaves dense quantized tensors unreachable, and forces consumers to probe for two protocols and reconcile their semantics.

Widen HurrayBuffer itself to hold N allocations. Rejected. HurrayBuffer maps to one allocation with one release callback, and each buffer of a tensor declares its own byte_size, alignment, and sync_mode. Collapsing them into one handle would lose that per-buffer metadata and, more importantly, the per-buffer release callback that lets buffers with independent owners be freed independently. (device_tag and memory_class are not per-buffer in practice: buffer-protocol.md § Device Colocation requires every buffer of one descriptor to share both.)

Require the producer to slice one contiguous allocation into N sub-buffers. Rejected as a requirement, though it remains permitted as a producer strategy — see § Notes below.

Consequences

Positive

  • Quantization beyond per-tensor affine becomes expressible from Python, unblocking the descriptor-authoring work in issue #146.
  • Sparse file I/O stops being rejected on buffer count.
  • Block-paged and composite tensors gain a transport path for the same reason.
  • One protocol and one probe for every tensor kind, dense or sparse.

Negative

  • An ABI bump: any existing C consumer of the capsule must be updated. Pre-1.0 this costs nothing externally, but it is a real change to a published handle shape.
  • HurrayBufferList is a new owning type whose lifetime discipline must be exactly right — the borrowed-handle rule in § 2 is the part most likely to be misused.
  • hurray.Tensor grows from one BufferStore to a collection, touching every construction path in the bindings.

Required Documentation Amendments

  • docs/impl/python-bindings.md — the native buffer protocol section: one capsule, N buffers, descriptor order, list-length validation, and SparseTensor support.
  • docs/impl/c-ffi.md — HurrayBufferList and its three accessors; ABI version 3.
  • docs/adr/ADR-023-*.md — mark the Multi-buffer tensors open question resolved by this ADR.

No amendments under docs/spec/ are required.

Open Questions Deferred

  • Cross-process (IPC) variant. Unchanged from ADR-023: a capsule only works in-process. Deferred.
  • Async counterpart. Unchanged from ADR-023: no __ahurray_buffer__; sync_mode already carries the per-buffer synchronisation contract. Deferred.
  • Per-buffer stream handling. ADR-023 § 6 defines stream for a single buffer. Buffers of one descriptor always share a device and memory class (buffer-protocol.md § Device Colocation), but sync_mode is not covered by that rule and may differ between them. Whether a stream argument applies per-buffer or per-tensor when sync modes differ is left open until a concrete case appears; the present implementation targets buffers sharing one sync mode.

Notes (non-normative)

Why not one contiguous allocation, sliced? A producer MAY do exactly that: the buffer handle is a declaration of properties, not a pointer (buffer-protocol.md § Buffer Handle), and nothing requires the N pointers behind a descriptor to live in distinct allocations. A producer that controls all of its buffers can allocate one block, place each buffer at a 64-byte-aligned offset within it, and hand out N handles pointing into that block. This decision does not forbid that; it declines to require it, for three reasons.

  1. Buffers frequently have independent owners. A quantized tensor's data may be a PyTorch storage while its scales are a NumPy array, or its values may be an mmap region while its indices were just computed. Requiring one contiguous block means copying foreign memory into it at the boundary, which contradicts the zero-copy-first principle the protocol exists to serve.
  2. Release is per buffer. Each HurrayBuffer carries its own release callback (ADR-009). One block collapses that into all-or-nothing: a consumer could not release the data and retain the scales, and a producer could not hand out buffers whose lifetimes differ.
  3. It saves no bookkeeping. A sliced block still needs N (offset, length) pairs plus inter-slice alignment padding — the same cardinality as N handles, with strictly less generality.

ADR-031: One Python Tensor class for every layout — retire SparseTensor

Status

Proposed (2026-08-15). § 2 amended 2026-08-16: inapplicable accessors raise AttributeError, not UnsupportedError — see the reasoning in that section.

Amends the Sparse Tensor Support section of docs/impl/python-bindings.md, which currently requires COO / CSR / CSC tensors to be exposed as a distinct hurray.SparseTensor class.

Context

hurray-python exposes two tensor classes: hurray.Tensor for dense tensors and hurray.SparseTensor for COO, CSR, and CSC. The split is mandated normatively in docs/impl/python-bindings.md § Sparse Tensor Support, but no ADR records a reason for it. It was asserted rather than decided.

Four observations argue against keeping it.

1. The format does not make this distinction

There is no sparse tensor descriptor. Sparse is a layout_tag inside the ordinary TensorDescriptor — same element type, same shape, same buffer table, same optional quantization / shard / statistics sections. hurray-core models every layout as one LayoutDescriptor enum.

A binding whose stated purpose is to expose what the format can express (issue #147) should not invent a type boundary the wire format does not have. The split makes hurray-python the only place in the stack where "sparse" is a kind of object rather than a property of a tensor.

2. The taxonomy is already incomplete

hurray.SparseFormat models exactly three layouts: COO, CSR, CSC. But COO is not the only multi-buffer layout, and sparse is not the only non-dense one:

LayoutBuffersPython class today
RowMajor, ColMajor, Strided, Tiled, Morton, Hilbert1Tensor
COO2SparseTensor
CSR, CSC3SparseTensor
CSF2·rank+1(none — falls back to Tensor)
BlockPaged3(none — falls back to Tensor)
Composite0(none)

CSF and block-paged are as far from row-major as CSR is, yet they land in Tensor. The boundary does not track any property of the format; it tracks which layouts happened to be bound first. Every new layout re-opens the question of which class it belongs to — a question that would not exist if there were one class.

3. The split leaks into every API

Because the two types are unrelated, each new capability must be threaded through twice or make a dispatch decision:

  • hurray.save() accepts Tensor only and raises UnsupportedError for SparseTensor — the whole of issue #156.
  • hurray.load() must decide which class to construct from a decoded descriptor.
  • hurray.from_hurray_buffer returns Tensor, so a sparse tensor that travels over the native protocol comes back as the wrong type (a leftover from #146).
  • __hurray_buffer__ is implemented twice, once per class.

The fix proposed for #156 was a shared "reconstruct into the right class" function. That function is pure overhead created by this decision; unifying deletes it rather than writing it.

4. The nearest analogue in this domain already unified

The ecosystem is genuinely split on this question:

LibraryDesign
PyTorchone torch.Tensor with a .layout attribute (torch.strided, torch.sparse_coo, torch.sparse_csr)
TensorFlowseparate tf.SparseTensor
SciPyseparate scipy.sparse matrix types
Apache Arrowone Array hierarchy; layout is a property of the type

SciPy's split is the one hurray-python currently mirrors, and it is the least applicable: SciPy's sparse types are a matrix library with their own arithmetic, not an interchange surface. PyTorch — the closest analogue, an ML tensor library where layout is metadata rather than a different kind of object — unified, and calling .values() on a dense tensor simply raises. The unified design is proven in practice.

Decision

hurray-python exposes exactly one tensor class, hurray.Tensor, for every layout. hurray.SparseTensor is removed.

1. Layout becomes a property

Amended by ADR-032: layout returns a hurray.Layout instance, not a string. The string is layout.name, drawn from the same set listed below. § 2 through § 5 are unchanged.

hurray.Tensor MUST expose a layout property reporting the descriptor's layout as a string: "row_major", "col_major", "strided", "tiled", "morton", "hilbert", "coo", "csr", "csc", "csf", "block_paged", "composite".

This replaces SparseTensor.format, which reported only the three sparse cases.

2. Layout-specific accessors live on the one class and raise AttributeError

values, indices, col_indices, row_ptr, row_indices, col_ptr, and nnz MUST be available on hurray.Tensor and MUST raise AttributeError when the tensor's layout does not define them.

This extends design decision D10 — already applied within SparseTensor, where a CSR tensor raises AttributeError for .indices — from the three sparse formats to every layout.

AttributeError rather than hurray.UnsupportedError, because it is the only choice that keeps hasattr honest:

hasattr(coo_tensor, "row_ptr")     # False — genuinely not available
hasattr(csr_tensor, "row_ptr")     # True

UnsupportedError subclasses NotImplementedError, so it is raised after attribute lookup succeeds; hasattr would return True for every accessor on every tensor and callers would be forced to switch on layout instead. Feature detection matters more under a unified class, not less: once the type no longer tells you what a tensor supports, hasattr is what remains.

Note (non-normative): PyTorch raises RuntimeError here, so it is not a model to follow on this point — its users check .layout. The cost of the unified class is that dense.row_ptr fails at call time rather than being absent from the type; AttributeError recovers as much of the "absent" behaviour as Python allows.

3. Dense-only protocols reject non-dense layouts by layout, not by type

__dlpack__, __array__, __array_interface__, and to_torch MUST raise hurray.UnsupportedError (or BufferError where the protocol requires it) for layouts they cannot represent. Previously the type system enforced this by making the methods absent from SparseTensor; now the check is explicit and states the layout in the message.

4. Constructors and interop keep their names

hurray.sparse_coo, hurray.from_scipy, and to_scipy remain, returning and accepting hurray.Tensor. They are named for what they do, not for the class they produce.

5. One protocol implementation

Answered by ADR-036: a composite is a container of tensors, not a tensor. hurray.Composite is its own class, and hurray.Tensor is unchanged.

__hurray_buffer__ is implemented once. hurray.from_hurray_buffer, hurray.load(), and hurray.save() handle every layout uniformly, which closes issue #156 and the sparse half of the #146 follow-up without any per-class dispatch.

Alternatives Considered

Keep both classes and give SparseTensor its own file I/O (the original #156 plan). Rejected: it fixes one symptom and leaves the cause. Every future capability — streaming (#157), extension types, new layouts — pays the same tax again, and the class boundary still does not correspond to anything in the format.

Keep both, and add classes for the missing layouts (CsfTensor, BlockPagedTensor, …). Rejected: it multiplies the problem. Each new layout in the spec would require a new Python class and another round of protocol implementations, and consumers would have to switch on type to do anything generic.

One class, with layout-specific accessors on a namespace object (t.sparse.row_ptr). Rejected as a middle road that costs an extra concept without removing the failure mode: t.sparse still has to raise for a dense tensor.

Subclass: SparseTensor(Tensor). Rejected. It would fix the "save() rejects sparse" symptom via inheritance while keeping the taxonomy question ("which layouts get a subclass?") permanently open, and isinstance checks in user code would quietly encode a boundary the format does not have.

Consequences

Positive

  • The Python object model matches the format's: a tensor has a layout, rather than a layout implying a type.
  • Issue #156 is closed by construction — save/load/from_hurray_buffer stop caring what layout a tensor has.
  • CSF, block-paged, and composite tensors gain the same accessors and protocol support as everything else, instead of falling back to a partial Tensor.
  • One implementation of __hurray_buffer__, one of __repr__, one of the property surface.

Negative

  • A visible API break. hurray.SparseTensor disappears; isinstance(x, hurray.SparseTensor) and x.format stop working. Pre-1.0 this carries no compatibility guarantee (see docs/spec/versioning.md), but it is a real change for anyone already using the sparse API.
  • Static analysis loses a signal. dense.row_ptr still raises AttributeError, so hasattr and getattr behave as before, but a type checker or IDE can no longer tell from the class alone which accessors a given tensor supports — every accessor exists on every Tensor.
  • A wider class surface. One class carries every layout's accessors, most of which raise for any given instance. Documentation must be explicit about which apply where.

Required Documentation Amendments

  • docs/impl/python-bindings.md § Sparse Tensor Support — rewritten around one class with a layout property; the hurray.SparseTensor requirement removed.
  • docs/cookbook/hurray-python-sparse-scipy.md — updated for the unified class.
  • docs/tutorials/python-interop-paths.md — the note recording that save() rejects SparseTensor is removed, since it no longer will.

No amendments under docs/spec/ are required: this decision changes only the Python binding's object model, not the format.

Open Questions Deferred

  • Composite tensors. A composite head owns no buffers and is a container rather than a tensor. Whether it belongs on the unified class at all, or needs its own representation, is left open — it is the one case where a separate type may be genuinely justified, and nothing in Python constructs composites today.
  • Layout-specific construction. hurray.sparse_coo stays, but whether every layout eventually gets a matching constructor, or a single generic one taking a layout argument, is left to whoever binds the next layout.

ADR-032: A Python layout descriptor hierarchy — t.layout returns an object, not a string

Status

Proposed (2026-08-18)

Extends ADR-031 (One Python Tensor class for every layout). Amends its § 1 — layout returns a hurray.Layout instance rather than a string — and leaves § 2–§ 5 in force.

Context

ADR-031 unified the Python tensor classes and gave hurray.Tensor a layout property. That property returns a string, which is lossy: hurray-core models layout as an enum whose variants carry data, and none of that data survives the translation.

LayoutFields in hurray-coreReachable from Python
COOnnz, is_sortedno
CSR / CSCnnzno
CSFnnz, mode_orderno
Stridedstridesno
Tiledtile_shape, outer/inner strides and layoutsno
Mortonmorton_bitsno
BlockPagedpage_size, num_pages, paged_axis, num_seqs, kv_roleno
Compositecomposition rule, combine op, member countno
PrivateExtensionextension_layout_id, extension_datano
Unknowntag, raw bytesno

A string cannot carry any of it. Under the standing requirement that hurray-python expose what hurray-core and hurray-io can express (issue #147), this is a hole.

It is also an inconsistency. Every other optional descriptor section already has a Python class — the five quantization schemes, Statistics, Shard — accepted by the Tensor constructor and returned by the matching getter. Layout is the only section of the tensor descriptor with no Python representation of its own.

Note (non-normative): This does not re-open SparseTensor. CsrLayout is a descriptor, not a tensor: it has no buffers, no protocols, and no save(). isinstance(t.layout, CsrLayout) encodes a distinction the wire format genuinely makes — the layout tag — where isinstance(t, SparseTensor) encoded one it does not. ADR-031 removed the hierarchy from the tensor; this ADR puts one where the format actually has it.

Decision

1. A class hierarchy with a data-carrying base

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

The base MUST store the core LayoutDescriptor and implement tag, name, buffer_count, is_dense, and is_virtual once; subclasses are typed façades reading their own fields off it. This mirrors how Statistics and the quantization schemes already wrap core types.

The base class also gives the binding a legal fallback object. LayoutDescriptor is #[non_exhaustive]: when core gains a variant this build has not bound, t.layout MUST return a bare Layout carrying tag and name. It MUST NOT be reported as UnknownLayout, which would claim the tag is unrecognised when it is merely unbound — destroying the signal a permissive reader depends on.

All layout classes MUST be immutable, with value equality and hashing, and a __repr__ of the form CsrLayout(nnz=4).

2. t.layout returns an instance; the string moves to .name

t.layout == "csr" no longer works; t.layout.name == "csr" replaces it. A single internal helper MUST produce that string, so .name and the layout named in error messages (__dlpack__, __array__, to_scipy) cannot drift.

Layout classes MUST NOT define equality against strings. It would break the hash/equality contract and keep a lossy comparison path alive indefinitely.

layout is read-only: assigning a layout would silently reinterpret existing buffers.

3. Component views stay on Tensor, plus a generic accessor

values, indices, row_ptr, col_indices, row_indices, col_ptr, and nnz remain on hurray.Tensor exactly as ADR-031 § 2 defines them, including the AttributeError discipline.

hurray.Tensor MUST additionally expose buffer(index), returning a 1-D uint8 view of exactly the declared byte size of the buffer at that descriptor index. CSF has 2·rank+1 buffers and block-paged has three; without a generic accessor their parameters would become reachable while their buffers stayed unreachable, leaving issue #147 unsatisfied for those layouts. uint8 is the only honest element type for a generic view — 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.

4. Authoring: layout= is accepted, and never inferred

hurray.Tensor MUST accept a layout keyword holding a hurray.Layout instance, mirroring quantization=. Omitting it means row-major, as today. 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 things:

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

Never reinterpret, never infer. 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. Such a descriptor encodes and decodes cleanly and hands the consumer an out-of-bounds read: the same class of failure the existing quantization buffer-placement check exists to prevent. Over-sized buffers are permitted (alignment and padding slack are legitimate); under-sized are not.

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. That yields two clear paths: high-level constructors infer and are ergonomic; Tensor(...) requires the descriptor to be stated and validates it.

Round-trip obligation. For every constructible layout, rebuilding a tensor from a tensor's own layout, quantization, statistics, shard, and buffers MUST produce an equal descriptor.

5. Units and enumerations

  • Strides — StridedLayout.strides and the tiled layouts' outer and inner strides — are in logical elements, signed, and may be negative or zero. This MUST be stated in the binding documentation: a reader arriving from NumPy will otherwise assume bytes.
  • Small closed enumerations (kv_role, block-table index type, tiled inner and outer layout tags) are exposed as lowercase strings, matching device.kind and layout.name. No new Python enum classes.
  • CompositeLayout exposes the composition rule and the combine operation as two properties, with the combine operation 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".

6. Private, unknown, and composite layouts

PrivateExtensionLayout and UnknownLayout MUST be separate classes. "A private layout I can identify by its extension id" and "a tag from a newer spec version I could not parse" are different facts, and merging them erases the signal a permissive relay needs.

  • PrivateExtensionLayout — exposes tag, extension id, and extension data; buffer count is unknown. Constructible and accepted for authoring. Because the buffer count is unknown, the size tier of § 4 cannot run; that hole MUST be documented rather than left implicit.
  • UnknownLayout — exposes tag and raw bytes; buffer count unknown. Constructible, so that a permissive relay can reconstruct a descriptor it decoded and write it back out. The Python constructor MUST reject any tag for which a named variant exists.

Amended by ADR-036: the rule is unchanged — a composite layout still cannot be given to hurray.Tensor — but the error now names hurray.Composite, which holds a head and its members together.

  • CompositeLayout — readable in full: a composite head decoded from a stream MUST NOT have its layout misreported. Constructing a tensor with a composite layout MUST raise hurray.UnsupportedError; a composite head owns no buffers, which the Python Tensor cannot yet represent. ADR-031's deferral of whether composites belong on the unified class is unaffected; this answers only what t.layout reports.

Resolved (2026-08-19, issue #162): core now rejects named and private tags in UnknownLayout::new, via a new is_named_tag helper shared with validate_layout_tag_strict. The finding as originally written follows.

Finding for hurray-core: UnknownLayout::new currently rejects only the reserved tags 0x00 and 0xFF, so a caller can construct an "unknown" layout on a tag that has a named variant — smuggling an unvalidated descriptor past every rank and buffer check. The Python constructor closes this per § 6; core should be tightened to the same rule.

Alternatives Considered

Layout objects holding a back-reference to their tensor, so t.layout.row_ptr returns a view. Rejected, and recorded here as rejected rather than deferred because it is otherwise certain to be re-proposed.

It turns a metadata accessor into a buffer-lifetime anchor. t.layout would hold a strong reference to the tensor, which holds the buffers, so

layouts = {name: t.layout for name, t in stream}   # "just collecting metadata"

pins every tensor's buffers for the lifetime of that dictionary. In a format whose premise is that the consumer decides when a zero-copy buffer is released, a descriptor attribute that silently extends buffer lifetime is a defect, and nothing in its name warns the caller. The Tensor → layout → Tensor reference cycle is a second cost of the same choice: uncollectable without GC traversal support on every layout class, and clearing the back-reference leaves a partially-dead object whose getters must then raise.

It also asserts a containment the wire format does not have. The buffer table is a sibling section of the layout section, not a child — which is precisely why quantization descriptors reference buffers by index into that shared table. t.quantization returns a descriptor carrying scale_buffer_index and not the scale buffer; a layout that owned views would be the only descriptor section in the binding behaving differently.

Finally it does not deliver its own headline benefit without a second, worse change: dir(t.layout) lists only what applies only if the accessors are removed from Tensor, which repeals ADR-031 § 2 and takes hasattr with it. And t.layout.values reads as though the layout has values. The tensor has values; the layout describes how they are arranged.

Keeping layout as a string and adding separate properties for the parameters (t.nnz, t.strides, t.page_size, …). Rejected: it moves every layout's fields onto Tensor, multiplying exactly the AttributeError-guarded surface ADR-031 already had to justify, and leaves no object to pass to layout= for authoring.

Thirteen independent classes with no common base. Rejected: tag, name, buffer_count, is_dense, and is_virtual would be written thirteen times, the layout= keyword would need a downcast chain instead of one type check, and — decisively — there would be no legal object to return when core gains a variant the binding has not yet bound.

Accepting a layout string for authoring, alongside objects. Rejected under § 4: a string cannot carry the parameters, so it can only produce a descriptor that is wrong.

Consequences

Positive

  • Every layout parameter in hurray-core becomes reachable from Python, closing the layout half of issue #147.
  • Layout joins quantization, statistics, and shard as a descriptor section with a Python class, so authoring and inspection read the same way across all four.
  • isinstance(t.layout, hurray.CsrLayout) is a stronger discriminator than a string comparison, and dir(t.layout) is fully honest about parameters.
  • The layout= keyword plus the three validation tiers make an inconsistent descriptor unauthorable, rather than merely undetected.

Negative

  • t.layout is not identity-stable: each access builds a fresh object, so t.layout is t.layout is false. Value equality and hashing mitigate it. Caching one object per tensor would restore identity without a cycle (tensor → layout is safe; only layout → tensor is not) and is recorded below as deferred.
  • A large mechanical surface: thirteen classes, each with a constructor, getters, __repr__, equality, a doc example, and a stub entry. The shared base is what keeps this mechanical rather than combinatorial.
  • TiledLayout is recursive — a tiled layout may nest another — so the Python class is self-referential and its __repr__ nests. Core bounds the depth, but the representation should be depth-aware rather than naively recursive.
  • Layout.name strings become public API. They are already public through the current string property, so this is not new surface, but pinning them to a per-class property makes them harder to change later. They MUST be frozen in the binding documentation and MUST match the specification's layout names, so a third naming vocabulary does not emerge alongside the spec's and hurray-inspect's.
  • Private and unknown layouts skip buffer-size validation, because their buffer count is unknowable. This is a genuine hole in an otherwise complete check.

Required Documentation Amendments

  • docs/impl/python-bindings.md — a normative section defining the class hierarchy, the layout= keyword, the three validation tiers, the frozen string sets for name and the enumeration-valued properties, the statement that strides are in logical elements and signed, and the private/unknown size-validation hole.
  • docs/adr/ADR-031-*.md § 1 — amended by reference: layout returns a Layout instance; the string is layout.name. § 2 through § 5 are unchanged.
  • docs/cookbook/hurray-python-sparse-scipy.md, docs/cookbook/composite-streaming.md, and docs/tutorials/python-interop-paths.md — updated for t.layout.name.
  • hurray-inspect — its layout rendering and the Python repr of a layout should agree field for field; any field hurray-inspect prints that the Python class omits is a remaining issue #147 gap.

No amendments under docs/spec/ are required: this changes the Python binding's object model, not the format.

Open Questions Deferred

  • Named CSF and block-paged component accessors (pos / crd, page table). The generic buffer(index) accessor covers them for now, and the layout object explains what each index holds.
  • Composite authoring from Python, which requires representing a tensor that owns no buffers.
  • Caching the layout object per tensor for identity stability.
  • Per-layout high-level constructors beyond sparse_coo — whether each layout eventually gets an array-shaped constructor that infers its parameters.

ADR-033: The native protocol is named for the format — __hurray__ / from_hurray

Status

Proposed (2026-08-20)

Amends ADR-023 § 1 (protocol name), § 7 (discovery), and § 8 (error semantics, which names the capsule string), and ADR-030 § 5 (one protocol, one probe). Every other decision in both ADRs is unaffected.

Context

The native interchange protocol is currently hurray.Tensor.__hurray_buffer__() and hurray.from_hurray_buffer(). The name is wrong twice over.

It is wrong about arity. ADR-030 widened the capsule from a single HurrayBuffer to a HurrayBufferList, because a tensor with per-channel scales, sparse index arrays, or a page table has several buffers and every one of them has to reach the consumer. The protocol has carried N buffers since then; the name still says one.

It is wrong about category, which matters more. The capsule does not transport buffers. It transports a whole tensor:

Capsule partContents
pointerHurrayBufferList — one HurrayBuffer per descriptor buffer index
contextthe encoded TensorDescriptor, the ABI version, and a strong reference to the source

The descriptor is what makes this protocol full-fidelity — element type, shape, layout, quantization, statistics, shard — and it is precisely what DLPack cannot carry, which is the protocol's reason to exist (ADR-023 § Context). Naming the thing after its buffers hides the part that justifies it.

The clearest evidence is the signature: from_hurray_buffer(obj) -> hurray.Tensor. A function named for buffers that returns a tensor is describing its payload wrongly.

The original reasoning already pointed the other way. ADR-023 § 1 justified the name by analogy to __dlpack__, __torch_function__, and __jax_array__ — and each of those is named for a format or project, never for the bytes underneath. __dlpack__ does not describe what a DLPack capsule contains. The _buffer suffix was drift from the precedent the decision itself cited.

Decision

1. The protocol is named for the format

hurray.Tensor.__hurray__(stream=None) -> PyCapsule
hurray.from_hurray(obj, /) -> hurray.Tensor

Naming the format rather than the payload is what keeps the protocol legible next to the one it complements. The two are always read together, because choosing between them is the decision a consumer actually makes:

if hasattr(obj, "__hurray__"):        # full fidelity: every dtype, layout, device
    t = hurray.from_hurray(obj)
elif hasattr(obj, "__dlpack__"):      # universal, but lossy
    t = hurray.from_dlpack(obj)

It is also the only name that stays true as the protocol grows. A capsule that later carries a composite group or a stream frame is still Hurray; it is no longer a tensor, and would not be buffers either.

2. The capsule keeps a payload name

The PyCapsule is named "hurray_tensor", and "used_hurray_tensor" once consumed.

This is deliberately not symmetric with the method name, and follows DLPack exactly: __dlpack__ returns a capsule named "dltensor_versioned". The method name is ergonomics — read by humans choosing a protocol. The capsule name is a wire contract — read by PyCapsule_IsValid in a C consumer that never sees the Python method. Precision belongs on the wire; brevity belongs in the call.

No version suffix is added, unlike DLPack's _versioned. DLPack needed one because its v0.8 and v1.0 structs differ with no in-band version field. Hurray carries HURRAY_C_ABI_VERSION in the capsule context, and from_hurray MUST verify it (ADR-023 § 8), so the version travels in the payload where it can be checked and reported rather than in a string that can only match or fail to match.

3. Discovery follows the method

hasattr(obj, "__hurray__") replaces hasattr(obj, '__hurray_buffer__') as the single probe (ADR-023 § 7, ADR-030 § 5). One protocol, one probe, unchanged in substance.

4. The rename is clean — no aliases

The old names MUST be removed, not deprecated alongside the new ones. Keeping __hurray_buffer__ as an alias would mean two names for one protocol, two feature probes a consumer must decide between, and a permanent second path through the capsule code — the "flag to avoid making a decision" that CLAUDE.md § Guiding Principles forbids.

Hurray is pre-1.0, where the versioning policy permits breaking changes precisely so that mistakes like this one are fixed rather than carried. Renaming after 1.0 would cost a deprecation cycle; renaming now costs a search and replace.

5. hurray.from_hurray stutters, and that is accepted

Inside the hurray module the name reads redundantly. That is the one genuine cost, and it lands on the least important consumer: hurray → hurray is a round trip, not the case the protocol was built for. Every other implementation reads correctly — torch.from_hurray(t), mylib.from_hurray(t) — exactly as numpy.from_dlpack does. DLPack avoids the stutter only because its consumers live in other libraries; here one of them happens to be us.

The status quo hurray.from_hurray_buffer stutters identically, so nothing is lost.

Alternatives Considered

__hurray_tensor__ / from_hurray_tensor. Accurate on both counts, symmetric with hurray.Tensor, and matching Arrow's PyCapsule interface, which names the logical object (__arrow_c_array__, __arrow_c_stream__) even though an Arrow array is itself a schema plus several buffers. Rejected because the protocol's nearest neighbour is DLPack, not Arrow: a consumer picks between __hurray__ and __dlpack__ in a single hasattr chain, and the parallel is worth more there than descriptive precision. The precision is not lost — it moves to the capsule name, per § 2.

__hurray_buffers__ / from_hurray_buffers. The minimal edit: pluralise and stop. Rejected because it corrects the arity error while preserving the category error, which is the more misleading of the two. The capsule would still be named for the part that does not distinguish it.

Keep __hurray_buffer__ and add __hurray__ as an alias. Rejected under § 4.

Do nothing. Rejected. The name is load-bearing documentation: it is the first thing an implementer of a third-party binding reads, and it currently tells them the protocol moves buffers when it moves tensors. That misdirection gets more expensive with every binding written against it, and the cost of fixing it only rises after 1.0.

Consequences

Positive

  • The name matches what the protocol transports, and stays true if the payload grows.
  • __hurray__ and __dlpack__ read as the alternatives they are, in the one code shape — a hasattr chain — where a consumer chooses between them.
  • The capsule string says tensor where a C consumer validates it, so the wire contract is more descriptive than before, not less.
  • One protocol, one probe, one name: no alias, no second path, nothing to deprecate later.

Negative

  • The capsule name change is a protocol break. A consumer keying on "hurray_buffer" via PyCapsule_IsValid stops recognising Hurray capsules. This is permitted pre-1.0 and is the reason to do it now, but it is a real break and MUST be called out in the release notes rather than folded into a rename.
  • hurray.from_hurray stutters (§ 5).
  • Roughly 150 references move, across hurray-python/src, its tests and examples, the cookbook, the tutorials, docs/impl/python-bindings.md, and ADR-023/ADR-030. Most are prose; the mechanical surface is small.
  • Two ADRs gain amendment notes. ADR-023 § 1, § 7, § 8 and ADR-030 § 5 keep their original text with a pointer here, matching how ADR-031 § 1 records its amendment by ADR-032.

Required Documentation Amendments

  • docs/impl/python-bindings.md — § Native Buffer Interchange Protocol: the method, the function, the capsule names, and the hasattr probe. The section title should lose "Buffer" too.
  • docs/adr/ADR-023-*.md § 1, § 7, and § 8 — amendment note pointing here. § 8's rules are unchanged in substance; only the two names they quote move.
  • docs/adr/ADR-030-*.md § 5 — amendment note; the "one protocol, one probe" rule is unchanged, only the spelling of the probe.
  • docs/cookbook/hurray-python-native-buffer.md and docs/tutorials/python-interop-paths.md — worked examples.
  • hurray-python/examples/native_buffer.py and multi_buffer.py.

No amendments under docs/spec/ are required: the native protocol is implementation-only (ADR-023 § 4), not part of the format specification.

Open Questions Deferred

  • Whether hurray-ffi should expose a matching C-level name. Resolved by ADR-034 § 5: no rename. Every C name was accurate for what it named — the C layer simply had no protocol type to misname. ADR-034 gives it one, HurrayTensorContext, named for what it carries.
  • Whether a future capsule carrying a composite group or a stream frame keeps the "hurray_tensor" capsule name or introduces a sibling. § 1 makes the method name survive that change; the capsule name would not have to.

ADR-034: The capsule context becomes a C ABI handle

Status

Proposed (2026-08-20)

Amends ADR-023 § 5 (capsule lifetime, which owns the context) and § 8 (the ABI version check), and resolves the C-level question ADR-033 deferred.

Context

The native protocol (__hurray__ / from_hurray, ADR-023, ADR-030, ADR-033) exists so that two Hurray-aware peers can exchange a tensor without the fidelity loss DLPack imposes. ADR-023 § Context names three peer pairs it is meant to serve:

hurray-python ↔ hurray-python, hurray-python ↔ hurray-ffi consumer, or hurray-python ↔ another binding built on hurray-ffi

Only the first of those works.

A capsule carries two things. The capsule pointer is a HurrayBufferList — a proper hurray-ffi handle, reachable by anyone linking the C ABI. The capsule context holds everything else:

struct NativeBufferContext {   // hurray-python/src/native_protocol.rs
    abi_version: u32,
    descriptor_bytes: Vec<u8>,
    tensor_ref: Py<PyAny>,
}

That struct is private to hurray-python, is not #[repr(C)], contains a Vec<u8> and a Python object reference, and is declared nowhere in hurray.h. Its layout is unspecified and may change with a compiler version. A consumer that is not hurray-python can call PyCapsule_GetContext and receive a pointer it has no legal way to interpret.

So the buffers cross the boundary and the descriptor does not — and the descriptor is the entire reason this protocol exists rather than DLPack. A Go or Julia binding on hurray-ffi receives element bytes with no element type, no shape, no layout, no quantization.

The gap also makes a normative rule unimplementable. docs/impl/python-bindings.md § ABI version requires:

The capsule context MUST include the HURRAY_C_ABI_VERSION constant from the producing hurray-ffi build. […] the consumer MUST verify it before dereferencing the handle.

A non-Python consumer cannot verify the version it is required to verify, because the version sits inside the struct it cannot read. The check exists precisely to stop a consumer dereferencing handles from an incompatible build, and it is unavailable to every consumer that is not the producer's twin.

This was found while resolving ADR-033's deferred question about C-level naming. That question turns out to have a short answer — see § 5 — and this is the real defect underneath it.

Decision

1. The context becomes a hurray-ffi handle

hurray-ffi gains HurrayTensorContext: an opaque handle carrying what a capsule needs beyond its buffers.

typedef struct HurrayTensorContext HurrayTensorContext;

HurrayStatus hurray_tensor_context_new(uint32_t abi_version,
                                       const uint8_t *descriptor_bytes,
                                       uint64_t descriptor_len,
                                       void *owner,
                                       void (*owner_release)(void *owner),
                                       struct HurrayTensorContext **out);

HurrayStatus hurray_tensor_context_abi_version(const struct HurrayTensorContext *ctx,
                                               uint32_t *out);
HurrayStatus hurray_tensor_context_descriptor(const struct HurrayTensorContext *ctx,
                                               const uint8_t **out_bytes,
                                               uint64_t *out_len);
void hurray_tensor_context_destroy(struct HurrayTensorContext **ctx);

The handle owns a copy of the descriptor bytes; hurray_tensor_context_destroy frees them and invokes owner_release(owner) exactly once, then nulls the caller's pointer — the same discipline hurray_buffer_destroy already follows.

2. Opaque with accessors, not a public repr(C) struct

The obvious fix is to make the struct #[repr(C)] and declare its fields in hurray.h, so a consumer reads them directly. This ADR rejects that.

Every other handle in the C ABI — HurrayBuffer, HurrayBufferList, HurrayDescriptor — is opaque with accessor functions, and says so in its own documentation: "this struct is not repr(C); its internal layout is an implementation detail." A single struct with a frozen public layout would be the one exception, and it would freeze that layout for the life of the major version.

The usual argument for a public layout — that the consumer avoids linking the producing library — does not apply. A consumer holding a capsule already links hurray-ffi; it has to, because the capsule pointer is a HurrayBufferList and reading it requires hurray_buffer_list_get. Nothing is saved by exposing this one type differently, and consistency across the ABI is worth more.

3. The Python owner reference travels behind void *

The context must keep the source tensor alive while the capsule lives, and today it does that with a Py<PyAny> — a type the C ABI must never see.

owner plus owner_release keeps it out. hurray-python boxes its strong reference, passes it as void *owner with a release function that drops it, and the C ABI stores two pointers it never interprets. This mirrors release / release_context on hurray_buffer_from_ptr, which solves the same problem for buffer memory, so the pattern is already the house idiom rather than a new invention.

It also puts the fix from PR #164 in one place: the release function hurray-python supplies is the only code that touches Python, so it remains the only code that must cope with running during interpreter finalization.

4. abi_version is read before anything else is trusted

hurray_tensor_context_abi_version MUST be callable on any context pointer produced by any version of this ABI, and a consumer MUST call it first. Every other accessor MAY assume the version has been checked.

This is what makes the handle extensible: fields added in a later ABI version are reachable only through accessors added in that version, and a consumer that checked the version knows which ones exist. Without that ordering rule, an opaque handle is as frozen as a public struct.

5. The C ABI is not renamed

ADR-033 deferred "whether hurray-ffi should expose a matching C-level name". It should not. HurrayBuffer is one buffer, hurray_buffer_* operates on one buffer, HurrayBufferList is a list of buffers, HurrayDescriptor is a descriptor — every name is accurate for what it names. The mistake ADR-033 corrected was a protocol carrying a tensor while named for buffers, and the C layer had no protocol type to misname.

It has one now, and it is named for what it carries. That closes the question.

6. C ABI version 3 → 4

New types and functions are additive, but a consumer must be able to tell whether a context handle is available at all, so the version moves. HURRAY_C_ABI_VERSION becomes 4.

Alternatives Considered

#[repr(C)] public struct in hurray.h. Rejected under § 2: inconsistent with every other handle in the ABI, and freezes a layout for no benefit a consumer that already links hurray-ffi can use.

Put the descriptor bytes in the capsule pointer instead, as a combined HurrayTensor handle owning both the buffer list and the descriptor. Cleaner in the abstract — one handle rather than a pointer/context pair — and worth revisiting. Rejected here because ADR-030 § 2 fixed the capsule pointer as a HurrayBufferList and consumers written against it would break for a change that buys elegance rather than capability. Recorded as deferred below.

Leave it, and document the protocol as hurray-python ↔ hurray-python only. Rejected. It would mean withdrawing a claim ADR-023 makes twice, and the protocol's whole justification is preserving what DLPack cannot. A full-fidelity protocol that only two instances of the same binding can speak is a private optimization, not an interchange protocol — and the format's first principle is that it is language-agnostic.

Expose the descriptor through the existing HurrayDescriptor handle instead of raw bytes. Attractive: the consumer would get a parsed descriptor rather than a byte range. Rejected for now because it forces every producer to parse before sending and every context to own a decoded structure, where today the encoded bytes are already in hand and hurray_descriptor_decode is one call away for a consumer that wants one. The bytes are the cheaper and more faithful thing to carry.

Consequences

Positive

  • The protocol's stated purpose becomes true: a non-Python binding on hurray-ffi can read the descriptor and the ABI version, not just the buffers.
  • The MUST verify the version rule becomes implementable by every consumer rather than only by the producer's twin.
  • The C ABI keeps one shape — opaque handles, accessor functions, explicit destroy — with no exception carved out for this type.
  • The Python reference is confined behind void *, so the C ABI stays free of Python types and the finalization hazard stays in one function.

Negative

  • An ABI version bump, with the compatibility-matrix and rebuild consequences every bump carries.
  • hurray-python no longer owns its context type, and must construct it through hurray-ffi. That is the point, but it does mean the capsule path crosses one more boundary than before.
  • The descriptor bytes are copied into the context. A borrow would avoid it, but would tie the context's validity to a buffer the producer might drop. A descriptor is small next to the tensor it describes.
  • No consumer exists to prove the design. The first real non-Python binding may still find this insufficient; § 4's version-then-accessors rule is what leaves room to fix that without another break.

Required Documentation Amendments

  • docs/impl/c-ffi.md — HurrayTensorContext, its four functions, the ownership and version-check rules, and ABI version 4 in the version table.
  • docs/impl/python-bindings.md — § Native Interchange Protocol: the capsule context is a HurrayTensorContext, and the version check is a documented C call rather than an internal detail.
  • docs/adr/ADR-023-*.md § 5 and § 8 — amendment notes pointing here. The design note D-NB2 in hurray-python/src/native_protocol.rs describes the context too and moves with the implementation.
  • docs/adr/ADR-033-*.md § Open Questions Deferred — the C-level naming question is resolved by § 5.
  • hurray-python/COMPAT-MATRIX.md — minimum HURRAY_C_ABI_VERSION 4.
  • docs/cookbook/layer-7-c-ffi.md — a consumer-side example: check the version, read the descriptor, walk the buffer list.

Open Questions Deferred

  • A combined HurrayTensor handle owning both the buffer list and the descriptor, so a capsule carries one handle instead of a pointer/context pair. Better shape; breaks ADR-030 § 2's pointer contract. Worth doing at the next deliberate ABI break, not this one.
  • Whether hurray-io's streaming frames should reuse HurrayTensorContext as their C-level representation, rather than growing a parallel one when Layer 5 gains a C surface.

ADR-035: The Python streaming API is blocking, and iterates

Status

Proposed (2026-08-22)

Resolves the design questions in issue #157. Extends docs/impl/python-bindings.md with a streaming section; no existing ADR is amended.

Context

hurray-io implements the streaming interchange format — StreamWriter::write_tensor / write_composite / finish, and StreamReader::next_tensor / next_item. None of it is reachable from Python, which exposes load and save only.

So a Python producer can write files but not streams, and the format's headline property is missing from the language most of the ecosystem uses: a reader that starts before the whole input has arrived, and a writer that emits tensors one at a time without buffering the output. hurray.StreamError has been defined and registered since Layer 8b — an exception for an API that does not exist.

Four questions have to be settled before any code, and the first one settles the rest.

Decision

1. Blocking, with an owned runtime — not asyncio

hurray.StreamReader and hurray.StreamWriter each own a current-thread tokio runtime for their lifetime and release the GIL around every call into it.

load and save already do this per call, building a disposable runtime and wrapping the work in py.detach. Streaming is long-lived, so the runtime moves from the call to the object; nothing else about the bridge changes.

An asyncio surface is deferred, not rejected. It is a second API with its own integration layer, and the case for it is concurrency across many streams — a Python process multiplexing dozens of peers. Nothing needs that yet, and a Python caller who does can run blocking readers on threads, which is what the GIL release is for. When a real multiplexing consumer appears, this ADR should be revisited rather than worked around; the note in § Open Questions records what evidence would justify it.

Shipping both surfaces now was considered and rejected outright: two APIs for one protocol is the shape CLAUDE.md § Guiding Principles forbids, doubling the surface to avoid choosing.

2. The reader is an iterator; the writer is a context manager

with hurray.StreamWriter(path) as writer:
    for tensor in tensors:
        writer.write(tensor)

for tensor in hurray.StreamReader(path):
    ...                                    # each tensor as it arrives

The reader implements __iter__ / __next__, raising StopIteration at clean EOF. That maps exactly onto next_tensor returning Ok(None), and it is the shape a Python caller expects from something that yields values incrementally. It also composes with everything that consumes an iterator, at no cost.

The writer is a context manager because finish flushes, and a caller who forgets it loses however much of the stream was still buffered. Making the close automatic means that cannot happen silently on the happy path. finish() remains available explicitly for callers who cannot use with, and MUST be idempotent so both paths compose. A writer used after finishing MUST raise hurray.StreamError.

Correction (2026-08-22): this section first said finish writes a terminator. It does not — StreamWriter::finish flushes and returns the sink. The Hurray stream format is self-delimiting per frame and has no end marker; a stream ends at EOF, which is the same property that forbids end-of-file indexes. The decision is unchanged and the reason is if anything stronger: a forgotten finish truncates the stream rather than merely leaving it unterminated.

One consequence follows and is worth stating: a stream truncated exactly at a frame boundary is indistinguishable from a complete one, because EOF is the only end marker there is. Truncation mid-frame raises hurray.StreamError; truncation on a boundary yields a short stream and no error. That is a property of the format, not of this binding.

The reader MUST also be usable as a context manager, so a caller can release the transport deterministically rather than waiting for garbage collection.

3. Transports: a path, a file descriptor, or bytes

SourceReaderWriter
filesystem pathStreamReader(path)StreamWriter(path)
anything with fileno() — sockets, pipes, open filesStreamReader(obj)StreamWriter(obj)
in-memoryStreamReader(data: bytes)StreamWriter() → getvalue()

hurray-io needs AsyncRead / AsyncWrite, and a Python file object is neither. Two bridges were possible and only one is sound.

Rejected: implementing AsyncRead over a Python object's .read(). It would need the GIL inside the poll loop, on a thread that deliberately released it — legal, but it reintroduces the contention the release exists to avoid, and every read becomes a reacquisition. It would also make the transport's failure modes Python exceptions raised from inside a Rust poll.

Chosen: fileno(). A file descriptor is already what tokio wants, and it covers sockets, pipes, and real files — which is the pipeline case the streaming format exists for. Objects with no descriptor (io.BytesIO) are served by the bytes path, which covers the rest of what a Python caller would reach for.

The descriptor MUST be duplicated (dup) so the stream owns its own and closing one side does not invalidate the caller's object. The stream MUST close its duplicate on finish or on drop.

4. Composites are rejected by name, not skipped

Amended by ADR-036: the reader now yields a hurray.Composite instead of raising. The rule that survives is the one this section was really about: a composite is one item, never a head plus loose members.

StreamReader::next_item yields a tensor or a composite. A composite head owns no buffers, and hurray.Tensor cannot represent that — ADR-031 and ADR-032 both deferred composite authoring from Python for exactly this reason, and ADR-032 § 6 already makes constructing a tensor with a composite layout raise hurray.UnsupportedError.

The Python reader therefore iterates tensors, and MUST raise hurray.UnsupportedError naming the composite when it meets one. It MUST NOT skip it: silently dropping a composite would hand the caller a stream that decoded "successfully" while losing data, which is worse than refusing.

This is a real gap and is recorded as such rather than papered over. It closes when composite authoring does.

5. hurray.StreamError finally means something

Framing errors — a truncated frame, a bad magic, a length that overruns — MUST surface as hurray.StreamError, the exception registered since Layer 8b and unused since. I/O failures on the transport keep raising hurray.FileError (an OSError subclass), and descriptor-level problems keep raising hurray.InvalidDescriptorError, so the three stay distinguishable.

Alternatives Considered

An asyncio API instead of a blocking one. Rejected for now under § 1: it is the right answer only for a multiplexing consumer, which does not exist yet, and it costs an integration layer that would have to be maintained through every pyo3 upgrade.

A callback API — hurray.read_stream(src, on_tensor=fn). Rejected: it inverts control for no gain, cannot be composed with itertools or a for loop, and makes early exit awkward. The iterator gives the same incrementality in the shape Python already has.

Reading the whole stream into a list — hurray.load_stream(src) -> list[Tensor]. Rejected as the primary API: it buffers the entire input, which is the exact property the streaming format exists to avoid, and would make the Python surface a worse version of load. It may be added later as a convenience on top of the iterator, where its cost is explicit in its name.

Exposing next_item and returning composites as tuples or dicts. Rejected: it would invent a second, ad-hoc representation of a composite in the binding, which the next pass on composite authoring would then have to keep or break. Refusing is honest and costs nothing to undo.

Consequences

Positive

  • The format's defining property becomes available in Python: incremental in, incremental out, no whole-input buffering on either side.
  • The API is the one a Python caller would guess — a for loop and a with block.
  • fileno() makes sockets and pipes work without a Python-object bridge, so the pipeline story is real rather than file-only.
  • One protocol surface, not two; the async question stays open without an API standing in for its answer.

Negative

  • A runtime per stream object. A current-thread runtime is cheap, but a caller holding hundreds of open streams pays for hundreds of them. That is the same caller who will want the async API, which is the signal § 1 asks for.
  • Composites are unreadable from Python, and a stream containing one fails rather than degrading. Deliberate, and the alternative is worse.
  • io.BytesIO is not accepted directly, only its getvalue(). A caller must know which of the two paths their object takes, which is a wart on an otherwise uniform constructor.
  • A duplicated descriptor is a resource the caller cannot see. It is closed on finish or drop, but a leaked reader leaks an fd until collection.

Required Documentation Amendments

  • docs/impl/python-bindings.md — a normative § Streaming section: the two classes, the transports, the iterator and context-manager protocols, the composite rejection, and the exception mapping.
  • docs/tutorials/python-interop-paths.md — currently says "the streaming format has no Python API yet; it is hurray-io, Rust only". That becomes false.
  • docs/cookbook/ — a Python streaming recipe beside the Rust ones in ipc-streaming.md and layer-5-streaming-interchange.md, per issue #147.
  • hurray-python/examples/streaming.py — runnable producer and consumer.

Open Questions Deferred

  • An asyncio surface. Revisit when a consumer needs to multiplex many streams in one process — that is the case a blocking API cannot serve by adding threads.
  • Composite streaming, which unblocks when hurray.Tensor can represent a buffer-less head.
  • Whether the writer should accept anything buffer-like rather than only hurray.Tensor — a NumPy array or a DLPack producer could be converted on the way in. Convenience, not capability; decide once the core API has users.

ADR-036: A composite is a container of tensors, not a tensor

Status

Proposed (2026-08-22)

Answers the question ADR-031 § 5 deferred — whether composites belong on the unified hurray.Tensor class. Amends ADR-032 § 6 (the error a composite layout raises) and ADR-035 § 4 (the stream reader stops refusing composites).

Context

Composites are the last capability hurray-core and hurray-io can express that hurray-python cannot, and they are blocked in three places at once:

WhereToday
authoringhurray.Tensor(..., layout=CompositeLayout(...)) raises UnsupportedError (ADR-032 § 6)
streamingStreamReader raises UnsupportedError on a composite, by name (ADR-035 § 4)
filessave / load have no composite path at all

All three trace to one root: hurray.Tensor cannot represent a head that owns no buffers. One decision unblocks all of them, which is why this is a single ADR rather than three.

The question ADR-031 left open is the one that has to be answered first. ADR-031 removed hurray.SparseTensor on the grounds that sparse is a layout, not a kind of object — a sparse tensor still has data, a shape, a dtype, and buffers, merely arranged differently. Does that argument reach composites?

Decision

1. hurray.Composite is its own class

It does not, and here is the difference. A sparse tensor has data. A composite contains tensors:

  • its head owns zero buffers — the format's own model calls it virtual, a fourth addressing category beside dense, sparse, and indirect (ADR-027)
  • len(composite.members) has no meaning on a tensor
  • there is no composite.values, no __dlpack__, no bytes to hand anyone — the data belongs to the members, each of which is an ordinary hurray.Tensor

ADR-031's rule was that a layout must not become a class. A composite is not a layout applied to data; it is a grouping of tensors that happens to be introduced by a descriptor. Giving it a class does not reopen SparseTensor, because nothing about a composite is expressible as "a tensor whose bytes are arranged differently".

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

composite.members          # (Tensor, Tensor)
composite.layout           # CompositeLayout(composition_rule='partition', member_count=2)
composite.shape            # (8, 8)

2. The head is stated, never derived

shape, dtype, the composition rule, and the combine operation are required — member_count is the one field taken from the members, because it is a count of what was passed rather than a claim about it.

A partition's head shape could in principle be derived from its members' shards, and it MUST NOT be. The same rule governs layout= on hurray.Tensor (ADR-032 § 4): the descriptor is a declaration, the members are evidence, and they must agree. Deriving would mean a caller who miscomputed a shard offset gets a head quietly reshaped to match their mistake, and a consumer downstream reading a composite that is self-consistent and wrong.

Validation is delegated entirely to hurray-core's CompositeValidator, which already enforces per-member checks, partition coverage, overlay ordering, and member count. The binding MUST NOT grow a second copy of those rules.

3. Members may be composites

members accepts hurray.Tensor or hurray.Composite, because the format nests (ADR-027 § Binding) and hurray-io already represents members as a tree. A nested member MUST be validated by the same path as a top-level one, and the depth limit is core's.

4. hurray.Tensor does not change

Constructing a tensor with a composite layout still raises hurray.UnsupportedError (ADR-032 § 6). The rule is unchanged; only the message changes, to name hurray.Composite instead of describing a gap.

This matters more than it looks. The alternative — letting Tensor hold zero buffers — would put a second family of inapplicable accessors on the class ADR-031 had to justify carefully: values, buffer, buffer_count, __dlpack__, __hurray__, __array__ would each need a composite branch, and hasattr would stop discriminating in the way ADR-031 § 2 relies on.

5. Composites travel over both I/O paths

  • StreamWriter.write MUST accept a Composite and emit it as head-then-members (write_composite).
  • StreamReader MUST yield a Composite where it previously raised, amending ADR-035 § 4. Its iteration remains one item per next(), a composite counting as one.
  • save MUST accept a Composite as a named entry, and load MUST return one.

A composite read back MUST equal what was written, member for member, so the round-trip obligation ADR-032 § 4 states for descriptors extends to composite trees.

6. Composites stay out of the native protocol

Composite MUST NOT implement __hurray__ or __dlpack__. The native protocol capsule carries a buffer list and one descriptor (ADR-030 § 2, ADR-034); a composite is a tree, and there is no honest way to flatten one into that shape without inventing wire structure this ADR has no business inventing.

A caller who wants a composite across a process boundary uses the streaming or file path, which the format already defines for exactly this. hasattr(obj, "__hurray__") therefore keeps meaning what it means, rather than becoming true for an object the protocol cannot actually carry.

Alternatives Considered

Let hurray.Tensor hold a buffer-less head. The instinct ADR-031 established, and the reason this question was deferred rather than answered there. Rejected under § 4: it buys uniformity in the type and pays for it in every accessor, and it would make hasattr — which ADR-031 § 2 chose deliberately over UnsupportedError — stop telling the truth. It also asserts something the format does not: that a head is a thing you can hold and use, when on the wire a head never appears without its members.

Represent a composite as a tuple or a dict — (head, [members]). Rejected for the reason ADR-035 already rejected it for streaming: it invents a second, ad-hoc representation that the next pass has to keep or break, and it gives the caller nothing to validate against.

Derive the head's shape and dtype from the members. Rejected under § 2. Convenient for the common partition case, wrong in exactly the case that matters.

Expose composites read-only first, defer authoring. Rejected: reading is the half that is nearly free (the reader already decodes the tree and throws it away), and authoring is the half that unblocks a Python producer. Shipping the easy half would close none of the three gaps in the table above.

Consequences

Positive

  • The last capability gap between hurray-python and the Rust layers closes; all three blocked paths open on one decision.
  • hurray.Tensor keeps its accessor discipline intact, and hasattr keeps discriminating.
  • The distinction the class draws — container versus tensor — is the one the format draws, so isinstance teaches the reader something true about the wire.

Negative

  • A second top-level object. Callers must now handle two kinds of thing coming out of a stream or a file, where before there was one. That is the format's shape, but it is still a branch every consumer has to write.
  • Composite cannot cross the native protocol, so the fastest in-process path does not carry them. Deliberate (§ 6), and a real limitation.
  • The head's shape and dtype are the caller's to get right. Core will reject a mismatch, but the caller must state something to be rejected — which is more work than deriving, and is the point.
  • Nesting makes repr a tree. It should be depth-aware, as TiledLayout's already is (ADR-032 § Consequences).

Required Documentation Amendments

  • docs/impl/python-bindings.md — a normative § Composites: the class, the required head parameters, nesting, the I/O paths, and the native-protocol exclusion.
  • docs/adr/ADR-031-*.md § 5 — the deferral is answered; note pointing here.
  • docs/adr/ADR-032-*.md § 6 — the UnsupportedError message now names Composite.
  • docs/adr/ADR-035-*.md § 4 — the reader yields composites; note pointing here.
  • docs/cookbook/composite-tensors.md, composite-streaming.md, composite-file.md — Python tabs beside the Rust recipes (#147).
  • hurray-python/examples/composites.py — runnable.

Open Questions Deferred

  • Whether a composite should be indexable — composite[0] as sugar for composite.members[0]. Ergonomics; decide once there are users.
  • Whether load should return composites lazily, reading members on access rather than eagerly with the head. Matters only for large trees, and the file reader would need to keep its handle open.
  • A native-protocol representation for trees (§ 6), which would need wire structure the format does not currently define.

ADR-037: Buffer metadata is a read-only value object, and alignment is measured

Status

Accepted (2026-08-23), implemented 2026-08-24 (§ 1–9, then § 6a)

Correction (2026-08-24, from implementation): § 6 specified hurray.BufferError for copy=False on an under-aligned source. The implementation raises hurray.CopyRequiredError instead — the binding already reserves that class for "copy=False requested but a copy is needed", and __array__ raises it for the same reason, so a caller catching one should catch both. Both subclass ValueError.

Extends ADR-032 to the last descriptor section without a Python representation, and applies its § 4 declaration-versus-evidence rule to a field where, unusually, the evidence is authoritative.

Context

The gap

docs/cookbook/layer-1-buffer-protocol.md is fourteen Rust blocks with no Python counterpart, because none of it is reachable. BufferHandle in hurray-core carries five fields; Python reaches three, two of them indirectly:

FieldPer-buffer?Reachable from Python
byte_sizeyesindirectly — t.buffer(i).shape[0]
alignmentyesno
sync_modeyesno
device_tagno — descriptor-widet.device.kind
memory_classno — descriptor-widet.device.memory_class

buffer-protocol.md § Device Colocation requires device_tag and memory_class to be identical across every buffer of one descriptor, so Tensor.device being a single device is correct and stays. The genuinely per-buffer gap is alignment and sync mode.

Issue #147 makes this a gap by policy. Two findings make it more than that.

Finding 1: the producer was declaring an alignment it did not provide

hurray.Tensor hardcoded MIN_BUFFER_ALIGNMENT (64) for every non-empty buffer, over Box<[u8]> allocations made at align_of::<u8>() == 1. Measured:

owned,  256 bytes  -> address % 64 = 32
owned, 1024 bytes  -> address % 64 = 16

The spec makes a 64-byte-aligned base address a MUST and says a reader MAY rely on the declared value, so every descriptor this binding produced invited a consumer's aligned SIMD load against an address that usually was not. Fixed for owned buffers in PR #179, which over-aligns the allocation so the declaration becomes true.

Borrowed buffers are not fixed, and cannot be without this decision. NumPy's alignment, measured over twenty samples per size:

     64 bytes: 10/20 were 64-byte aligned
   1024 bytes:  0/20
   16384 bytes: 0/20
  4194304 bytes: 0/20

Essentially never — and it is worse than chance for exactly the arrays where copying costs most. Above glibc's MMAP_THRESHOLD (128 KiB by default) an allocation is served by mmap, which returns a page-aligned block; glibc then places a 16-byte header before the pointer it hands back. Measured, and identical for plain malloc, so it is the allocator rather than NumPy:

np.zeros(n) address % 4096, ten samples each
   262144 bytes -> [16]
  1048576 bytes -> [16]
  4194304 bytes -> [16]
 16777216 bytes -> [16]

A large NumPy array served by a fresh mmap is therefore exactly 16 bytes past a page boundary, and never 64-byte aligned. Small arrays land on 64 about a quarter of the time, by luck.

Correction (2026-08-24, from CI). An earlier draft of this section said every large array is 16 bytes past a page, deterministically. That overstates it, and a test written on the strength of it failed on CI. The mechanism is real — 0/40 allocations of 1 MiB and above landed on 64 when the arrays were held, so a fresh mmap genuinely never qualifies — but glibc also recycles freed chunks, and a large allocation that lands in a recycled chunk inherits whatever offset the heap's history gives it, including 64. The honest claim is therefore: NumPy's alignment is unpredictable, reliably wrong for a fresh mmap and a matter of heap history otherwise. Nothing may be written that assumes either outcome for a particular array — including a test.

NumPy documents no alignment guarantee of its own: numpy.org/devdocs/dev/alignment defines only "true" and "uint" alignment for its internal copy code, both derived from dtype.alignment — 8 bytes for float64, never 64.

BufferHandle::with_memory_class rejects any declared alignment below 64, so the binding cannot state the truth either. hurray-python therefore cannot currently ingest a NumPy array zero-copy and be conformant. That is not a bug in the binding; it is a collision between the format's alignment floor and what the Python ecosystem allocates.

Finding 2: an accessor is only worth shipping if the value behind it is true

Exposing alignment on top of a fabricated constant would convert a latent producer bug into a documented API that lies to its caller, and would give the fabricated value users. So the accessor and the measurement rule are one decision, not two.

What the prior art already settled

  • ADR-031 — a layout is a property of a tensor, not a kind of object; and AttributeError over UnsupportedError, so hasattr keeps discriminating.
  • ADR-036 — a composite contains tensors, so it is a different kind of thing.
  • ADR-032 — layouts get a class, but as immutable value objects with no back-reference to their tensor, because a metadata accessor that pins buffer lifetime is a defect in a zero-copy format. Its rejection text carries the sentence that decides the shape here: "The buffer table is a sibling section of the layout section, not a child."

Layout, quantization, statistics, and shard each have a Python class. The buffer table is the only descriptor section that does not.

Decision

1. hurray.BufferHandle — one frozen value object per buffer-table entry

class BufferHandle:          # frozen; NOT constructible from Python
    byte_size: int           # the declared size in bytes
    alignment: int           # power of two; >= 64 when byte_size > 0
    sync_mode: str           # "producer_synced" | "event" | "consumer_stream"
    device: Device           # descriptor-wide; the same object t.device returns
    is_empty: bool           # byte_size == 0

with value equality, hashing consistent with it, and a repr of the form BufferHandle(byte_size=1024, alignment=64, sync_mode='producer_synced', device=cpu).

sync_mode is a lowercase string per ADR-032 § 5's rule for small closed enumerations, matching hurray_core::SyncMode's Display output — which hurray-inspect also prints — and produced by a single internal helper so the three cannot drift.

A BufferHandle holds no reference to its tensor and none to any buffer. It is five scalars copied out of the descriptor, so [h for t in stream for h in t.buffer_handles] pins nothing. This is ADR-032's rejection of layout back-references applied verbatim.

2. t.buffer_handles is a tuple property; t.buffer(i) is unchanged

class Tensor:
    buffer_handles: tuple[BufferHandle, ...]   # descriptor order
    def buffer(self, index: int) -> Tensor: ...   # unchanged: 1-D uint8 data view

Metadata and data get separate accessors deliberately: reading how large or how aligned a buffer is MUST NOT require materializing anything that references its bytes. On a CUDA tensor, asking "how is buffer 2 aligned?" must not construct a view over device memory the caller cannot read.

A property rather than a method, because the asymmetry is informative — buffer(i) does work and hands back something that pins memory; buffer_handles is five scalars per buffer. len(t.buffer_handles) == t.buffer_count is then self-evident, and IndexError comes from tuple indexing rather than a second hand-written error path.

hurray.Composite MUST NOT expose buffer_handles, for the reason ADR-036 § 4 excluded buffer and values: a head owns zero buffers, and an empty tuple answers a question that has no meaning.

3. BufferHandle.device is the tensor's Device object, identically

t.buffer_handles[i].device is t.device MUST hold for every i.

The wire row has five fields and the Python image should show five, so a reader diffing against hurray-inspect does not have to ask where two went. But colocation means the per-handle device is a redundant encoding of one fact, and five separately constructed Device objects would invite callers to compare them and branch on a difference the format forbids. Returning the same object gives wire fidelity without the hazard.

4. Everything is read-only, and the constructor is not touched

No alignment=, no sync_mode=, no buffer_handles=. hurray.BufferHandle is not constructible from Python, like the hurray.Layout base class.

This follows ADR-032 § 4 rather than departing from it. ADR-032 made layout= a constructor argument because a layout is a declaration whose truth the buffers cannot supply — nnz and strides are the author's intent, and a buffer of the right size is consistent with many of them. A buffer handle has no field of that kind:

FieldWhere its value comes from
byte_sizelen(buffer) — evidence, exact
alignmentthe base address — evidence, exact (§ 5)
device_tag, memory_classalready the device= argument
sync_modefixed by what Python can do (§ 6)

A buffer_handles= parameter would be a parameter with no free variables: its only possible use is to contradict the buffers, and every contradiction must be rejected. A parameter whose only reachable effect is to raise is not an API.

5. Alignment is measured, never asserted

This does not carve an exception out of "never infer". ADR-032's rule governs structural claims — statements about what bytes mean, which bytes cannot settle. Alignment is a physical property of an address that the binding can observe. Measuring is observation; asserting 64 without looking is the actual violation, and it is what the code did.

  • Empty buffers declare alignment = 1, matching BufferHandle::empty.
  • Owned buffers are allocated over-aligned to at least 64 and declare it (PR #179).
  • Borrowed buffers MUST have their base address measured, and MUST declare the largest power of two the address actually satisfies, capped at PAGE_ALIGNMENT. A stronger true declaration is legal and useful to IPC and RDMA consumers, and free.

6. An under-aligned borrowed source is copied, and the caller can say otherwise

Because the spec's floor is 64 and NumPy essentially never provides it, from_numpy and its siblings gain an explicit argument, matching the convention __array__ already follows in this binding:

def from_numpy(array, *, copy: bool | None = None) -> Tensor: ...
# copy=None   copy into a 64-byte-aligned allocation only if the source is under-aligned
# copy=False  raise hurray.CopyRequiredError naming the measured alignment; never copy
# copy=True   always copy

copy=None is the default because the alternatives are worse: refusing by default breaks from_numpy for essentially every array, and copying unconditionally gives up zero-copy even when the source would have qualified.

This is a real cost and it must be stated plainly rather than buried. Zero-copy NumPy ingest, which the binding appeared to offer, was never conformant; the honest version of it copies for most arrays. copy=False exists so that a caller who needs the guarantee gets an error instead of a silent memcpy, and so the cost is measurable rather than mysterious.

Note where the cost falls: a large array freshly served by mmap never qualifies, so the copy is all but unavoidable exactly where it costs most. It is not certain — a large allocation that lands in a chunk glibc recycled may happen to be aligned — but a producer cannot arrange for that, and unpredictability is no better than a copy. This is the strongest argument for the escape hatch below.

6a. The escape hatch: allocate through NumPy's pluggable allocator

Resolved (2026-08-24), implemented. Shipped as hurray.aligned_allocator(), a context manager only — no module-level install. The policy is thread-local, so "install once at startup" would quietly do nothing for arrays allocated on worker threads; one shape avoids teaching that footgun. No alignment= parameter either: 64 is the floor the format requires and the only reason the feature exists, and page alignment serves a different question (IPC/RDMA) that can be answered separately if it earns it.

A prototype settled four things that the write-up below had left to assumption:

  • The API slots must be called with the GIL held. They read and write a ContextVar; calling PyDataMem_GetHandler without the GIL segfaults immediately. Free under PyO3, but it means these calls must never sit inside a py.detach() block.
  • np.zeros goes through calloc, not malloc. An implementation covering only malloc looks correct and silently allocates nothing.
  • realloc is exercised (ndarray.resize) and is handed only the new size, so the allocator carries a 64-byte header recording each block's size. That also keeps every alloc/dealloc pair inside Rust, which is what the NEP's implementation notes warn to preserve.
  • Slots 304/305 verified against the installed NumPy 2.5.2 headers rather than recalled.

NumPy ≥ 1.22 lets an extension install a data-memory handler (NEP 49; numpy._core.multiarray.get_handler_name() reports default_allocator today).

This is the use case NEP 49 was written for. Its Motivation lists "ensuring data alignment" first, citing a 2005 numpy-discussion thread on SIMD alignment and issue #5312, "Use an aligned allocator for NumPy?", where 64-byte alignment produced a 40× improvement in one reported case. NumPy considered guaranteeing alignment itself, declined, and shipped the hook instead — so "bring your own allocator" is not a workaround here, it is the ecosystem's answer to exactly this question. That also strengthens § Alternatives: NumPy's own maintainers did not treat a 64-byte requirement as unreasonable, they treated satisfying it as the consumer's job.

Three properties of the mechanism make a scoped installer safe, each verified against NumPy's own tests and headers rather than assumed:

  • The handler is stored per array. "each ndarray carries with it the functions used at the time of its instantiation, and these will be used to reallocate or free the data memory of the instance." An array allocated inside the block is therefore freed by the matching free long after the block exits.
  • It is thread- and context-local. numpy/_core/tests/test_mem_policy.py asserts both: test_thread_locality requires that "the policy is not affected by changes in parallel threads", and test_context_locality covers asyncio. Installing a handler cannot leak into unrelated code.
  • PyDataMem_SetHandler returns the previous handler, and NULL restores the default, so save-and-restore is the intended usage.

With one gotcha that MUST be documented: child threads do not inherit the policy. Arrays allocated by a worker thread started inside the block get the default allocator, and will be copied on ingest like any other.

hurray-python SHOULD offer a handler that allocates 64-byte-aligned blocks:

with hurray.aligned_allocator():
    weights = np.zeros(shape, dtype=np.float32)   # 64-byte aligned
t = hurray.from_numpy(weights, copy=False)        # genuinely zero-copy

This turns "Hurray always copies NumPy arrays" into "arrays allocated for Hurray are not copied", which is a materially different bargain for a producer that controls its own allocations — the case that matters for an inference pipeline writing checkpoints.

Deferred rather than decided here only because it is additive and independent: the copy argument is needed regardless, for arrays the caller did not allocate.

Two implementation notes for whoever takes it: setting a handler is C-API only — NumPy exposes get_handler_name and get_handler_version to Python but no setter — and the numpy Rust crate this binding already depends on declares PyDataMem_SetHandler and PyDataMem_GetHandler at API slots 304/305 but leaves them commented out, so the binding must reach them itself. NEP 49's implementation PR also warns that mixing allocators risks mismatched alloc/free pairs, and recommends a PyCapsule base when taking ownership of data.

7. sync_mode is read-only, and byte-yielding paths refuse anything else

Read-only for a different reason than alignment, and the difference matters. Alignment is a fact the binding can observe. Sync mode is a promise the binding cannot keep: SYNC_EVENT means the producer recorded a device event and the consumer can retrieve it through the C ABI, and no Python API supplies an event handle. A sync_mode= keyword would let a caller emit a descriptor whose contract is unsatisfiable by construction — the consumer does the correct thing, waits for an event that does not exist, and gets a hard failure or a race. That is the same failure class ADR-030 and ADR-032 § 4 exist to prevent: a descriptor that encodes and decodes cleanly and is wrong.

Anything Python constructs is producer_synced, which is a consequence rather than a default: the interpreter cannot enqueue device work through this API.

Reading it carries an obligation. buffer-protocol.md § Consumer Requirement places a normative duty on the consumer — inspect the field, and for SYNC_EVENT wait on the producer's event before touching a byte. So:

A tensor holding any buffer whose sync_mode is not producer_synced MUST refuse the paths that hand out its bytes — buffer(i), __array__, __array_interface__, to_torch, __dlpack__ — with hurray.UnsupportedError naming the mode, until the binding can honour the wait.

This is not new policy. docs/impl/python-bindings.md § Stream parameter semantics already requires BufferError from __dlpack__ for Event and ConsumerStream. This extends the same discipline to every byte-yielding path, because the reason is the buffer's contract rather than the protocol's — and it makes the refusal explicable, since the caller can now read t.buffer_handles[0].sync_mode and see why.

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

8. Module constants

hurray.MIN_BUFFER_ALIGNMENT = 64 and hurray.PAGE_ALIGNMENT = 4096, mirroring hurray-core. The cookbook page's alignment sections cannot be translated without them.

9. Alignment is exempt from the round-trip obligation

ADR-032 § 4 requires that rebuilding a tensor from another's layout, quantization, statistics, shard, and buffers produce an equal descriptor. That obligation does not extend to alignment, and must not be made to: alignment describes an address, and a rebuild that copies bytes has a different address. A tensor that arrived declaring 4096 and is rebuilt through Python bytes will honestly declare 64. Adding a settable alignment= to force equality would restore the exact fiction § 5 removes.

Alternatives Considered

Parallel tuples on Tensor — t.alignments, t.sync_modes. Rejected: it decomposes one 16-byte wire row into unrelated columns the caller must re-zip, and it is the shape ADR-032 already rejected as "keeping layout as a string and adding separate properties for the parameters". It leaves nothing to compare, hash, or diff against hurray-inspect, and would make the buffer table the only descriptor section without a class.

Put the metadata on the view t.buffer(i) returns. Rejected on three independent grounds, the first fatal. Tensor::buffer returns a hurray.Tensor built by new_borrowed_view, so this is literally "add .alignment to hurray.Tensor" — and that view's descriptor carries a freshly fabricated handle, not the parent's buffer-table entry, so t.buffer(0).alignment would report the view's value rather than the parent's. Plumbing the parent's handle through would leave one attribute with two referents. Second, it puts a per-buffer field on every tensor, so t.alignment exists on a CSF tensor with nine buffers and answers about one. Third, it makes reading a number require materializing a view over bytes — ADR-032's lifetime objection exactly.

A constructible or settable BufferHandle. Recorded as rejected, not deferred, following ADR-032's treatment of layout back-references, because the alignment asymmetry in § 9 makes it certain to be re-proposed. Every field is either evidence or an unkeepable promise.

Relax the spec's 64-byte MUST for foreign memory. The tempting escape from § 6, and rejected — though it deserves the full argument, because it is the one option that would restore zero-copy NumPy ingest.

The case for it: DLPack imposes no alignment requirement at all, and a format whose most important on-ramp must copy has an adoption problem — sharpened by the measurement above, since the copy is certain for large arrays rather than occasional. The case against is stronger on this project's own terms. docs/prior-art.md § 699 lists "alignment guarantees — 64-byte minimum for SIMD; page-aligned for GPU/IPC — expressed in the spec, not left to convention" among the gaps Hurray deliberately fixes; relaxing it gives away a stated differentiator. The same document, at § 335, records that Arrow Flight loses alignment through gRPC and that receivers must copy to aligned memory — so copying at an unaligned boundary is the established remedy in the closest prior art, not a novel penalty. And a MUST that consumers can rely on is worth more than one they must defensively check, precisely because the consumer is the party that cannot see how the buffer was made.

Recorded here rather than settled silently: this is a format question, and a binding ADR cannot decide it. If the copy cost proves unacceptable in practice, the escalation path is a spec amendment through format-spec-writer, not a quiet relaxation in hurray-python.

Naming it BufferInfo or BufferSpec. Rejected: the spec calls it a buffer handle. Inventing a third vocabulary is what ADR-032 § Consequences warned about for layout names. Confusion with hurray-ffi's HurrayBuffer is already borne by hurray-core, which has both, and is answered with a sentence of documentation.

Consequences

Positive

  • The last descriptor section without a Python representation gets one, closing the buffer-protocol half of #147.
  • The binding stops declaring an alignment guarantee it does not provide, so a consumer's aligned SIMD load stops being a coin flip.
  • The consumer obligation sync_mode encodes becomes visible instead of suppressed, and the paths that cannot honour it fail loudly with a message the caller can verify.
  • The house pattern holds across all five sections: layout, quantization, statistics, shard, and buffer table are each a frozen value object with value equality, string enums, and a repr that agrees with hurray-inspect.

Negative

  • from_numpy now copies for most arrays. The largest cost in this ADR, and the one most likely to be reported as a regression. The previous behaviour was zero-copy and non-conformant; copy=False makes the difference diagnosable.
  • A sixth class in the namespace whose instances most callers never inspect.
  • alignment does not round-trip through a Python rebuild, by design (§ 9).
  • t.buffer_handles is not identity-stable — a fresh tuple per access, like t.layout. Value equality mitigates it.
  • Refusing byte access on non-producer_synced buffers converts a silent race into a visible failure, and will look like a regression to whoever meets it first.

Required Documentation Amendments

  • docs/impl/python-bindings.md — a normative § Buffer Handles covering the class, the tuple property, the identical-Device rule, non-constructibility, the measured alignment rule with copy, the sync_mode string set and byte-path refusal, and the § 9 exemption from the round-trip obligation.
  • docs/cookbook/layer-1-buffer-protocol.md — Python tabs, plus hurray-python/examples/buffer_protocol.py.
  • hurray-python/src/buffer.rs — the D2 design note describes an Owned variant that PR #179 already replaced.

Open Questions Deferred

  • Shipping the NEP 49 aligned allocator of § 6a — resolved and implemented 2026-08-24; see the note on § 6a.
  • Requesting a stronger alignment at construction — hurray.empty(..., alignment=4096) that allocates to the request and declares what it allocated. Explicitly not a reopening of § 4: an allocation request is an instruction to the allocator, not a declaration about memory the caller already holds.
  • Authoring event and consumer_stream, which needs an event-handle type in Python and a CUDA-capable path. ADR-030's deferred per-buffer stream question belongs with it.
  • Honouring a non-producer_synced buffer rather than refusing it (§ 7).
  • Private device tags are lossy in Python — device.rs collapses every tag in 0xF0–0xFE to "private" and cannot author one. Same cookbook page, separate decision; raised by the architect during this review.
  • validate_colocation has no Python surface. Probably correct to leave unexposed, but the cookbook's § Device Colocation has nothing to translate to, so it wants an explicit decision rather than an omission.