Skip to content

Make the quadrants API type-checkable: annotation spellings, kernel decorator overloads, element-typed containers #831

Description

@duburcqa

Concisely describe the proposed feature

Quadrants cannot currently be type checked by a downstream project, for several independent reasons. This issue covers all of them; the annotation spellings below are the largest and the one that has to be fixed first, and a second set of blockers in kernel bodies, decorators and call sites is described further down.

Kernel parameter annotations that are built by calling a factory - qd.types.vector(3), qd.types.matrix(3, 3) - are not valid Python type expressions. A call is not part of the type-expression grammar, so it is rejected regardless of what the callee returns, and the rejection is not tool-specific: mypy 1.15 reports it as [valid-type], pyright 1.1.407 as reportInvalidTypeForm, ty 0.0.65 as invalid-type-form. Any downstream project that wants to run a type checker over code containing quadrants kernels therefore cannot, and this blocks Genesis-Embodied-AI/genesis-world#1271.

Quadrants already solves this for two of its three annotation families. Measuring on Genesis at 54b9ca2e5, ty check genesis reports 5376 diagnostics, of which 1716 are invalid-type-form across 54 files. Grouped by the exact annotation expression:

annotation form sites type-expression-valid spelling available today
qd.types.ndarray() 813 yes - qd.types.NDArray
qd.template() 588 yes - qd.Template
qd.types.ndarray(ndim=N) and other keyword variants 53 yes - qd.types.NDArray[None, N]
qd.types.vector(3), qd.types.vector(4) 257 no

So 1454 of the 1716 are already fixable downstream today, and the remaining 257 are blocked here.

The reason the first three work is that qd.types.ndarray and qd.template are aliases for classes (NdarrayType, Template), so the bare alias is a plain class reference - a valid type expression - and NdarrayType additionally defines __class_getitem__, so the subscripted form carries the parameters through. By contrast qd.types.vector and qd.types.matrix are factory functions returning VectorType / MatrixType instances; neither class is exported from quadrants.types nor subscriptable, so for those two there is no valid spelling at all.

Verified against quadrants 1.1.4 (commit 47e9dfb5) and ty 0.0.65 - the following file both runs correctly and reports All checks passed!:

import numpy as np
import quadrants as qd

qd.init(arch=qd.cpu)


@qd.kernel
def bare(x: qd.types.NDArray, s: qd.i32):
    x[0] = s


@qd.kernel
def subscripted(x: qd.types.NDArray[qd.i32, 1], s: qd.i32):
    x[0] = s


@qd.kernel
def templated(f: qd.Template, s: qd.i32):
    f[0] = s


arr = np.zeros(4, dtype=np.int32)
bare(arr, 7)
subscripted(arr, 8)
templated(qd.field(qd.i32, shape=4), 9)

Rewriting any of those annotations into its call form (qd.types.ndarray(), qd.types.ndarray(ndim=1), qd.template()) leaves the runtime behaviour identical and produces error[invalid-type-form] Function calls are not allowed in parameter annotations. There is no equivalent rewrite for qd.types.vector(3).

Describe the solution you'd like (if any)

Export Vector and Matrix from quadrants.types as class aliases carrying __class_getitem__, mirroring exactly what NdarrayType already does, so that the dimensions and dtype travel through a subscript instead of a call:

qd.types.Vector[3]               # equivalent to qd.types.vector(3)
qd.types.Vector[3, qd.f32]       # equivalent to qd.types.vector(3, qd.f32)
qd.types.Matrix[3, 3, qd.f32]    # equivalent to qd.types.matrix(3, 3, qd.f32)

Concretely: export the classes currently named VectorType and MatrixType under quadrants.types as Vector and Matrix, and give each a __class_getitem__ forwarding its arguments positionally to the constructor - the same few lines already present as NdarrayType.__class_getitem__ in python/quadrants/types/ndarray_type.py. Per the naming point below these should be renamed, not aliased, so the class name and the exported name coincide. The existing vector() and matrix() factories can keep working unchanged, which makes the subscript form purely additive on its own.

While settling on a spelling, the two families that already work are themselves inconsistent with each other, and that should be fixed rather than propagated to Vector / Matrix. Three separate things differ:

  • Export depth. Template is reachable at both qd.Template and qd.types.Template (they are the same object), whereas NDArray exists only under qd.types - qd.NDArray raises AttributeError. So the two type-expression-valid spellings a user has to write today are qd.Template and qd.types.NDArray, at different depths, for no reason a caller can infer. The top-level copies are also the minority case: vector, matrix, struct, NDArray and BufferViewType are already reachable only under qd.types, and the sole source of the leak is from quadrants.types.annotations import * at python/quadrants/__init__.py:52, whose __all__ is ["template", "sparse_matrix_builder", "Template"].
  • A name that means two different things at the two depths. qd.template is qd.types.template, but qd.ndarray is not qd.types.ndarray: at top level it is the allocation function (qd.ndarray(qd.i32, shape=4)), and only under qd.types is it the annotation class. Worse, that trap is invisible to the checker - reveal_type(qd.ndarray) is Unknown because the top-level re-export is not statically resolvable, so def k(x: qd.ndarray) passes ty check with All checks passed! and fails only at import time with QuadrantsSyntaxError: Invalid type annotation (argument 0) of Quadrants kernel: <function ndarray ...>. A user who has learned that qd.Template works at top level and reaches for qd.NDArray or qd.ndarray gets either an AttributeError or a spelling the type checker actively endorses and the runtime rejects.
  • Class naming. Four styles coexist. Template is plain CapWords. NdarrayType carries a Type suffix and a different casing of "ndarray" than the NDArray alias in front of it. BufferViewType is exported with its suffix intact, so the use site reads qd.types.BufferViewType. And sparse_matrix_builder is a lowercase class. The suffix is pure stutter under this namespace - qd.types.NdarrayType says "type" twice - so it should come off the classes themselves rather than being papered over by an alias, which also makes the class name and the exported name coincide. The same applies to the classes behind the request above (VectorType, MatrixType) and to StructType and CompoundType, so the new names should land suffix-free from the start rather than being renamed later.

