Tensor Operators
JSON has no tensor. Anything that marshals JSON into a model’s inputs and
its outputs back into JSON (an ONNX Runtime session, an edge request
encoder, an RL observation buffer) needs one value that is neither a
scalar nor a JSON array. The tensor feature adds it: an opaque
n-dimensional typed buffer (a dtype, a shape, and one row-major
contiguous byte payload), plus twenty operators that make, reshape, and
read one back.
Feature flags (Rust crate). All tensor operators require the
tensorfeature.tensor-halfalso enablesf16/bf16elements. Every language binding enablestensorbut nottensor-half. See the feature table.
Argument counts: each section’s syntax lists every argument an
operator reads, optional ones included. to_list, shape and dtype
take one; zeros, stack, concat, unstack, reshape, cast and
argmax two; full, rle_expand, one_hot and crop three; tensor
and transpose one or two; normalize and gather two or three;
scatter and pad three or four. See Errors and limits
for a call with the wrong count.
What this is not
There is no arithmetic here: no matmul, no convolution, no element-wise add. The family marshals data and nothing else. Every operator’s cost is proportional to the data it moves, which is what keeps it honest to price; an operator whose work is not proportional to its data (a matmul reads 2n² elements and does n³ multiplies) would break that property. Model execution belongs in the runtime you are feeding, not in the rules engine.
The wire form
A tensor crosses the JSON boundary as a single-key tagged object:
{ "tensor": { "dtype": "f32", "shape": [2, 2], "data": "AACAPwAAAEAAAEBAAACAQA==" } }
data is the raw little-endian payload in standard base64. This one form
is the operator call, the form the engine emits, and the form the decoder
accepts, so a serialized tensor pasted back into a rule
evaluates to the tensor it came from, and a tensor stored in your input
data decodes with {"tensor": [{"val": "..."}]}.
Because the text-returning bindings (WASM, Node, C and everything built on it) hand back JSON, tensors flow through them unchanged with no FFI change.
dtypes
bool, i8, u8, i16, u16, i32, u32, i64, u64, f16,
bf16, f32, f64. Names match case-insensitively, so
safetensors-style "F32" works.
The shape operators (stack, concat, unstack, reshape,
transpose, pad, crop, gather) move raw cells and never interpret
one, so they work on every dtype including f16 / bf16 with no extra
feature. zeros does too: an all-zero buffer is a valid value of every
dtype. The element operators need tensor-half before they will
touch f16 / bf16, and answer an unsupported-dtype error otherwise.
tensor
Build a tensor, or pass one through. The family’s entry point.
Syntax:
{ "tensor": [value, dtype] }
{ "tensor": [value] }
Arguments:
value: nested JSON arrays (or a bare scalar for a 0-d tensor), the tagged wire form, or an existing tensordtype: required for the nested-array form; the shape is inferred from the nesting
Returns: A tensor.
An element that does not fit the declared dtype (300 as u8) is an
error; the operator does not truncate it or replace it with an infinity.
Examples:
{ "tensor": [[[1, 2], [3, 4]], "f32"] }
// Result: a 2x2 f32 tensor
{ "tensor": [{ "val": "prediction" }] }
// Decodes a tagged tensor out of the input data
{ "reshape": [{ "tensor": [[1, 2, 3, 4], "u8"] }, [2, 2]] }
// A flat list plus a shape
zeros / full
Build a tensor of a declared shape.
Syntax:
{ "zeros": [shape, dtype] }
{ "full": [shape, dtype, value] }
Examples:
{ "zeros": [[2, 3], "i32"] } // 2x3 of 0
{ "full": [[2, 2], "f32", 1.5] } // 2x2 of 1.5
scatter
Sparse writes into an otherwise-zero tensor.
Syntax:
{ "scatter": [points, shape, dtype, value] }
Arguments:
points: array of coordinate arrays.[i, j]writesvalue(1 by default);[i, j, v]writesvvalue: optional default written value
scatter drops an out-of-range point without an error: the usual
producer is a detector emitting boxes in source coordinates that may fall
outside the target grid.
Examples:
{ "scatter": [[[0, 1], [1, 0]], [2, 2], "u8"] }
// [[0, 1], [1, 0]]
rle_expand
Run-length decode into a tensor, row-major.
Syntax:
{ "rle_expand": [runs, shape, dtype] }
runs is the flat [v0, n0, v1, n1, …] pairing. The run lengths must sum
to exactly the shape’s element count. A mask that decodes to the wrong
size is a producer bug, and zero-filling the remainder would hide it.
Examples:
{ "rle_expand": [[0, 2, 1, 2], [2, 2], "u8"] }
// [[0, 0], [1, 1]]
one_hot
Build a [len, depth] indicator matrix.
Syntax:
{ "one_hot": [indices, depth, dtype] }
An index outside 0..depth leaves its row all-zero.
Examples:
{ "one_hot": [[0, 2], 3, "u8"] }
// [[1, 0, 0], [0, 0, 1]]
stack / concat / unstack
Join and split along an axis. stack introduces a new axis (so the
result is one rank higher); concat joins along an existing one.
unstack is the inverse of stack. Negative axes count from the end.
Syntax:
{ "stack": [tensors, axis] }
{ "concat": [tensors, axis] }
{ "unstack": [tensor, axis] }
stack requires identical shapes; concat requires them to agree on
every axis but the joined one. Both require a common dtype. unstack
returns an array of tensors.
Examples:
{ "stack": [[{"tensor": [[1, 2], "u8"]}, {"tensor": [[3, 4], "u8"]}], 0] }
// A 2x2 tensor: [[1, 2], [3, 4]]
reshape
Reinterpret the same bytes under a new shape. The only operator here that copies nothing: the output shares its payload with the input.
Syntax:
{ "reshape": [tensor, shape] }
The element count must match exactly; there is no inferred -1
dimension.
transpose
Permute the axes. perm defaults to a full reversal, so a 2-d transpose
needs no second argument.
Syntax:
{ "transpose": [tensor] }
{ "transpose": [tensor, perm] }
pad / crop
Grow or cut every axis. crop is the inverse of pad.
Syntax:
{ "pad": [tensor, before, after, value] }
{ "crop": [tensor, offset, shape] }
before and after take one entry per axis. value defaults to 0.
A crop window must lie inside the input.
Examples:
{ "pad": [{ "tensor": [[1, 2], "u8"] }, [1], [2]] }
// [0, 1, 2, 0, 0]
gather
Select slices along an axis, in the order given, so it both reorders and resamples.
Syntax:
{ "gather": [tensor, indices, axis] }
axis defaults to 0. Negative indices count from the end. Every index
must be in range: unlike scatter, dropping one would change the output
shape without an error.
cast
Convert to another dtype. cast is the family’s only cross-dtype
conversion, and it is lossy.
Syntax:
{ "cast": [tensor, dtype] }
Narrowing saturates rather than wrapping (300 to u8 is 255, not
44), and NaN becomes 0. cast converts each value through f64, so
i64 / u64 elements above 2^53 lose their low bits. Casting to the
dtype a tensor already has returns it unchanged, with no trip through
f64.
normalize
(x − mean) × scale, always producing f32.
Syntax:
{ "normalize": [tensor, mean, scale] }
scale defaults to 1, so the two-argument form is a plain mean
subtraction. The output dtype is fixed because that is what the operation
is for: turning integer sensor or pixel data into the float range a model
expects. mean and scale are scalars; per-channel normalization is
unstack + normalize + stack.
Examples:
{ "normalize": [{ "tensor": [[0, 255], "u8"] }, 127.5, 0.5] }
// [-63.75, 63.75]
argmax
Index of the largest element along an axis, as plain JSON.
Syntax:
{ "argmax": [tensor, axis] }
Returns: Nested JSON arrays of indices, or a bare number when the input is 1-d. An index is something a rule goes on to compare and branch on, so it comes back as ordinary JSON rather than a tensor.
Ties go to the first occurrence, and NaN never wins.
Examples:
{ "argmax": [{ "tensor": [[[1, 9], [7, 3]], "f32"] }, 1] }
// [1, 0]
to_list
Expand into plain nested JSON arrays.
Syntax:
{ "to_list": [tensor] }
The general escape hatch, and expensive by design: this is the one
operator that turns a compact buffer back into one JSON node per element.
Reach for argmax, shape or a comparison first.
shape / dtype
Read the header.
Syntax:
{ "shape": [tensor] }
{ "dtype": [tensor] }
shape returns an array of numbers; dtype returns the wire name, which
is exactly what tensor, zeros, full and cast accept back.
Errors and limits
- Argument count. Every tensor operator checks its argument count
before it evaluates any argument: too few raises
missing argument, too many raisestoo many arguments(bothInvalidArguments). - Size cap.
zeros,full,scatter,rle_expand,one_hotandpadsize a new buffer from dimensions in the rule. Past 2^28 (268,435,456) elements, 2 GiB off64, they raiseInvalidArguments("tensor would hold more than 268435456 elements")before allocating, so{"zeros": [[1000000, 1000000], "f64"]}fails fast on an engine without an operation budget. - Two problems in one call. The arguments are evaluated in order, and
an error raised while evaluating a later argument wins over a coercion
error on an earlier one:
{"zeros": ["bad", {"throw": "x"}]}throwsx.
How a tensor behaves as a value
| Context | Behavior |
|---|---|
type | "tensor" |
| Truthiness | true unless the element count is 0, matching the empty-array rule. A 0-d tensor holds one element, so it is truthy |
== / != | Structural between two tensors: dtype, shape, and payload. Against any other type it is incompatible, which follows the loose_equality_errors config as an object does |
=== | Structural, with no coercion at all |
| Numeric coercion | None. A one-element tensor does not coerce the way a one-element array does; to_list is the explicit way out |
sort | Tensors rank after objects; two tensors order by dtype, then shape, then payload bytes |
map, array elements, object fields | Passed through untouched |
Tensor operators are never constant-folded and never memoized by the CSE
pass, even though they are pure functions of their arguments: folding
{"zeros": [[128, 128], "f32"]} would bake a 64 KB literal into the
compiled rule.
See Also
- Array Operators — manipulate ordinary JSON arrays before converting with
tensoror afterto_list - Operation Budget — how tensor operations charge per element
- Configuration — enable or disable
Family::Tensorat runtime - Operators Overview — full operator catalog and feature-flag table