Quabla

Quabla API Reference

This document is the reference companion to the README. It describes the Python API on main:

v1.0 removed the v0.1 surface that 0.x kept for compatibility: the trace_tensor and tensor_*_fn helpers, the device optimizer helpers, the 2D Matrix API with quabla.legacy, and the other names deprecated since v0.2. The migration guide maps each of them to the core API.

Paths in code spans (for example examples/pinn_poisson.py) are relative to the repository root. Per-feature status and validation records are in jax_like_roadmap.md. The examples below use import quabla as qb.

Contents

Core API

Arrays and NumPy

qb.array(obj, dtype=None)       # always a new object
qb.asarray(obj, dtype=None)     # returns obj unchanged if already a Tensor of dtype
qb.zeros(shape, dtype=None), qb.ones(...), qb.full(shape, fill_value, ...)
qb.arange(start, stop=None, step=1.0, dtype=None), qb.linspace(start, stop, num, ...)
qb.eye(n, m=None, dtype=None)

Function Transforms

The v0.2 transforms (api_v0_2_design.md, sections 3.3-3.5; see Device Execution) take pytree arguments (see Pytrees) of arrays and Python scalars, and need no input specs:

qb.grad(fun, argnums=0, has_aux=False)            # -> grads, or (grads, aux)
qb.value_and_grad(fun, argnums=0, has_aux=False)  # -> (value, grads)
qb.jvp(fun, primals, tangents)                    # -> (out, tangent_out)
qb.vjp(fun, *primals, has_aux=False)              # -> (out, vjp_fun[, aux])
qb.jacobian(fun, argnums=0)                       # blocks [*out.shape, *in.shape]
qb.hessian(fun, argnums=0)                        # blocks [*in.shape, *in.shape]
qb.vmap(fun, in_axes=0, out_axes=0)               # -> batched fun
qb.jit(fun, device=None, static_argnums=(), max_traces=8, static_argnames=(),
       precision=None)

jit, grad, and value_and_grad called with keywords only return a decorator: @qb.jit(device="mlx", static_argnums=1), @qb.grad(argnums=1).

Custom Differentiation Rules

custom_vjp, custom_jvp, and checkpoint (alias remat) follow JAX:

@qb.custom_vjp
def log1pexp(x):
    return qb.log(1.0 + qb.exp(x))

def log1pexp_fwd(x):
    return log1pexp(x), x                      # (output, residuals)

def log1pexp_bwd(x, g):                        # (residuals, cotangent)
    return (g * (1.0 - 1.0 / (1.0 + qb.exp(x))),)

log1pexp.defvjp(log1pexp_fwd, log1pexp_bwd)

@qb.custom_jvp
def safe_norm(x):
    return qb.sqrt(qb.sum(x * x))

@safe_norm.defjvp
def safe_norm_jvp(primals, tangents):
    (x,), (t,) = primals, tangents
    norm = safe_norm(x)
    return norm, qb.sum(x * t) / qb.where(qb.equal(norm, 0.0), 1.0, norm)

layer = qb.checkpoint(lambda h: qb.tanh(h @ w + b))

Pytrees

Transform arguments and results, optimizer parameters, and Trainer data are pytrees: nested containers whose leaves are arrays or Python values. The containers are:

Dataclasses are not containers until registered, as in JAX:

@qb.tree.register_dataclass
@dataclasses.dataclass(frozen=True)
class Layer:
    w: qb.Tensor
    b: qb.Tensor

qb.tree.register_dataclass(Mlp, data_fields=("layers",), meta_fields=("activation",))
qb.tree.register(Interval, flatten_fn, unflatten_fn)

register_dataclass(cls, data_fields=None, meta_fields=()) makes the data_fields children (default: every __init__ field not in meta_fields, in declaration order) and the meta_fields static; together they must name every __init__ field once. Values are rebuilt with cls(**fields), so frozen dataclasses work and __post_init__ runs again. register(cls, flatten_fn, unflatten_fn) is the general form: flatten_fn(value) returns (children, aux_data) and unflatten_fn(aux_data, children) rebuilds the value. Only exact instances of a registered class are containers, and a class can be registered once.