The concrete request is therefore that qd.types become the single home for annotations, with exactly one statically-resolvable spelling per family and no Type suffix: qd.types.NDArray, qd.types.Vector, qd.types.Matrix, qd.types.Struct, qd.types.Template, qd.types.BufferView, qd.types.SparseMatrixBuilder. That means dropping the lowercase duplicates (qd.types.ndarray, qd.types.template) and deleting the from quadrants.types.annotations import * line so that nothing annotation-related remains at top level - which also dissolves the qd.ndarray collision structurally, since the top level would then hold only the allocation function and qd.types only the annotation. The from quadrants.types.primitive_types import * shortcut on the next line should stay as it is: qd.f32 and friends are dtypes rather than annotations, and are genuinely wanted at top level.

This is a breaking change and would need the MAJOR label, but the cost to downstream is close to zero if it ships alongside the Vector / Matrix addition, because every affected call site has to be rewritten anyway to become type-expression-valid - that is one migration instead of two. For the largest consumer it is free: Genesis reaches all of these exclusively through qd.types.* and qd.template(), and imports none of NdarrayType, VectorType, MatrixType, StructType, BufferViewType or CompoundType by name.

One dead end worth recording, so it is not mistaken for a solution: qd.math.vec3 looks like it already covers this case, and a checker does accept def k(v: qd.math.vec3) without complaint. That pass is vacuous. vec3 = vector(3, cfg().default_fp) is a module-level variable whose type a checker cannot infer (the cfg() chain is unresolvable), so it resolves to Unknown and the annotation is simply not checked rather than being valid - reveal_type(vec3) confirms Unknown. Pre-built instances hide the problem instead of fixing it; the spelling has to be a class reference or a subscript of one.

A second blocker: valid annotations are necessary but not sufficient

Fixing the annotations only clears a third of the picture, so it is worth scoping this issue as full typing compliance rather than just parameter declarations. Of the 5376 diagnostics on Genesis, 1716 are the annotations above and a further 1549 sit in the same 58 kernel-bearing files and come from kernel bodies, decorators and call sites. Not all of those are quadrants' responsibility - a large share is Genesis's own (164 from attributes initialised to None and dereferenced without narrowing, 251 from attributes never assigned in their class, and 88 from a Genesis-side name collision where genesis.utils.geom.qd_vec3(val) is star-imported into gs and then shadowed at gs.init() time by qd_vec3 = qd.types.vector(3, qd_float)). The following, though, are quadrants-side and account for 334 of them.

@qd.kernel(fastcache=False) matches no overload - 149 sites. The two @overload declarations for kernel in python/quadrants/lang/kernel_impl.py accept pure, graph and checkpoints, but the implementation below them also accepts fastcache, so the overloads have drifted from the implementation and every parameterised use of the decorator is an error.

qd.kernel erases the signature of the function it decorates, so no kernel call site is checked at all. Both overloads return Any / Callable[[Any], Any] rather than being generic in the decorated callable. qd.func already does this correctly one file over, with F = TypeVar("F", bound=Callable), which is the same kind of internal inconsistency as the naming one above - the fix for kernel is to mirror func.

qd.simt.block.SharedArray defines neither __getitem__ nor __setitem__ - 72 sites. Indexing a shared array (pivot_row[j]) or assigning through it is therefore an error wherever it is used.

There is no way to spell "field or tensor whose elements are T" - 113 sites. Genesis declares struct-element buffers (ContactFace, ContactNormal, MDVertex, EPAPolytopeFace, Witness, ContactHalfspace) and indexes them as gjk_state.contact_faces[i_b, i_n].normal1, but the only available annotation names the element type, which is not subscriptable. This is the same underlying gap as the vector/matrix request: the annotation describes the metadata object rather than the value the kernel body actually receives.

All three of the first points reproduce in this standalone file on quadrants 1.1.4 (commit 47e9dfb5) with ty 0.0.65:

from typing import reveal_type

import quadrants as qd

qd.init(arch=qd.cpu)


@qd.kernel
def plain(s: qd.i32) -> qd.i32:
    return s + 1


@qd.kernel(fastcache=False)
def with_fastcache(s: qd.i32) -> qd.i32:
    return s + 1


@qd.func
def helper(s: qd.i32) -> qd.i32:
    return s + 1


reveal_type(plain)   # Any            <- signature erased
reveal_type(helper)  # def helper(s: Unknown) -> Unknown
plain("this is not an i32", 1, 2, 3)  # no diagnostic; the runtime does reject it

ty check reports exactly one error on it - error[no-matching-overload] No overload of function 'kernel' matches arguments on the fastcache line - while with_fastcache(4) runs fine, confirming the overloads and not the implementation are wrong. Swapping fastcache=False for graph=False removes the error, isolating it to that one missing parameter. The last line shows the consequence of the erased signature: four arguments of the wrong types to a one-argument kernel pass the checker silently, and only the runtime catches it.

The remaining 712 diagnostics in those files I have not attributed yet, and I have not checked whether they collapse to a similarly small number of root causes the way the annotations did.

Additional comments

Separately, the distributed package carries no py.typed marker and no .pyi stubs, so under PEP 561 a conforming checker is not supposed to use its inline types for a normal (non-editable) install at all. That is a different question from this one - whether quadrants wants to advertise types downstream - so I have not folded it in here, but happy to open it as its own issue if that is useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions