jarnvid 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jarnvid-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noah Syrkis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jarnvid-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: jarnvid
3
+ Version: 0.2.1
4
+ Summary: Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch.
5
+ Keywords: jax,vmap,vectorize,jaxtyping,pytree,dataclass,batching
6
+ Author: Noah Syrkis
7
+ Author-email: Noah Syrkis <noah@syrkis.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: jax>=0.9.0
16
+ Requires-Dist: jaxtyping>=0.3.9
17
+ Requires-Python: >=3.13
18
+ Project-URL: Repository, https://github.com/syrkis/jarnvid
19
+ Project-URL: Decisions, https://github.com/syrkis/jarnvid/tree/main/docs/adr
20
+ Description-Content-Type: text/markdown
21
+
22
+ # jarnvid
23
+
24
+ Write a JAX function for one instance of a structure, and call it on any batch of them.
25
+ `vectorize` reads which axes belong to one instance from the jaxtyping annotations you
26
+ already write, and maps the function over the rest.
27
+
28
+ ```sh
29
+ pip install jarnvid
30
+ ```
31
+
32
+ Járnviðr is the iron wood of Norse myth. The structures it is for are complex things written
33
+ as data (a force on a map, a doctrine, a molecule): frozen dataclasses of JAX arrays, nested in
34
+ tuples and named sums.
35
+
36
+ ## Example
37
+
38
+ ```pycon
39
+ >>> from dataclasses import dataclass
40
+ >>> from typing import Annotated
41
+ >>> import jax.numpy as jnp
42
+ >>> from jax.tree_util import register_dataclass
43
+ >>> from jaxtyping import Array, Float
44
+ >>> from jarnvid import Extend, stack, take, vectorize
45
+ >>> @register_dataclass
46
+ ... @dataclass(frozen=True)
47
+ ... class Unit:
48
+ ... position: Float[Array, "*batch 2"]
49
+ ... health: Float[Array, "*batch"]
50
+ >>> type Force = Annotated[Unit, Extend("unit")] # a side: its unit axis stays inside each call
51
+ >>> @vectorize
52
+ ... def threat(force: Force, target: Float[Array, "*batch 2"]) -> Float[Array, "*batch"]:
53
+ ... distance = jnp.linalg.norm(force.position - target, axis=-1)
54
+ ... return (force.health / (1 + distance)).sum()
55
+ >>> forces = Unit(position=jnp.zeros((5, 3, 2)), health=jnp.ones((5, 3))) # 5 scenarios, 3 units each
56
+ >>> threat(forces, jnp.ones((5, 2))).shape # one target per scenario
57
+ (5,)
58
+ >>> threat(forces, jnp.ones(2)).shape # one target for all: it reaches every call as is
59
+ (5,)
60
+ >>> threat(stack([forces, forces]), jnp.ones((2, 5, 2))).shape # batch axes nest freely
61
+ (2, 5)
62
+ >>> take(forces, 0).position.shape # the first scenario's force
63
+ (3, 2)
64
+
65
+ ```
66
+
67
+ ## Why not `vmap`
68
+
69
+ `jax.vmap` maps one axis, chosen per argument with `in_axes`. Several batch axes mean nested
70
+ `vmap`s, and a dataclass whose fields carry different numbers of batch axes means spelling
71
+ out an `in_axes` tree that mirrors it. `equinox.filter_vmap` chooses what to map by type, not
72
+ by shape. jarnvid reads the shapes from the annotations instead:
73
+
74
+ - One function signature gives any number of batch axes, broadcast **from the left** across
75
+ all arguments (docs/adr/0001).
76
+ - A leaf with no batch axes (a map, a rule table) reaches every call once, never copied.
77
+ - `Extend("unit")` keeps a named axis inside each call, so a function can see a whole side.
78
+ - The same decorator maps in chunks (`Vectorizer(placement=Chunked(64))`, bounded memory) or
79
+ shards over a device mesh (`Vectorizer(placement=Sharded("data"))`, padding a batch that does
80
+ not divide evenly).
81
+
82
+ The price is annotation discipline: every mapped array is annotated, starting with `*batch`.
83
+
84
+ ## Rules
85
+
86
+ - Arguments are PyTrees: tuples, dicts (`dict[str, T]`, `Mapping[str, T]`), registered
87
+ dataclasses (fields marked `static` are left alone), named sums (the arm matching the value)
88
+ and type aliases. Arrays in any other container are an error.
89
+ - What is mapped is read from the annotations alone. Plain Python values and unregistered
90
+ dataclasses (configs) reach every call as they are.
91
+ - A leaf's batch axes beyond the ones shared by all arguments stay inside each call.
92
+
93
+ Between calls, structures are reshaped by their batch axes alone (docs/adr/0002): `take`,
94
+ `stack`, `concat`, `expand` (singleton axes, for outer products) and `batch_shape`. Leaves
95
+ without batch axes stay once.
96
+
97
+ | File | Concept |
98
+ |---|---|
99
+ | `annotation.py` | Reading annotations: which trailing axes of each leaf one call sees |
100
+ | `vectorizer.py` | `vectorize` and `Vectorizer`: broadcast, flatten, map, reshape |
101
+ | `placement.py` | Where the batch runs: `Local`, `Chunked`, or `Sharded` over a mesh axis |
102
+ | `structure.py` | `take`, `stack`, `concat`, `expand`, `batch_shape` on batched structures |
103
+
104
+ ## Develop
105
+
106
+ `just test` (the tests and this README's examples), `just sharded` (four forced CPU devices),
107
+ `just check` (ruff and vulture), `just build` (the wheel and sdist). The scope is fixed: one
108
+ decorator and the structure operations around it.
109
+
110
+ ## Cite
111
+
112
+ See `CITATION.cff`. MIT licence.
113
+
114
+ ## Open
115
+
116
+ - JAX 0.12: jarnvid passes unchanged. With `jax>=0.12`, add a law that `Sharded` adds no
117
+ collectives (`jax.sharding.no_collectives`), except for the result gather after padding.
@@ -0,0 +1,96 @@
1
+ # jarnvid
2
+
3
+ Write a JAX function for one instance of a structure, and call it on any batch of them.
4
+ `vectorize` reads which axes belong to one instance from the jaxtyping annotations you
5
+ already write, and maps the function over the rest.
6
+
7
+ ```sh
8
+ pip install jarnvid
9
+ ```
10
+
11
+ Járnviðr is the iron wood of Norse myth. The structures it is for are complex things written
12
+ as data (a force on a map, a doctrine, a molecule): frozen dataclasses of JAX arrays, nested in
13
+ tuples and named sums.
14
+
15
+ ## Example
16
+
17
+ ```pycon
18
+ >>> from dataclasses import dataclass
19
+ >>> from typing import Annotated
20
+ >>> import jax.numpy as jnp
21
+ >>> from jax.tree_util import register_dataclass
22
+ >>> from jaxtyping import Array, Float
23
+ >>> from jarnvid import Extend, stack, take, vectorize
24
+ >>> @register_dataclass
25
+ ... @dataclass(frozen=True)
26
+ ... class Unit:
27
+ ... position: Float[Array, "*batch 2"]
28
+ ... health: Float[Array, "*batch"]
29
+ >>> type Force = Annotated[Unit, Extend("unit")] # a side: its unit axis stays inside each call
30
+ >>> @vectorize
31
+ ... def threat(force: Force, target: Float[Array, "*batch 2"]) -> Float[Array, "*batch"]:
32
+ ... distance = jnp.linalg.norm(force.position - target, axis=-1)
33
+ ... return (force.health / (1 + distance)).sum()
34
+ >>> forces = Unit(position=jnp.zeros((5, 3, 2)), health=jnp.ones((5, 3))) # 5 scenarios, 3 units each
35
+ >>> threat(forces, jnp.ones((5, 2))).shape # one target per scenario
36
+ (5,)
37
+ >>> threat(forces, jnp.ones(2)).shape # one target for all: it reaches every call as is
38
+ (5,)
39
+ >>> threat(stack([forces, forces]), jnp.ones((2, 5, 2))).shape # batch axes nest freely
40
+ (2, 5)
41
+ >>> take(forces, 0).position.shape # the first scenario's force
42
+ (3, 2)
43
+
44
+ ```
45
+
46
+ ## Why not `vmap`
47
+
48
+ `jax.vmap` maps one axis, chosen per argument with `in_axes`. Several batch axes mean nested
49
+ `vmap`s, and a dataclass whose fields carry different numbers of batch axes means spelling
50
+ out an `in_axes` tree that mirrors it. `equinox.filter_vmap` chooses what to map by type, not
51
+ by shape. jarnvid reads the shapes from the annotations instead:
52
+
53
+ - One function signature gives any number of batch axes, broadcast **from the left** across
54
+ all arguments (docs/adr/0001).
55
+ - A leaf with no batch axes (a map, a rule table) reaches every call once, never copied.
56
+ - `Extend("unit")` keeps a named axis inside each call, so a function can see a whole side.
57
+ - The same decorator maps in chunks (`Vectorizer(placement=Chunked(64))`, bounded memory) or
58
+ shards over a device mesh (`Vectorizer(placement=Sharded("data"))`, padding a batch that does
59
+ not divide evenly).
60
+
61
+ The price is annotation discipline: every mapped array is annotated, starting with `*batch`.
62
+
63
+ ## Rules
64
+
65
+ - Arguments are PyTrees: tuples, dicts (`dict[str, T]`, `Mapping[str, T]`), registered
66
+ dataclasses (fields marked `static` are left alone), named sums (the arm matching the value)
67
+ and type aliases. Arrays in any other container are an error.
68
+ - What is mapped is read from the annotations alone. Plain Python values and unregistered
69
+ dataclasses (configs) reach every call as they are.
70
+ - A leaf's batch axes beyond the ones shared by all arguments stay inside each call.
71
+
72
+ Between calls, structures are reshaped by their batch axes alone (docs/adr/0002): `take`,
73
+ `stack`, `concat`, `expand` (singleton axes, for outer products) and `batch_shape`. Leaves
74
+ without batch axes stay once.
75
+
76
+ | File | Concept |
77
+ |---|---|
78
+ | `annotation.py` | Reading annotations: which trailing axes of each leaf one call sees |
79
+ | `vectorizer.py` | `vectorize` and `Vectorizer`: broadcast, flatten, map, reshape |
80
+ | `placement.py` | Where the batch runs: `Local`, `Chunked`, or `Sharded` over a mesh axis |
81
+ | `structure.py` | `take`, `stack`, `concat`, `expand`, `batch_shape` on batched structures |
82
+
83
+ ## Develop
84
+
85
+ `just test` (the tests and this README's examples), `just sharded` (four forced CPU devices),
86
+ `just check` (ruff and vulture), `just build` (the wheel and sdist). The scope is fixed: one
87
+ decorator and the structure operations around it.
88
+
89
+ ## Cite
90
+
91
+ See `CITATION.cff`. MIT licence.
92
+
93
+ ## Open
94
+
95
+ - JAX 0.12: jarnvid passes unchanged. With `jax>=0.12`, add a law that `Sharded` adds no
96
+ collectives (`jax.sharding.no_collectives`), except for the result gather after padding.
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "jarnvid"
3
+ version = "0.2.1"
4
+ description = "Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.13"
9
+ keywords = [
10
+ "jax",
11
+ "vmap",
12
+ "vectorize",
13
+ "jaxtyping",
14
+ "pytree",
15
+ "dataclass",
16
+ "batching",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Science/Research",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "jax>=0.9.0",
27
+ "jaxtyping>=0.3.9",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Noah Syrkis"
32
+ email = "noah@syrkis.com"
33
+
34
+ [project.urls]
35
+ Repository = "https://github.com/syrkis/jarnvid"
36
+ Decisions = "https://github.com/syrkis/jarnvid/tree/main/docs/adr"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.1.1",
41
+ "pytest-xdist",
42
+ "hypothesis",
43
+ "ruff>=0.17.4",
44
+ "vulture>=2.16",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.13,<0.14"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.ruff]
52
+ line-length = 128
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = [
56
+ "tests",
57
+ "README.md",
58
+ ]
59
+ addopts = "-n auto --doctest-glob=README.md"
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "jarnvid"
3
+ version = "0.2.1"
4
+ description = "Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Noah Syrkis", email = "noah@syrkis.com" }]
9
+ requires-python = ">=3.13"
10
+ keywords = ["jax", "vmap", "vectorize", "jaxtyping", "pytree", "dataclass", "batching"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Science/Research",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Topic :: Scientific/Engineering",
16
+ "Typing :: Typed",
17
+ ]
18
+ dependencies = [
19
+ "jax>=0.9.0",
20
+ "jaxtyping>=0.3.9",
21
+ ]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/syrkis/jarnvid"
25
+ Decisions = "https://github.com/syrkis/jarnvid/tree/main/docs/adr"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=9.1.1",
30
+ "pytest-xdist",
31
+ "hypothesis",
32
+ "ruff>=0.17.4",
33
+ "vulture>=2.16",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.13,<0.14"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.ruff]
41
+ line-length = 128
42
+
43
+ [tool.pytest.ini_options]
44
+ # The README's examples run as doctests, so it cannot fall behind the code.
45
+ testpaths = ["tests", "README.md"]
46
+ addopts = "-n auto --doctest-glob=README.md"
@@ -0,0 +1,15 @@
1
+ """Járnviðr, the iron wood: complex structures as data, algebraic in JAX.
2
+
3
+ Doctrine, forces, molecules: frozen dataclasses of arrays. `vectorize` reads a function's
4
+ jaxtyping annotations (`*batch` first), broadcasts the leading batch axes of its PyTree
5
+ arguments, and maps the function over them; `Extend` keeps named semantic axes inside each call;
6
+ `Vectorizer` maps the batch in chunks, or shards it over a mesh axis. `take`, `stack`, `concat` and `expand` reshape structures
7
+ along their batch axes only.
8
+ """
9
+
10
+ from .annotation import Extend
11
+ from .placement import Chunked, Local, Sharded
12
+ from .structure import batch_shape, concat, expand, stack, take
13
+ from .vectorizer import Vectorizer, vectorize
14
+
15
+ __all__ = ["Chunked", "Extend", "Local", "Sharded", "Vectorizer", "batch_shape", "concat", "expand", "stack", "take", "vectorize"]
@@ -0,0 +1,174 @@
1
+ """Reading annotations: for each leaf of an argument, how many trailing axes belong to one call.
2
+
3
+ An argument's annotation is walked together with its value, once: through type aliases,
4
+ `Annotated[..., Extend(...)]`, the one arm of a union that admits the value, tuples, dicts and
5
+ dataclasses, down to jaxtyping arrays annotated `*batch ...`. The result is a PyTree of the
6
+ value's structure whose leaves are `Core` (the array's last `ndim` axes belong to each call; the
7
+ rest, at least one, are batch axes) or `Fixed` (passed to every call unchanged).
8
+ """
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, fields, is_dataclass, replace
12
+ from types import UnionType
13
+ from typing import Annotated, TypeAliasType, TypeIs, Union, get_args, get_origin, get_type_hints
14
+
15
+ from jax import tree
16
+ from jaxtyping import AbstractArray, PyTree
17
+
18
+ # Any runtime annotation (a class, alias, union or Annotated form); PEP 747's TypeForm, which
19
+ # Python 3.13 lacks, is the precise type.
20
+ type TypeForm = object
21
+
22
+
23
+ @dataclass(frozen=True, init=False)
24
+ class Extend:
25
+ """Insert semantic axes immediately after every nested ``*batch`` axis."""
26
+
27
+ axes: tuple[str, ...]
28
+
29
+ def __init__(self, *axes: str) -> None:
30
+ if not axes or any(not axis for axis in axes):
31
+ raise ValueError("Extend requires one or more nonempty axis names")
32
+ object.__setattr__(self, "axes", axes)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Core:
37
+ """An array leaf whose last `ndim` axes belong to each call; the rest are batch axes."""
38
+
39
+ ndim: int
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Fixed:
44
+ """A leaf passed to every call unchanged."""
45
+
46
+
47
+ type Rank = Core | Fixed
48
+
49
+
50
+ def ranks(value: PyTree, annotation: TypeForm, path: str, extended: tuple[str, ...] = ()) -> PyTree:
51
+ """`value`'s structure with a `Rank` at every leaf, checking each array against its annotation."""
52
+ annotation, axes = resolve(value, annotation, path)
53
+ extended += axes
54
+ if value is None:
55
+ return None
56
+ if is_array(annotation):
57
+ return array_rank(value, annotation, path, extended)
58
+ if isinstance(value, tuple):
59
+ items = zip(value, tuple_annotations(annotation, len(value), path), strict=True)
60
+ return tuple(ranks(item, inner, f"{path}[{index}]", extended) for index, (item, inner) in enumerate(items))
61
+ if isinstance(value, dict):
62
+ inner = mapping_annotation(annotation, path)
63
+ return {key: ranks(item, inner, f"{path}[{key!r}]", extended) for key, item in value.items()}
64
+ if is_dataclass(value):
65
+ return dataclass_ranks(value, annotation, path, extended)
66
+ if hasattr(value, "shape"):
67
+ raise TypeError(f"{path} must use a jaxtyping annotation beginning with '*batch'")
68
+ if any(hasattr(leaf, "shape") for leaf in tree.leaves(value)):
69
+ raise TypeError(f"{path} holds arrays in a {type(value).__name__}, which is not traversed: use a tuple, dict or dataclass")
70
+ return tree.map(lambda _: Fixed(), value)
71
+
72
+
73
+ def array_rank(value: PyTree, annotation: type[AbstractArray], path: str, extended: tuple[str, ...]) -> Rank:
74
+ """An array's `Rank`: `Core` if it has batch axes, else `Fixed`."""
75
+ if not hasattr(value, "shape"):
76
+ raise TypeError(f"{path} is annotated as an array but received {type(value).__name__}")
77
+ core = core_ndim(annotation, path) + len(extended)
78
+ if value.ndim < core:
79
+ raise ValueError(f"{path} has shape {value.shape}, which is smaller than {extended_dims(annotation, extended)!r}")
80
+ # A leaf without batch axes reaches every call as is, never copied across the batch.
81
+ return Core(core) if value.ndim > core else Fixed()
82
+
83
+
84
+ def dataclass_ranks(value: PyTree, annotation: TypeForm, path: str, extended: tuple[str, ...]) -> PyTree:
85
+ """A registered dataclass with the `Rank`s of its non-static fields; an unregistered one (a
86
+ config) is `Fixed`, unless it holds arrays."""
87
+ if any(leaf is value for leaf in tree.leaves(value)):
88
+ if any(hasattr(leaf, "shape") for leaf in tree.leaves([getattr(value, field.name) for field in fields(value)])):
89
+ raise TypeError(f"{path}: register {type(value).__name__} as a PyTree (jax.tree_util.register_dataclass)")
90
+ return Fixed()
91
+ if not isinstance(annotation, type) or not is_dataclass(annotation):
92
+ raise TypeError(f"{path} must have an annotated dataclass type")
93
+ hints = get_type_hints(annotation, include_extras=True)
94
+ mapped = (field.name for field in fields(value) if not field.metadata.get("static", False))
95
+ return replace(value, **{name: ranks(getattr(value, name), hints.get(name), f"{path}.{name}", extended) for name in mapped})
96
+
97
+
98
+ def mapping_annotation(annotation: TypeForm, path: str) -> TypeForm:
99
+ """The value type of a ``dict[K, V]`` or ``Mapping[K, V]`` annotation."""
100
+ if get_origin(annotation) not in (dict, Mapping) or len(get_args(annotation)) != 2:
101
+ raise TypeError(f"{path} must have an annotated dict[K, V] or Mapping[K, V] type")
102
+ return get_args(annotation)[1]
103
+
104
+
105
+ def resolve(value: PyTree, annotation: TypeForm, path: str) -> tuple[TypeForm, tuple[str, ...]]:
106
+ """Unwrap aliases and ``Extend`` metadata, taking a union's one arm that admits ``value``."""
107
+ annotation, axes = extension(annotation)
108
+ while get_origin(annotation) in (Union, UnionType):
109
+ arms = tuple(arm for arm in get_args(annotation) if admits(extension(arm)[0], value))
110
+ if len(arms) != 1:
111
+ raise TypeError(f"{path} ({type(value).__name__}) matches {len(arms)} arms of {annotation}, not one")
112
+ annotation, arm_axes = extension(arms[0])
113
+ axes += arm_axes
114
+ return annotation, axes
115
+
116
+
117
+ def extension(annotation: TypeForm) -> tuple[TypeForm, tuple[str, ...]]:
118
+ """Separate a runtime annotation from its (possibly nested) semantic-axis metadata."""
119
+ axes: tuple[str, ...] = ()
120
+ seen: set[int] = set()
121
+ while True:
122
+ if isinstance(annotation, TypeAliasType):
123
+ if id(annotation) in seen:
124
+ raise TypeError(f"Cyclic type alias: {annotation.__name__}")
125
+ seen.add(id(annotation))
126
+ if annotation.__type_params__:
127
+ raise TypeError(f"Generic type aliases are not supported: {annotation.__name__}")
128
+ annotation = annotation.__value__
129
+ elif isinstance(get_origin(annotation), TypeAliasType):
130
+ raise TypeError(f"Generic type aliases are not supported: {annotation}")
131
+ elif get_origin(annotation) is Annotated:
132
+ annotation, *metadata = get_args(annotation)
133
+ axes += tuple(axis for item in metadata if isinstance(item, Extend) for axis in item.axes)
134
+ else:
135
+ return annotation, axes
136
+
137
+
138
+ def admits(annotation: TypeForm, value: PyTree) -> bool:
139
+ """Whether one union arm describes a runtime value: arrays by shape, others by class."""
140
+ if is_array(annotation):
141
+ return hasattr(value, "shape")
142
+ origin = get_origin(annotation) or annotation
143
+ return isinstance(origin, type) and isinstance(value, origin)
144
+
145
+
146
+ def tuple_annotations(annotation: TypeForm, length: int, path: str) -> tuple[TypeForm, ...]:
147
+ """Expand a fixed or variadic tuple annotation over its runtime values."""
148
+ if get_origin(annotation) is not tuple:
149
+ raise TypeError(f"{path} must have an annotated tuple type")
150
+ annotations = get_args(annotation)
151
+ if len(annotations) == 2 and annotations[1] is Ellipsis:
152
+ return (annotations[0],) * length
153
+ if len(annotations) != length:
154
+ raise TypeError(f"{path} has {length} values but {len(annotations)} annotated tuple fields")
155
+ return annotations
156
+
157
+
158
+ def is_array(annotation: TypeForm) -> TypeIs[type[AbstractArray]]:
159
+ """Whether an annotation is a concrete jaxtyping array type."""
160
+ return isinstance(annotation, type) and issubclass(annotation, AbstractArray)
161
+
162
+
163
+ def core_ndim(annotation: type[AbstractArray], path: str) -> int:
164
+ """The axes to the right of the required leading ``*batch``."""
165
+ dimensions = annotation.dim_str.split()
166
+ if annotation.index_variadic != 0 or not dimensions or dimensions[0] != "*batch":
167
+ raise TypeError(f"{path} must use a jaxtyping annotation beginning with '*batch'")
168
+ return len(annotation.dims) - 1
169
+
170
+
171
+ def extended_dims(annotation: type[AbstractArray], extended: tuple[str, ...]) -> str:
172
+ """The effective annotation, semantic axes included, for error messages."""
173
+ batch, *core = annotation.dim_str.split()
174
+ return " ".join((batch, *extended, *core))
@@ -0,0 +1,93 @@
1
+ """Where the flattened batch runs: on the local devices, or split over one mesh axis."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol, assert_never
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from jax import tree
9
+ from jax.sharding import NamedSharding, PartitionSpec
10
+ from jaxtyping import PyTree
11
+
12
+ from .annotation import Core
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Local:
17
+ """Map the flattened batch on the current devices."""
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Chunked:
22
+ """Map the flattened batch `batch` instances at a time (``lax.map``): the same result as
23
+ ``Local`` in bounded memory."""
24
+
25
+ batch: int
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Sharded:
30
+ """Split the one flattened batch axis over the active mesh's ``axis``."""
31
+
32
+ axis: str
33
+
34
+
35
+ type Placement = Local | Chunked | Sharded
36
+
37
+
38
+ class Apply[R](Protocol):
39
+ """One call of the wrapped function, on the mapped arguments by name."""
40
+
41
+ def __call__(self, values: dict[str, PyTree], /) -> R: ...
42
+
43
+
44
+ def run[R](placement: Placement, apply: Apply[R], values: dict[str, PyTree], ranks: dict[str, PyTree], size: int) -> R:
45
+ """`apply` mapped over the leading axis (of length `size`) of every `Core` leaf of `values`."""
46
+ axes = tree.map(lambda rank: 0 if isinstance(rank, Core) else None, ranks)
47
+ mapped = jax.vmap(apply, in_axes=(axes,))
48
+ if size == 0:
49
+ return mapped(values)
50
+ match placement:
51
+ case Local():
52
+ return mapped(values)
53
+ case Chunked(batch=batch):
54
+
55
+ def at(index: jax.Array) -> R:
56
+ return apply(tree.map(lambda leaf, rank: leaf[index] if isinstance(rank, Core) else leaf, values, ranks))
57
+
58
+ return jax.lax.map(at, jnp.arange(size), batch_size=batch)
59
+ case Sharded(axis=axis):
60
+ return sharded(axis, mapped, values, ranks, size)
61
+ case _:
62
+ assert_never(placement)
63
+
64
+
65
+ def sharded[R](axis: str, mapped: Apply[R], values: dict[str, PyTree], ranks: dict[str, PyTree], size: int) -> R:
66
+ """`mapped` over the batch split across the active mesh's `axis`.
67
+
68
+ The batch is padded to a multiple of the axis with copies of its last instance, and the
69
+ padding cut from a replicated result (a sharded axis cannot be cut unevenly). Unbatched
70
+ arrays are replicated: shard_map cannot close over arrays sharded on Explicit axes.
71
+ """
72
+ mesh = jax.sharding.get_abstract_mesh()
73
+ if axis not in mesh.shape:
74
+ raise ValueError(f"vectorize over Sharded({axis!r}) requires an active mesh with that axis")
75
+ padding = -size % mesh.shape[axis]
76
+
77
+ def spec(leaf: PyTree, rank: PyTree) -> PartitionSpec | None:
78
+ if not hasattr(leaf, "shape"):
79
+ return None
80
+ return PartitionSpec(axis) if isinstance(rank, Core) else PartitionSpec()
81
+
82
+ def place(leaf: PyTree, rank: PyTree) -> PyTree:
83
+ if not hasattr(leaf, "shape"):
84
+ return leaf
85
+ if isinstance(rank, Core):
86
+ leaf = jnp.pad(leaf, [(0, padding)] + [(0, 0)] * (leaf.ndim - 1), mode="edge")
87
+ return jax.sharding.reshard(leaf, NamedSharding(mesh, spec(leaf, rank)))
88
+
89
+ local = jax.shard_map(mapped, mesh=mesh, in_specs=(tree.map(spec, values, ranks),), out_specs=PartitionSpec(axis), check_vma=False)
90
+ result = local(tree.map(place, values, ranks))
91
+ if not padding:
92
+ return result
93
+ return tree.map(lambda leaf: jax.sharding.reshard(leaf, PartitionSpec())[:size], result)
File without changes
@@ -0,0 +1,95 @@
1
+ """Batch-aware operations on structures: `take`, `stack`, `concat`, `expand`, `batch_shape`.
2
+
3
+ A structure is a registered dataclass whose annotations say which axes of each leaf are batch
4
+ axes, as `vectorize` reads them. Only leaves with batch axes move: a leaf without them (a terrain
5
+ kept once) stays once, and plain values (Python scalars) must agree.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Protocol
10
+
11
+ import jax.numpy as jnp
12
+ from jax import Array, tree
13
+ from jax.typing import ArrayLike
14
+ from jaxtyping import PyTree
15
+
16
+ from .annotation import Core, Fixed, Rank, ranks
17
+ from .vectorizer import batch_shapes, shared
18
+
19
+
20
+ def layout(value: PyTree) -> PyTree:
21
+ """`value`'s `Rank`s, read from its own class's annotations."""
22
+ return ranks(value, type(value), type(value).__name__)
23
+
24
+
25
+ def batch_shape(value: PyTree) -> tuple[int, ...]:
26
+ """The batch `vectorize` maps `value` over."""
27
+ return shared(batch_shapes(type(value).__name__, value, layout(value)))
28
+
29
+
30
+ def take[T](value: T, index: ArrayLike) -> T:
31
+ """The instance (or instances) at `index` along the leading batch axis."""
32
+ size = leading(value)
33
+ return tree.map(lambda leaf, rank: lead(leaf, rank, size)[index] if isinstance(rank, Core) else leaf, value, layout(value))
34
+
35
+
36
+ def stack[T](values: Sequence[T]) -> T:
37
+ """`values` along a new leading batch axis."""
38
+ return merge(values, lambda leaves, _: jnp.stack(jnp.broadcast_arrays(*leaves)))
39
+
40
+
41
+ def concat[T](values: Sequence[T]) -> T:
42
+ """`values` joined along their leading batch axis."""
43
+ sizes = tuple(map(leading, values))
44
+ return merge(values, lambda leaves, ranks: jnp.concatenate([lead(*parts) for parts in zip(leaves, ranks, sizes, strict=True)]))
45
+
46
+
47
+ def expand[T](value: T, before: int = 0, after: int = 0) -> T:
48
+ """`value` with singleton batch axes before and after its batch axes; an outer product is
49
+ `f(expand(xs, after=1), expand(ys, before=1))`."""
50
+ rank = len(batch_shape(value))
51
+
52
+ def insert(leaf: PyTree, r: PyTree) -> PyTree:
53
+ if not isinstance(r, Core):
54
+ return leaf
55
+ return leaf.reshape((1,) * before + leaf.shape[:rank] + (1,) * after + leaf.shape[rank:])
56
+
57
+ return tree.map(insert, value, layout(value))
58
+
59
+
60
+ class Join(Protocol):
61
+ """Combine one leaf from each structure into one leaf."""
62
+
63
+ def __call__(self, leaves: tuple[Array, ...], ranks: tuple[Rank, ...], /) -> Array: ...
64
+
65
+
66
+ def merge[T](values: Sequence[T], join: Join) -> T:
67
+ """Leaf by leaf: agreeing plain values, and one leaf without batch axes that every structure
68
+ shares, stay once; the rest are `join`ed."""
69
+ count = len(values)
70
+
71
+ def leaf(*items: PyTree) -> PyTree:
72
+ leaves, rank = items[:count], items[count:]
73
+ if not all(hasattr(item, "shape") for item in leaves):
74
+ if any(item != leaves[0] for item in leaves):
75
+ raise ValueError(f"Plain values differ across the structures: {leaves}")
76
+ return leaves[0]
77
+ if all(isinstance(r, Fixed) and item is leaves[0] for item, r in zip(leaves, rank, strict=True)):
78
+ return leaves[0]
79
+ return join(leaves, rank)
80
+
81
+ return tree.map(leaf, *values, *map(layout, values))
82
+
83
+
84
+ def leading(value: PyTree) -> int:
85
+ """The size of `value`'s leading batch axis."""
86
+ shape = batch_shape(value)
87
+ if not shape:
88
+ raise ValueError(f"{type(value).__name__} has no batch axes")
89
+ return shape[0]
90
+
91
+
92
+ def lead(leaf: Array, rank: Rank, size: int) -> Array:
93
+ """`leaf` with a leading batch axis of `size`, broadcast if it has one, added if not."""
94
+ shape = leaf.shape[1:] if isinstance(rank, Core) else leaf.shape
95
+ return jnp.broadcast_to(leaf, (size, *shape))
@@ -0,0 +1,96 @@
1
+ """`vectorize`: write a function for one instance, call it on any batch.
2
+
3
+ Each argument's annotation gives, for every array leaf, the trailing axes one call sees; the
4
+ leading axes left over are batch axes. Batch axes broadcast across leaves **aligned from the
5
+ left**: the shared batch is the broadcast of every leaf's first `r` batch axes, `r` the fewest
6
+ any leaf has, and a leaf's further batch axes stay inside each call (docs/adr/0001). The batch
7
+ is flattened to one axis, mapped (`placement`), and reshaped back.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from functools import wraps
13
+ from inspect import signature
14
+ from math import prod
15
+ from typing import get_type_hints
16
+
17
+ import jax.numpy as jnp
18
+ from jax import Array, tree
19
+ from jax.tree_util import keystr
20
+ from jaxtyping import PyTree
21
+
22
+ from .annotation import Core, ranks
23
+ from .placement import Local, Placement, run
24
+
25
+ # A function whose signature vectorize preserves.
26
+ type Function[**P, R] = Callable[P, R]
27
+
28
+
29
+ def vectorize[**P, R](fn: Function[P, R]) -> Function[P, R]:
30
+ """Vectorize a PyTree function from its jaxtyping annotations."""
31
+ return Vectorizer()(fn)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Vectorizer:
36
+ """A configured ``vectorize``: where the batch runs. What is mapped is read from the
37
+ annotations alone; `Extend` keeps named axes inside each call."""
38
+
39
+ placement: Placement = field(default_factory=Local)
40
+
41
+ def __call__[**P, R](self, fn: Function[P, R]) -> Function[P, R]:
42
+ call = signature(fn)
43
+ hints = get_type_hints(fn, include_extras=True)
44
+
45
+ @wraps(fn)
46
+ def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
47
+ bound = call.bind(*args, **kwargs)
48
+ bound.apply_defaults()
49
+ arguments = bound.arguments
50
+ layout = {name: ranks(arguments[name], hints.get(name), name) for name in arguments}
51
+ batches = {name: batch_shapes(name, arguments[name], rank) for name, rank in layout.items()}
52
+ batch = shared({path: shape for shapes in batches.values() for path, shape in shapes.items()})
53
+ if not batch:
54
+ return fn(*args, **kwargs)
55
+ values = {name: tree.map(lambda leaf, r: flatten(leaf, r, batch), arguments[name], rank) for name, rank in layout.items()}
56
+
57
+ def apply(inner: dict[str, PyTree]) -> R:
58
+ one = call.bind_partial()
59
+ one.arguments.update(arguments | inner)
60
+ return fn(*one.args, **one.kwargs)
61
+
62
+ result = run(self.placement, apply, values, layout, prod(batch))
63
+ return tree.map(lambda leaf: leaf.reshape(batch + leaf.shape[1:]), result)
64
+
65
+ return wrapped
66
+
67
+
68
+ def batch_shapes(name: str, value: PyTree, rank: PyTree) -> dict[str, tuple[int, ...]]:
69
+ """The batch shape (axes before the core) of every batched array leaf, by path."""
70
+ pairs = zip(tree.leaves_with_path(value), tree.leaves(rank), strict=True)
71
+ return {name + keystr(path): leaf.shape[: leaf.ndim - r.ndim] for (path, leaf), r in pairs if isinstance(r, Core)}
72
+
73
+
74
+ def shared(shapes: dict[str, tuple[int, ...]]) -> tuple[int, ...]:
75
+ """The broadcast of every batched leaf's first `r` batch axes, `r` the fewest any has."""
76
+ if not shapes:
77
+ return ()
78
+ rank = min(map(len, shapes.values()))
79
+ try:
80
+ return jnp.broadcast_shapes(*(shape[:rank] for shape in shapes.values()))
81
+ except ValueError:
82
+ leaves = ", ".join(f"{path} {shape}" for path, shape in shapes.items())
83
+ raise ValueError(
84
+ f"Batch axes do not broadcast in their first {rank}, aligned from the left: {leaves}. "
85
+ "An axis meant to stay inside each call belongs in the annotation, or in Extend."
86
+ ) from None
87
+
88
+
89
+ def flatten(leaf: PyTree, rank: PyTree, batch: tuple[int, ...]) -> PyTree:
90
+ """A `Core` leaf broadcast to the shared batch, which is flattened to one leading axis; a
91
+ leaf's batch axes beyond the shared ones stay inside the call."""
92
+ if not isinstance(rank, Core):
93
+ return jnp.asarray(leaf) if hasattr(leaf, "shape") else leaf
94
+ array: Array = leaf
95
+ rest = array.shape[len(batch) : array.ndim - rank.ndim] + array.shape[array.ndim - rank.ndim :]
96
+ return jnp.broadcast_to(array, batch + rest).reshape((prod(batch), *rest))