The structure includes each node’s class and aux data (the meta fields of a dataclass), and is part of every trace cache key: two classes with the same fields, or two values of a meta field, never share a trace, so aux data must be hashable and a new value retraces. Gradients, vmap results, and Jacobian blocks keep the custom classes of their arguments; vmap in_axes/out_axes prefixes may use the same classes (with equal aux data), for example in_axes=(Point(0, None),). Other container subclasses (OrderedDict, a list subclass) and unregistered dataclasses are rejected as transform arguments.

quabla.tree provides flatten(tree) -> (leaves, treedef), unflatten(treedef, leaves), leaves(tree), structure(tree), map(f, tree, *rest) over trees of one structure, and flatten_with_path(tree) -> ([(path, leaf), ...], treedef), where a path is a tuple of dict keys, field names, and integer positions. A TreeDef compares and hashes by structure and has num_leaves and unflatten(leaves).

Random Numbers

quabla.random samples with explicit keys, in the style of jax.random:

key = qb.random.key(0)
k1, k2 = qb.random.split(key)              # split(key, num=2) -> tuple of keys
w = qb.random.glorot_normal(k1, (64, 32))  # float64 by default
noise = qb.random.normal(k2, (128,), dtype=qb.float32)
step_key = qb.random.fold_in(key, step)
Function Result
key(seed) a Key from an integer seed (modulo 2**64)
split(key, num=2) a tuple of num new keys
fold_in(key, data) a new key from a key and an integer
uniform(key, shape=(), dtype=float64, minval=0.0, maxval=1.0) samples on [minval, maxval)
normal(key, shape=(), dtype=float64) standard normal samples
bernoulli(key, p=0.5, shape=()) bool_ samples, True with probability p
glorot_normal(key, shape, dtype=float64) std sqrt(2 / (fan_in + fan_out))
glorot_uniform(key, shape, dtype=float64) [-l, l) with l = sqrt(6 / (fan_in + fan_out))
he_normal(key, shape, dtype=float64) std sqrt(2 / fan_in)

Saving and Loading

qb.save("checkpoint.qb", {"params": params, "opt": opt_state})
restored = qb.load("checkpoint.qb")

save(file, tree) writes a pytree to a path or a binary file object, and load(file) reads it back. Leaves may be quabla arrays (Tensor or TensorView), None, and Python bool, int, float, and str values; containers may be dict with string or integer keys, list, and tuple. Arrays load as Tensors with the saved shape and dtype, bit for bit; scalars keep their type and value (float exactly), dicts their key order, and tuples stay tuples, so an optimizer state such as the {"step": 3, "m": ..., "v": ...} of optim.Adam resumes unchanged.

NamedTuples and classes registered with quabla.tree.register or register_dataclass are not serialized, since rebuilding them would mean importing classes named by the file; save their fields, for example as a dict, and rebuild the objects after loading. Other leaves (NumPy arrays, random.Key values) and traced values raise TypeError.

The format needs neither NumPy nor pickle, so load never executes code from the file; a malformed or truncated file raises ValueError. Version 1 is laid out as:

Offset Size Content
0 8 magic bytes b"\x93QUABLA\n"
8 8 header size N, unsigned little-endian
16 N header: strict UTF-8 JSON
16 + N rest raw array data, concatenated

The header is {"format": "quabla", "version": 1, "tree": NODE}, where a node is {"type": "none"}, {"type": "list" | "tuple", "items": [...]}, {"type": "dict", "keys": [...], "values": [...]}, {"type": "bool" | "int" | "str", "value": ...}, {"type": "float", "value": float.hex(x)}, or {"type": "array", "dtype": "float64" | "float32" | "bool", "shape": [...], "offset": ..., "nbytes": ...}. Array data is C-ordered and little-endian (IEEE binary64 or binary32; one byte, 0 or 1, per bool), at offset bytes from the start of the data section. A reader rejects any other version.

Version and Type Stubs

quabla.__version__ is the installed version string (for example "0.2.3"). The Cargo manifest of crates/quabla-python is its single source: maturin derives the package metadata from it, and the extension carries the same string for a source tree without installed metadata.

The package ships py.typed and a stub, quabla/_quabla.pyi, for the compiled extension. It types Tensor, TensorView, TraceTensor, dtype and the dtype objects in detail, including that an operator with a traced operand gives a TraceTensor; the other extension functions and classes (the comparison functions, the compiler facade, and the private helpers the Python layers call) are declared with their parameter names but loose (Any) types. The pure-Python modules (quabla.optim, quabla.tree, the transforms) are not annotated yet, so checkers infer their types.

Device Execution

qb.jit(fun, device=None, static_argnums=(), max_traces=8) selects CPU by default, or explicitly "cuda", "cuda:N", or "mlx". A missing build target raises UnsupportedOperationError; device lowering rejections carry .op and .device. Invalid device spellings are ValueError. Eager array operations remain on the host. Device calls upload inputs and return host output pytrees; logical float64 device programs emit one UserWarning per compiled function because execution is float32. qb.devices() lists built targets, not physical GPU ordinals.

qb.jit(fun, device="cuda", precision="float64") executes float64 programs natively in double precision on CUDA, without the warning. Consumer GPUs run f64 arithmetic at a small fraction of their f32 rate (a matmul-heavy value-and-gradient step takes about 9x longer on a GTX 1660 SUPER), so float32 execution stays the default. Under precision="float64" every operation the CUDA backend lowers runs in double: the NVRTC kernels (elementwise, reductions, cumsum, Cholesky and its derivatives, fused and host-driven fori/scan/while/cond regions) and cuBLAS dgemm and cuSOLVER getrf/getrs/syevd/geqrf/orgqr/gesvdj; results match the CPU float64 reference to about 1e-13 relative. One compiled program has one floating element type, so a program that also contains float32 values raises UnsupportedOperationError with .op == "float32"; cast them to float64 or use the default precision. A program without float64 values compiles as with the default. The argument is a no-op on the CPU, which already runs float64 natively, raises UnsupportedOperationError on MLX, which has no float64 arithmetic, and is part of the trace-cache configuration, so the two precisions never share a compiled program. Other values than None and "float64" are ValueError. qb.optim.Trainer takes the same precision keyword; the NCCL data-parallel paths keep float32 device execution.

qb.jit(fun).lower(*args) accepts arrays or qb.ShapeDtype(shape, dtype) inside pytrees and traces without execution. The lowered object exposes .as_text(), an ordered-output facade .program, and .compile() returning a positional callable that rejects changed structures, shapes, dtypes or static values. The facade’s .output_shapes lists traced outputs (including duplicates); .program.compile(target)(inputs_dict) returns their ordered list. .compile() on the lowered object also restores constants and pytree structure. Transform the Python function before lowering; multi-output facade Program.jvp/vjp are explicitly rejected. Existing Compiler.trace keeps its single-output Tensor contract.

qb.optim.Adam(learning_rate=1e-3, b1=0.9, b2=0.999, eps=1e-8) and qb.optim.SGD(learning_rate=1e-2) provide init(params) and pure update(params, grads, state) -> (params, state) over floating Tensor pytrees. Adam also has a stateful step(params, grads), which keeps the init state on the optimizer and returns update’s parameters bit for bit, and accepts the keyword aliases beta1, beta2, epsilon. Host moments are float64, and parameters keep their dtype. qb.optim.AdamW(learning_rate=1e-3, b1=0.9, b2=0.999, eps=1e-8, weight_decay=1e-4) applies decoupled weight decay as optax’s adamw does: p - lr * (m_hat / (sqrt(v_hat) + eps) + weight_decay * p) with the pre-update p; its state and precision rules are Adam’s, and it has no step.

Every learning_rate also accepts a schedule, a callable f(step) -> float evaluated on the host at the number of completed updates (0 for the first update, as in optax). Adam and AdamW keep that count in state["step"]; a scheduled SGD’s state is {"step": n} instead of None. The schedules follow optax’s formulas and argument order: constant(value), exponential_decay(init_value, transition_steps, decay_rate, transition_begin=0, staircase=False, end_value=None), cosine_decay(init_value, decay_steps, alpha=0.0), warmup_cosine_decay(init_value, peak_value, warmup_steps, decay_steps, end_value=0.0) (where decay_steps includes the warmup), and piecewise_constant(boundaries, values), which returns values[i] from step boundaries[i - 1] on. Invalid configurations raise ValueError where optax warns, and a schedule value that is negative or not finite raises when used.

qb.optim.clip_by_global_norm(grads, max_norm) returns (clipped_grads, global_norm). The norm over all leaves is computed in float64 as m * sqrt(sum((g / m) ** 2)) with m = max |g|, so it neither overflows nor underflows; leaves are scaled by min(1, max_norm / norm) and keep their dtype. A NaN or infinite norm returns all-NaN gradients rather than finite values; test math.isfinite(global_norm) to skip such steps. The keyword clip_norm= on Adam, AdamW, and SGD applies the same clipping to every update.

qb.optim.LBFGS(history=10, max_iterations=500, max_evaluations=None, tolerance_grad=1e-7, tolerance_change=1e-9, line_search="strong_wolfe") is a host-side full-batch minimizer for the “Adam, then L-BFGS” stage of PINN training. minimize(fun, params, *args) jits value_and_grad(fun) on the CPU once, iterates over one float64 vector of all parameter leaves (float32 leaves are evaluated, and their iterate stored, at float32 precision), and returns (params, info). It uses the two-loop recursion with H0 = (s'y / y'y) I, skips curvature pairs with s'y <= 1e-10 ||s|| ||y||, and a strong Wolfe line search (c1=1e-4, c2=0.9, safeguarded cubic interpolation, at most 25 evaluations) that backtracks from non-finite losses. A failed line search restarts once from steepest descent. info holds iterations, evaluations, loss, grad_norm (infinity norm), converged, and reason: gradient_tolerance, change_tolerance (|f_k - f_k+1| <= tol * max(|f_k|, |f_k+1|), scipy’s ftol test without its floor of 1, so small PINN losses are not stopped early), max_iterations, max_evaluations, or line_search_failed (usual at the float32 resolution limit).

qb.optim.Trainer(loss, params, optimizer, *data, device="cpu", batch_argnums=(), precision=None) traces loss(params, *data). Every device supports Adam, AdamW, and SGD with a constant rate or a schedule, and with clip_norm. A schedule is evaluated on the host and sets the native optimizer’s learning_rate before each step. Device updates follow the CPU definitions in the CPU’s operation order: AdamW’s decay weight_decay * p uses the pre-update p and is scaled by the scheduled rate, and the bias corrections and 1 - beta are formed in float64. clip_norm reduces the global norm on the device, without reading gradients back: m * sqrt(sum((g / m) ** 2)) with m = max |g| over all parameters, accumulated in float64 on CUDA and in float32 on MLX (every term is at most one, so neither overflows), then every gradient is scaled by min(1, clip_norm / norm) before the moment updates. As on the CPU, a zero norm leaves the gradients unchanged and a NaN or infinite norm makes every parameter NaN. A parameter the loss ignores has a zero gradient: it changes only by AdamW’s decay, or to NaN after a non-finite clipped norm, as on the CPU. Device plans compute in float32 by default and agree with the CPU trainer to float32 rounding. precision="float64" (validated as in jit) runs a float64 loss natively on CUDA with float64 parameters and moments, agreeing with the CPU to float64 rounding; it is a no-op on the CPU and raises UnsupportedOperationError on MLX. batch_argnums indexes data positions, excluding params. step(*batch) replaces them in the declared order, with unchanged pytree/shapes/dtypes; step() reuses data without Python flattening on devices. Device parameters and moments stay retained; .loss() and .params explicitly read back. python examples/pinn_mlp.py --device cuda (or --device mlx) trains a small PINN on the device and checks it against the CPU trainer.

qb.cond(pred, true_fun, false_fun, *operands) runs one branch lazily. qb.fori_loop(lower, upper, body, init, operands=(), unroll=False) calls body(i, carry, *operands). qb.scan(f, init, length=n, operands=(), unroll=False) calls f(carry, i, *operands) -> (carry, output) and stacks outputs on axis zero. Bounds are static non-negative integers, scan length is positive, and carries are single arrays. Region indices are scalar arrays; eager/unrolled indices are Python integers. Pass every outer tracer used by a region as an operand; branches and bodies may ignore operands, and cond branches may return constants of the other branch’s dtype. Unsupported implicit capture raises TracerError naming operands=.

qb.while_loop(cond_fun, body_fun, init, operands=()) repeats carry = body_fun(carry, *operands) while cond_fun(carry, *operands), a scalar bool, is true. A traced carry or operand forms one region; CPU and MLX evaluate the predicate, read it back to the host once per iteration, and then run the body, so the trip count may depend on traced values. CUDA does the same with device-resident carry buffers: the predicate and body regions are compiled once and only the scalar predicate is read back per iteration. Forward mode (jvp) runs the same loop over a packed primal/tangent carry. Reverse mode (grad, vjp, and the reverse-mode jacobian/hessian) raises an error naming fori_loop, as in JAX: a data-dependent trip count leaves no fixed tape, so write a bounded fori_loop whose body masks finished iterations with where instead. A body or predicate may ignore its carry. vmap batches a while loop as jax.vmap does: when the predicate depends on a mapped value, the predicate region becomes “any example continues” and the body selects where(pred, body(carry), carry) per example, so a finished example keeps its carry (and forward-mode tangent) bit for bit while the others iterate, and whatever the body computes for it meanwhile, NaN or inf included, is discarded. The body still runs on finished examples, so a batch costs the trip count of its slowest example; backends still read back one scalar flag per iteration.

qb.linalg holds dense linear algebra over the last two axes, batched over leading axes that broadcast like NumPy’s. Every function takes eager or traced arrays and differentiates to every order, because each derivative rule is written with solve, matmul, and the decompositions themselves:

Derivatives at exactly singular matrices are undefined: the derivatives of slogdet, det, and inv call solve, which raises on the CPU and MLX and reports the singular factor on CUDA (JAX returns non-finite values). A repeated eigenvalue makes F infinite, so the eigenvector derivative is inf or NaN, as in JAX, while the eigenvalue derivative stays defined.

On MLX, whose LU, eigh, QR, and SVD factorizations exist only on its CPU stream, each factorization runs there with LAPACK (getrf, syevd, geqrf/orgqr, gesdd) in float32, and the CPU backend’s conventions (ascending eigenvalues, signed vectors, a non-negative diagonal of R, the Householder completion of a complete Q or full SVD basis, NaN for a non-finite matrix) are applied on the GPU stream; the two streams share unified memory, so nothing is copied between them. solve (and so inv, solve_triangular, cho_solve, lstsq, and every derivative that solves) substitutes with the LU factors in a Metal kernel and reads one flag back to raise on an exactly zero pivot like the CPU. Exact zero pivots depend on rounding: a matrix that is singular only in exact arithmetic, such as one with linearly dependent rows, can leave a pivot of rounding size, and is then solved with huge values instead of raising; LAPACK’s float32 rounding can do so where the CPU’s float64 elimination finds an exact zero.

Iterative and implicit solvers take functions instead of matrices:

Each runs eagerly in Python or, under jit, as one while_loop region that stops at convergence (||b - A x|| <= max(tol * ||b||, atol) for the linear solvers, ||dx|| <= tol * (1 + ||x||) for newton). Derivatives with respect to b and the array leaves of args follow the implicit function theorem at the solution instead of differentiating the iterations: reverse mode solves one adjoint system, and forward mode (jvp, or vmap of jvp for a forward-mode Jacobian) one tangent system, x_dot = A^-1 (b_dot - A_dot x) for the linear solvers and x_dot = -J^-1 (df/dargs) args_dot for newton; x0 gets no derivative. Pass every array the operator depends on through args: values a Python function closes over are not differentiated. Every combination of two derivative passes uses these rules (grad(grad(...)), jvp(grad(...)), grad of jvp, jvp(jvp(...))), and so does a third reverse pass; a third pass involving forward mode raises. jacobian and hessian of a solution use reverse mode. A solve may run inside cond, fori_loop, scan, and while_loop bodies, such as an implicit time step in a fori_loop, and the loop’s derivatives apply the rules. vmap batches a solve over b, x0, and the leaves of args, and composes with the derivatives in both orders (vmap(grad(...)), grad of a loss over vmap): each example stops at its own tolerance, because the batched loop freezes an example’s state once it has converged, so the batch costs the iterations of its slowest example. An eager solve that does not converge raises RuntimeError; info=True returns (x, info) with "iterations", "residual_norm", and "success", per example under vmap, and the only report under jit and vmap.

qb.ode.odeint(f, y0, (t0, t1), steps=n, method="rk4", args=(), save=False, saveat=None) integrates dy/dt = f(y, t, *args) with n equal steps of classical RK4, Heun’s method ("heun"), or forward Euler ("euler"), at times t0 + i * dt so long integrations do not drift. A traced state forms one fori_loop region (a scan region with save=True, which returns the n + 1 states stacked on axis zero), so solves differentiate with respect to y0, t0, t1, and args and run under jit on every device. y0 is a single array, and f must receive traced values through args, as loop bodies do.

qb.ode.odeint(f, y0, (t0, t1), method="dopri5", rtol=1e-6, atol=1e-9, max_steps=512, info=False) is the adaptive Dormand-Prince 5(4) pair with first-same-as-last stages. Steps are accepted when the RMS norm of the embedded error estimate scaled by atol + rtol * max(|y|, |y_new|) is at most one; a PI controller (gains 0.7/5 and 0.4/5, safety 0.9, factor clipped to [0.2, 10]) picks the next step, rejected steps fall back to the I-controller, and the first step follows Hairer and Wanner’s starting-step algorithm. The last step lands exactly on t1, and integration may run backward. The solve is a bounded fori_loop whose body stops advancing once t1 is reached, so it is reverse-mode differentiable; a traced solve therefore always pays max_steps step attempts. It runs on every device; its body slices and concatenates the packed carry and takes norms, so CUDA runs it as a host-driven region loop (see the CUDA loop lowering notes). Step sizes are controller outputs without gradients (only the final step depends on t1), so derivatives are those of the discrete scheme on the chosen mesh and are piecewise smooth across accept/reject changes. An eager solve stops at t1 and raises RuntimeError when max_steps attempts do not reach it; under jit it cannot raise, and info=True returns (y, info) with t, accepted_steps, rejected_steps, and a bool success.

qb.ode.odeint(f, y0, (t0, t1), method="rosenbrock23", rtol=1e-6, atol=1e-9, max_steps=512, info=False) is the adaptive, L-stable Rosenbrock 2(3) method of Shampine and Reichelt (MATLAB’s ode23s) for stiff problems: W = I - h d J with d = 1 / (2 + sqrt(2)), a second-order solution, and a third-order error estimate. Each step forms J = df/dy and df/dt at the current point in one batched forward-mode pass of y0.size + 1 tangents, so f needs no hand-written Jacobian, and solves three linear systems with W through solve (LU with partial pivoting). Step control, tolerances, max_steps, info, and the eager and jit behavior are those of dopri5, with the controller gains over the error order 3. Gradients differentiate through the Jacobian and the solves, so they are those of the discrete scheme. It runs on every backend. An exactly singular W raises like solve.

saveat=ts (any method) returns the states at the times ts stacked on axis zero instead of the final state. ts is a 1-D array ordered from t0 to t1 and inside the span (checked when it is concrete); under jit, a time outside the span or one the bounded loop did not reach is NaN. The states come from each method’s continuous extension, written with masked updates inside the loop, so adaptive steps are not shortened to land on the save times: Shampine’s fourth-order interpolant for dopri5 (Hairer’s CONTD5 form), the method’s own second-order interpolant for rosenbrock23, and cubic Hermite interpolation of each step’s end values and slopes for the fixed-step methods, which keeps their order. Saved states differentiate with respect to y0, args, the span, and the save times. saveat cannot be combined with save=True.

qb.distributed.value_and_grad(fun, devices=["cuda:0", "cuda:1"], shard_argnums=(1,), argnums=0, reduction="mean") is experimental single-node CUDA/NCCL execution. Arguments and replicated parameters may be pytrees. Full batches split equally on axis zero; sum sums shard losses/parameter gradients and mean averages them. Mapped-input gradients and nested execution inside another transform are rejected. Optimizer updates stay on the host. The cache retains at most eight signatures; changed static values, shapes or dtypes specialize it.

Explicit compilation for a device goes through the Compiler Facade: Compiler().trace(fn, specs).compile("cuda") or compile("mlx"), with Program.jvp(name) and Program.vjp(cotangent_name) for symbolic derivative programs. Compiler.capabilities() reports which targets the build contains; it is not a hardware probe. The PINN examples examples/pinn_poisson.py and examples/pinn_mlp.py take --device and train with Trainer on each device.

Compiler Facade

The rank-N compiler path has one explicit lifecycle. It is the advanced layer of the v0.2 design, for explicit compilation and inspection from name-keyed input specs on CPU, CUDA and MLX. Integrations that need a compiled program should use this facade. The Quickstart shows the lifecycle end to end.

Program.jvp(input_name) and Program.vjp(cotangent_name) produce new programs that can be compiled through the same interface. compile("cuda") and compile("mlx") use the existing verified backend lowerings; build availability is reported by compiler.capabilities(), while unsupported IR operations still fail explicitly during backend compilation or execution.

Layer Stable responsibility Public boundary
Frontend Trace a Python function with fixed input shapes Compiler.trace(...) -> Program
Transform Build symbolic first-order coordinate JVP or named VJP programs Program.jvp(...), Program.vjp(...)
Compilation Freeze reachability-pruned IR and select one target Program.compile(target, device_ordinal=0)
Runtime Bind eager Tensor inputs and execute the frozen plan Executable(inputs) / Executable.evaluate(inputs)
Inspection Report build-time target availability and frozen graph text Compiler.capabilities(), Program.lower_text(), Program.stablehlo_text()

Compiler.capabilities() answers only whether this extension was built with a target backend. It is not a hardware probe and it does not guarantee that a specific graph lowers: CUDA and MLX retain their operation-specific validation rules. CUDA structured Scan HVP is a restricted facade capability: the verified subset requires a fixed-bound, pure-elementwise Scan with same-shaped explicit captures and either equal carry/output shapes, an equal-count output reshape, or a per-step output that directly broadcasts a carry-shaped value. Its symbolic JVP paired carry uses a leading extent of two. In the direct broadcast case, ScanVjp and ScanVjpJvp aggregate output cotangents, and their tangents for HVP, back to each carry lane before reverse replay; broadcast capture gradients are reduced on the device in a fixed order. General unequal-count output graphs and indexed bodies have no fused ScanVjpJvp kernel; like their primal Scan and first-order ScanVjp, they run as host-driven region loops.

Notes:

The corresponding Rust lifecycle is QuablaCompiler -> QuablaProgram -> QuablaExecutable in quabla_core::compiler. The ownership boundaries are:

Python frontend / PyO3 -> TensorTraceGraph -> TensorIr / AD transforms
                       -> frozen TensorExecutionPlan -> CPU | CUDA | MLX

Tensor Reference

The eager Tensor and traced TraceTensor share one operation set and the dtype rules below.

Rust Core Crate

Known Limitations