stringjax-tools 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,581 @@
1
+ # Copyright 2022-2026 Andreas Schachner
2
+ #
3
+ # This file is part of StringJAX Tools.
4
+ #
5
+ # StringJAX Tools is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # StringJAX Tools is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with StringJAX Tools. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Rank-checked automatic vectorisation helpers.
19
+
20
+ The central helper is :func:`auto_vmap`, a conservative decorator for functions
21
+ whose single-sample input ranks are known. At call time, selected arguments are
22
+ checked before JAX tracing. Rank ``r`` is treated as one sample, rank ``r + 1``
23
+ as a paired leading-axis batch, and checked batched arguments must share the
24
+ same leading size.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import inspect
30
+ from collections import OrderedDict
31
+ from collections.abc import Iterator
32
+ from contextlib import contextmanager
33
+ from dataclasses import dataclass
34
+ from functools import wraps
35
+ from numbers import Integral
36
+ from typing import Any, Callable, Mapping, Sequence, TypeAlias
37
+
38
+ import jax
39
+ import jax.numpy as jnp
40
+
41
+ from ._utils import cache_key_value, lru_cache_get, lru_cache_set, snapshot_static_value
42
+
43
+ Shape = tuple[int, ...]
44
+ ShapeLike = int | Sequence[int] | None
45
+ ShapeSpec = Any
46
+
47
+ __all__ = [
48
+ "ArgSpec",
49
+ "auto_vmap",
50
+ "auto_vmap_default_shapes",
51
+ "auto_vmap_defaults",
52
+ "clear_auto_vmap_caches",
53
+ "get_auto_vmap_default_shapes",
54
+ "get_auto_vmap_defaults",
55
+ "reset_auto_vmap_default_shapes",
56
+ "reset_auto_vmap_defaults",
57
+ "set_auto_vmap_default_shapes",
58
+ "set_auto_vmap_defaults",
59
+ ]
60
+
61
+ _AUTO_VMAP_CACHE_MAXSIZE = 256
62
+ _AUTO_VMAP_CACHE: OrderedDict[tuple[Any, ...], Callable[..., Any]] = OrderedDict()
63
+ _AUTO_VMAP_DEFAULT_RANKS: dict[str, int] = {}
64
+ _AUTO_VMAP_DEFAULT_SHAPES: dict[str, ShapeSpec] = {}
65
+
66
+
67
+ def _is_nonnegative_int(value: Any) -> bool:
68
+ r"""Return whether ``value`` is a valid non-negative integer dimension."""
69
+ return isinstance(value, Integral) and not isinstance(value, bool) and int(value) >= 0
70
+
71
+
72
+ def _normalise_rank(name: str, rank: Any) -> int:
73
+ r"""Normalise and validate a sample-rank declaration."""
74
+ if not _is_nonnegative_int(rank):
75
+ raise ValueError(
76
+ f"Sample rank for argument {name!r} must be a non-negative integer, "
77
+ f"got {rank!r}."
78
+ )
79
+ return int(rank)
80
+
81
+
82
+ def _normalise_rank_mapping(ranks: Mapping[str, Any] | None) -> dict[str, int]:
83
+ r"""Normalise a mapping of argument names to sample ranks."""
84
+ if ranks is None:
85
+ return {}
86
+ return {name: _normalise_rank(name, rank) for name, rank in dict(ranks).items()}
87
+
88
+
89
+ def _normalise_concrete_shape(shape: ShapeLike) -> Shape:
90
+ r"""Return a concrete integer shape as a tuple."""
91
+ if shape is None:
92
+ return ()
93
+ if _is_nonnegative_int(shape):
94
+ return (int(shape),)
95
+ if isinstance(shape, str):
96
+ raise ValueError(f"Concrete shapes cannot contain unresolved string {shape!r}.")
97
+
98
+ try:
99
+ dims = tuple(shape)
100
+ except TypeError as exc:
101
+ raise ValueError(f"Could not interpret shape {shape!r}.") from exc
102
+
103
+ if not all(_is_nonnegative_int(dim) for dim in dims):
104
+ raise ValueError(f"Shape entries must be non-negative integers, got {dims}.")
105
+ return tuple(int(dim) for dim in dims)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ArgSpec:
110
+ r"""
111
+ Optional exact trailing sample-shape specification.
112
+
113
+ ``ArgSpec`` is not needed for the common rank-only API. It is retained as
114
+ a readable wrapper for entries in ``sample_shapes`` or global shape
115
+ defaults.
116
+
117
+ Args:
118
+ shape: Expected trailing sample shape. Use ``None`` or ``()`` for a
119
+ scalar, an ``int`` for a fixed one-dimensional sample, a tuple/list
120
+ for higher-rank samples, a string for attribute lookup on the first
121
+ bound object exposing that attribute, or a callable receiving the
122
+ bound argument mapping and returning a concrete shape.
123
+ """
124
+
125
+ shape: ShapeSpec = ()
126
+
127
+
128
+ ShapeSpecLike: TypeAlias = ArgSpec | ShapeSpec
129
+
130
+
131
+ def _argument_shape(value: Any) -> Shape:
132
+ r"""Return the runtime shape of an argument without entering a JAX trace."""
133
+ try:
134
+ return tuple(jnp.shape(value))
135
+ except Exception as exc:
136
+ raise ValueError(f"Could not determine shape of argument {value!r}.") from exc
137
+
138
+
139
+ def _find_attribute_owner(
140
+ attr_name: str,
141
+ bound_arguments: Mapping[str, Any],
142
+ ) -> Any:
143
+ r"""Find the first bound object exposing ``attr_name``."""
144
+ for preferred in ("self", "cls"):
145
+ owner = bound_arguments.get(preferred)
146
+ if owner is not None and hasattr(owner, attr_name):
147
+ return owner
148
+
149
+ for owner in bound_arguments.values():
150
+ if hasattr(owner, attr_name):
151
+ return owner
152
+
153
+ raise ValueError(
154
+ f"Could not resolve shape attribute {attr_name!r}; no bound argument "
155
+ "exposes this attribute."
156
+ )
157
+
158
+
159
+ def _resolve_shape_dim(dim: Any, bound_arguments: Mapping[str, Any]) -> int:
160
+ r"""Resolve one dimension in a shape specification."""
161
+ if _is_nonnegative_int(dim):
162
+ return int(dim)
163
+ if isinstance(dim, str):
164
+ value = getattr(_find_attribute_owner(dim, bound_arguments), dim)
165
+ if not _is_nonnegative_int(value):
166
+ raise ValueError(
167
+ f"Shape attribute {dim!r} resolved to {value!r}, but dimensions "
168
+ "must be non-negative integers."
169
+ )
170
+ return int(value)
171
+ raise ValueError(f"Unsupported shape dimension specification {dim!r}.")
172
+
173
+
174
+ def _resolve_shape_spec(
175
+ spec: ShapeSpecLike,
176
+ bound_arguments: Mapping[str, Any],
177
+ ) -> Shape:
178
+ r"""Resolve an optional exact sample-shape specification."""
179
+ if isinstance(spec, ArgSpec):
180
+ spec = spec.shape
181
+
182
+ if callable(spec):
183
+ return _normalise_concrete_shape(spec(bound_arguments))
184
+ if spec is None:
185
+ return ()
186
+ if _is_nonnegative_int(spec):
187
+ return (int(spec),)
188
+ if isinstance(spec, str):
189
+ return (_resolve_shape_dim(spec, bound_arguments),)
190
+
191
+ try:
192
+ dims = tuple(spec)
193
+ except TypeError as exc:
194
+ raise ValueError(f"Could not interpret shape specification {spec!r}.") from exc
195
+
196
+ return tuple(_resolve_shape_dim(dim, bound_arguments) for dim in dims)
197
+
198
+
199
+ def _infer_vmap_axis(
200
+ name: str,
201
+ value: Any,
202
+ sample_rank: int,
203
+ ) -> tuple[int | None, int | None, Shape]:
204
+ r"""
205
+ Infer whether ``value`` is unbatched or carries one leading batch axis.
206
+
207
+ Returns ``(axis, batch_size, sample_shape)``.
208
+ """
209
+ shape = _argument_shape(value)
210
+ ndim = len(shape)
211
+
212
+ if ndim == sample_rank:
213
+ return None, None, shape
214
+ if ndim == sample_rank + 1:
215
+ return 0, shape[0], shape[1:]
216
+
217
+ raise ValueError(
218
+ f"Argument {name!r} has shape {shape}; expected sample rank "
219
+ f"{sample_rank} or a leading-axis batch with rank {sample_rank + 1}."
220
+ )
221
+
222
+
223
+ def _call_with_bound_arguments(
224
+ func: Callable[..., Any],
225
+ parameters: Mapping[str, inspect.Parameter],
226
+ arguments: Mapping[str, Any],
227
+ ) -> Any:
228
+ r"""Call ``func`` from an ordered mapping of bound argument values."""
229
+ positional: list[Any] = []
230
+ keywords: dict[str, Any] = {}
231
+
232
+ for name, parameter in parameters.items():
233
+ if name not in arguments:
234
+ continue
235
+
236
+ value = arguments[name]
237
+ if parameter.kind is inspect.Parameter.POSITIONAL_ONLY:
238
+ positional.append(value)
239
+ elif parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
240
+ positional.append(value)
241
+ elif parameter.kind is inspect.Parameter.VAR_POSITIONAL:
242
+ positional.extend(value)
243
+ elif parameter.kind is inspect.Parameter.KEYWORD_ONLY:
244
+ keywords[name] = value
245
+ elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
246
+ keywords.update(value)
247
+
248
+ return func(*positional, **keywords)
249
+
250
+
251
+ def set_auto_vmap_defaults(**sample_ranks: Any) -> dict[str, int]:
252
+ r"""Update global default sample ranks used by :func:`auto_vmap`."""
253
+ _AUTO_VMAP_DEFAULT_RANKS.update(_normalise_rank_mapping(sample_ranks))
254
+ return get_auto_vmap_defaults()
255
+
256
+
257
+ def get_auto_vmap_defaults() -> dict[str, int]:
258
+ r"""Return a copy of the active global sample-rank defaults."""
259
+ return dict(_AUTO_VMAP_DEFAULT_RANKS)
260
+
261
+
262
+ def reset_auto_vmap_defaults() -> None:
263
+ r"""Clear all global sample-rank defaults."""
264
+ _AUTO_VMAP_DEFAULT_RANKS.clear()
265
+
266
+
267
+ @contextmanager
268
+ def auto_vmap_defaults(**sample_ranks: Any) -> Iterator[dict[str, int]]:
269
+ r"""Temporarily update global sample-rank defaults inside a ``with`` block."""
270
+ previous = get_auto_vmap_defaults()
271
+ set_auto_vmap_defaults(**sample_ranks)
272
+ try:
273
+ yield get_auto_vmap_defaults()
274
+ finally:
275
+ _AUTO_VMAP_DEFAULT_RANKS.clear()
276
+ _AUTO_VMAP_DEFAULT_RANKS.update(previous)
277
+
278
+
279
+ def set_auto_vmap_default_shapes(**sample_shapes: ShapeSpecLike) -> dict[str, ShapeSpec]:
280
+ r"""
281
+ Update global default exact sample-shape specifications.
282
+
283
+ These defaults are used only when :func:`auto_vmap` is called with
284
+ ``validate_shapes=True``.
285
+ """
286
+ _AUTO_VMAP_DEFAULT_SHAPES.update(sample_shapes)
287
+ return get_auto_vmap_default_shapes()
288
+
289
+
290
+ def get_auto_vmap_default_shapes() -> dict[str, ShapeSpec]:
291
+ r"""Return a copy of the active global sample-shape defaults."""
292
+ return dict(_AUTO_VMAP_DEFAULT_SHAPES)
293
+
294
+
295
+ def reset_auto_vmap_default_shapes() -> None:
296
+ r"""Clear all global sample-shape defaults."""
297
+ _AUTO_VMAP_DEFAULT_SHAPES.clear()
298
+
299
+
300
+ @contextmanager
301
+ def auto_vmap_default_shapes(**sample_shapes: ShapeSpecLike) -> Iterator[dict[str, ShapeSpec]]:
302
+ r"""Temporarily update global sample-shape defaults inside a ``with`` block."""
303
+ previous = get_auto_vmap_default_shapes()
304
+ set_auto_vmap_default_shapes(**sample_shapes)
305
+ try:
306
+ yield get_auto_vmap_default_shapes()
307
+ finally:
308
+ _AUTO_VMAP_DEFAULT_SHAPES.clear()
309
+ _AUTO_VMAP_DEFAULT_SHAPES.update(previous)
310
+
311
+
312
+ def _validate_local_names(
313
+ kind: str,
314
+ names: Sequence[str],
315
+ parameters: Mapping[str, inspect.Parameter],
316
+ func_name: str,
317
+ ) -> None:
318
+ r"""Validate local decorator declarations against a function signature."""
319
+ missing = sorted(name for name in names if name not in parameters)
320
+ if missing:
321
+ raise ValueError(
322
+ f"auto_vmap {kind} refer to unknown argument(s) {missing} "
323
+ f"for function {func_name!r}."
324
+ )
325
+
326
+ unsupported = sorted(
327
+ name
328
+ for name in names
329
+ if parameters[name].kind
330
+ in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
331
+ )
332
+ if unsupported:
333
+ raise ValueError(
334
+ "auto_vmap cannot infer ranks or shapes for variadic argument(s) "
335
+ f"{unsupported} in function {func_name!r}."
336
+ )
337
+
338
+
339
+ def _active_ranks(
340
+ parameters: Mapping[str, inspect.Parameter],
341
+ local_ranks: Mapping[str, int],
342
+ ) -> dict[str, int]:
343
+ r"""Merge applicable global ranks with local rank overrides."""
344
+ ranks = {
345
+ name: rank
346
+ for name, rank in _AUTO_VMAP_DEFAULT_RANKS.items()
347
+ if name in parameters
348
+ }
349
+ ranks.update(local_ranks)
350
+ return ranks
351
+
352
+
353
+ def _active_shapes(
354
+ parameters: Mapping[str, inspect.Parameter],
355
+ local_shapes: Mapping[str, ShapeSpecLike],
356
+ ) -> dict[str, ShapeSpecLike]:
357
+ r"""Merge applicable global shapes with local shape overrides."""
358
+ shapes = {
359
+ name: spec
360
+ for name, spec in _AUTO_VMAP_DEFAULT_SHAPES.items()
361
+ if name in parameters
362
+ }
363
+ shapes.update(local_shapes)
364
+ return shapes
365
+
366
+
367
+ def auto_vmap(
368
+ sample_ranks: Mapping[str, int] | None = None,
369
+ *,
370
+ sample_shapes: Mapping[str, ShapeSpecLike] | None = None,
371
+ validate_shapes: bool = False,
372
+ jit: bool = True,
373
+ **named_sample_ranks: int,
374
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
375
+ r"""
376
+ Decorate a single-sample function with conservative automatic batching.
377
+
378
+ Sample ranks decide batching. For an argument declared with sample rank
379
+ ``r``, rank ``r`` is treated as one unbatched sample and rank ``r + 1`` is
380
+ treated as a leading-axis batch. If several selected arguments are
381
+ batched, their leading batch sizes must agree. Exact trailing-shape
382
+ validation is optional and separate from batching detection.
383
+ """
384
+ local_ranks = _normalise_rank_mapping(sample_ranks)
385
+ local_ranks.update(_normalise_rank_mapping(named_sample_ranks))
386
+ local_shapes = dict(sample_shapes or {})
387
+
388
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
389
+ signature = inspect.signature(func)
390
+ parameters = signature.parameters
391
+
392
+ _validate_local_names("sample ranks", tuple(local_ranks), parameters, func.__name__)
393
+ _validate_local_names("sample shapes", tuple(local_shapes), parameters, func.__name__)
394
+
395
+ @wraps(func)
396
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
397
+ bound = signature.bind(*args, **kwargs)
398
+ bound.apply_defaults()
399
+
400
+ ranks = _active_ranks(parameters, local_ranks)
401
+ shape_specs = _active_shapes(parameters, local_shapes)
402
+ if not ranks:
403
+ if validate_shapes and shape_specs:
404
+ raise ValueError(
405
+ "auto_vmap shape specifications require matching "
406
+ f"sample-rank declarations; no ranks are active for "
407
+ f"{sorted(shape_specs)} in function {func.__name__!r}."
408
+ )
409
+ raise ValueError(
410
+ f"auto_vmap has no sample-rank declarations for function "
411
+ f"{func.__name__!r}. Pass ranks to auto_vmap or configure "
412
+ "global defaults with set_auto_vmap_defaults(...)."
413
+ )
414
+
415
+ _validate_local_names("active sample ranks", tuple(ranks), parameters, func.__name__)
416
+ if validate_shapes:
417
+ unchecked_shape_specs = sorted(set(shape_specs) - set(ranks))
418
+ if unchecked_shape_specs:
419
+ raise ValueError(
420
+ "auto_vmap shape specifications require matching "
421
+ f"sample-rank declarations; no ranks are active for "
422
+ f"{unchecked_shape_specs} in function {func.__name__!r}."
423
+ )
424
+
425
+ batch_size: int | None = None
426
+ dynamic_values: list[Any] = []
427
+ dynamic_names: list[str] = []
428
+ in_axes: list[int | None] = []
429
+ resolved_shapes: dict[str, Shape] = {}
430
+ resolved_rank_items: list[tuple[str, int]] = []
431
+
432
+ for name, sample_rank in ranks.items():
433
+ axis, size, sample_shape = _infer_vmap_axis(
434
+ name,
435
+ bound.arguments[name],
436
+ sample_rank,
437
+ )
438
+ dynamic_names.append(name)
439
+ dynamic_values.append(bound.arguments[name])
440
+ in_axes.append(axis)
441
+ resolved_rank_items.append((name, sample_rank))
442
+
443
+ if validate_shapes and name in shape_specs:
444
+ expected_shape = _resolve_shape_spec(shape_specs[name], bound.arguments)
445
+ resolved_shapes[name] = expected_shape
446
+ if sample_shape != expected_shape:
447
+ raise ValueError(
448
+ f"Argument {name!r} has trailing sample shape {sample_shape}; "
449
+ f"expected {expected_shape}."
450
+ )
451
+
452
+ if size is None:
453
+ continue
454
+ if batch_size is None:
455
+ batch_size = size
456
+ elif batch_size != size:
457
+ raise ValueError(
458
+ f"Incompatible batch sizes in auto_vmap call: got {batch_size} "
459
+ f"and {size} while checking argument {name!r}."
460
+ )
461
+
462
+ if batch_size is None:
463
+ return func(*args, **kwargs)
464
+
465
+ static_arguments = {
466
+ name: snapshot_static_value(value)
467
+ for name, value in bound.arguments.items()
468
+ if name not in ranks
469
+ }
470
+ static_key = tuple(
471
+ (name, cache_key_value(value))
472
+ for name, value in static_arguments.items()
473
+ )
474
+ cache_key = (
475
+ func,
476
+ tuple(dynamic_names),
477
+ tuple(in_axes),
478
+ bool(jit),
479
+ tuple(resolved_rank_items),
480
+ tuple(
481
+ (name, resolved_shapes[name])
482
+ for name in dynamic_names
483
+ if name in resolved_shapes
484
+ ),
485
+ static_key,
486
+ )
487
+
488
+ cached = lru_cache_get(_AUTO_VMAP_CACHE, cache_key)
489
+ if cached is None:
490
+
491
+ def mapped_call(*dynamic_args: Any) -> Any:
492
+ call_arguments = dict(static_arguments)
493
+ call_arguments.update(zip(dynamic_names, dynamic_args, strict=True))
494
+ return _call_with_bound_arguments(func, parameters, call_arguments)
495
+
496
+ vmapped = jax.vmap(mapped_call, in_axes=tuple(in_axes))
497
+ cached = jax.jit(vmapped) if jit else vmapped
498
+ lru_cache_set(
499
+ _AUTO_VMAP_CACHE,
500
+ cache_key,
501
+ cached,
502
+ maxsize=_AUTO_VMAP_CACHE_MAXSIZE,
503
+ )
504
+
505
+ return cached(*dynamic_values)
506
+
507
+ wrapped.auto_vmap_local_ranks = local_ranks
508
+ wrapped.auto_vmap_local_shapes = local_shapes
509
+ wrapped.auto_vmap_axes = lambda *args, **kwargs: _axes_by_call(
510
+ signature, parameters, local_ranks, args, kwargs
511
+ )
512
+ return wrapped
513
+
514
+ return decorator
515
+
516
+
517
+ def _axes_by_call(
518
+ signature: inspect.Signature,
519
+ parameters: Mapping[str, inspect.Parameter],
520
+ local_ranks: Mapping[str, int],
521
+ args: tuple[Any, ...],
522
+ kwargs: Mapping[str, Any],
523
+ ) -> dict[str, int | None]:
524
+ r"""Return inferred axes for a call, primarily for debugging and tests."""
525
+ bound = signature.bind(*args, **kwargs)
526
+ bound.apply_defaults()
527
+ ranks = _active_ranks(parameters, local_ranks)
528
+ return {
529
+ name: _infer_vmap_axis(name, bound.arguments[name], rank)[0]
530
+ for name, rank in ranks.items()
531
+ }
532
+
533
+
534
+ def clear_auto_vmap_caches(clear_jax: bool = True) -> None:
535
+ r"""
536
+ Clear cached wrappers created by :func:`auto_vmap`.
537
+
538
+ Args:
539
+ clear_jax: If ``True``, also call ``jax.clear_caches()`` when available.
540
+ This clears JAX's in-process compilation caches but does not remove
541
+ entries from a persistent on-disk compilation cache.
542
+ """
543
+ _AUTO_VMAP_CACHE.clear()
544
+ if clear_jax and hasattr(jax, "clear_caches"):
545
+ jax.clear_caches()
546
+
547
+
548
+ def _smoke_test() -> None:
549
+ r"""Run a small self-contained smoke test for ``python -m stringjax_tools``."""
550
+ import numpy as np
551
+
552
+ @auto_vmap(moduli=1, tau=0, fluxes=1)
553
+ def f(moduli: Any, tau: Any, fluxes: Any, scale: float = 1.0) -> Any:
554
+ return scale * (jnp.sum(moduli) + tau + jnp.sum(fluxes))
555
+
556
+ single = f(jnp.array([1.0, 2.0]), jnp.array(3.0), jnp.arange(6.0), scale=2.0)
557
+ paired = f(jnp.ones((4, 2)), jnp.arange(4.0), jnp.ones((4, 6)), scale=2.0)
558
+ broadcast = f(jnp.ones((4, 2)), jnp.array(3.0), jnp.ones(6), scale=2.0)
559
+
560
+ assert np.asarray(single).shape == ()
561
+ np.testing.assert_allclose(np.asarray(single), 42.0)
562
+ assert np.asarray(paired).shape == (4,)
563
+ np.testing.assert_allclose(np.asarray(paired), np.array([16.0, 18.0, 20.0, 22.0]))
564
+ assert np.asarray(broadcast).shape == (4,)
565
+ np.testing.assert_allclose(np.asarray(broadcast), np.full(4, 22.0))
566
+
567
+ try:
568
+ f(jnp.ones((4, 2)), jnp.ones((5,)), jnp.ones((4, 6)))
569
+ except ValueError:
570
+ pass
571
+ else:
572
+ raise AssertionError("incompatible batch sizes did not raise ValueError")
573
+
574
+ try:
575
+ f(jnp.ones((4, 2, 1)), jnp.ones((4,)), jnp.ones((4, 6)))
576
+ except ValueError:
577
+ pass
578
+ else:
579
+ raise AssertionError("malformed moduli rank did not raise ValueError")
580
+
581
+ print("stringjax_tools smoke test passed")
@@ -0,0 +1,86 @@
1
+ # Copyright 2022-2026 Andreas Schachner
2
+ #
3
+ # This file is part of StringJAX Tools.
4
+ #
5
+ # StringJAX Tools is free software: you can redistribute it and/or modify
6
+ # it under the terms of the GNU General Public License as published by
7
+ # the Free Software Foundation, either version 3 of the License, or
8
+ # (at your option) any later version.
9
+ #
10
+ # StringJAX Tools is distributed in the hope that it will be useful,
11
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ # GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License
16
+ # along with StringJAX Tools. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ """Opt-in helpers for JAX compilation-cache configuration."""
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import jax
26
+
27
+ __all__ = ["configure_compilation_cache"]
28
+
29
+
30
+ def configure_compilation_cache(
31
+ cache_dir: str | Path | None = None,
32
+ *,
33
+ max_size_bytes: int | None = None,
34
+ min_compile_time_secs: float | None = None,
35
+ min_entry_size_bytes: int | None = None,
36
+ enable_xla_caches: str | None = None,
37
+ explain_cache_misses: bool | None = None,
38
+ ) -> dict[str, Any]:
39
+ r"""
40
+ Configure JAX's persistent compilation cache explicitly.
41
+
42
+ This helper has no import-time side effects. Call it near the top of a
43
+ script or notebook before any JAX compilation happens.
44
+
45
+ Args:
46
+ cache_dir: Directory for the persistent compilation cache. If supplied,
47
+ the directory is created if needed. If ``None``, the cache
48
+ directory is not changed.
49
+ max_size_bytes: Maximum persistent cache size in bytes. JAX interprets
50
+ ``0`` as disabling the persistent cache and ``-1`` as no size limit.
51
+ min_compile_time_secs: Minimum compilation time required before an entry
52
+ is written to the persistent cache.
53
+ min_entry_size_bytes: Minimum entry size required before an entry is
54
+ written to the persistent cache.
55
+ enable_xla_caches: Value forwarded to
56
+ ``jax_persistent_cache_enable_xla_caches``.
57
+ explain_cache_misses: If ``True``, ask JAX to explain persistent-cache
58
+ misses.
59
+
60
+ Returns:
61
+ Configuration names and values that were applied.
62
+ """
63
+ updates: dict[str, Any] = {}
64
+
65
+ if cache_dir is not None:
66
+ cache_path = Path(cache_dir).expanduser()
67
+ cache_path.mkdir(parents=True, exist_ok=True)
68
+ updates["jax_compilation_cache_dir"] = str(cache_path)
69
+ if max_size_bytes is not None:
70
+ updates["jax_compilation_cache_max_size"] = int(max_size_bytes)
71
+ if min_compile_time_secs is not None:
72
+ updates["jax_persistent_cache_min_compile_time_secs"] = float(
73
+ min_compile_time_secs
74
+ )
75
+ if min_entry_size_bytes is not None:
76
+ updates["jax_persistent_cache_min_entry_size_bytes"] = int(min_entry_size_bytes)
77
+ if enable_xla_caches is not None:
78
+ updates["jax_persistent_cache_enable_xla_caches"] = enable_xla_caches
79
+ if explain_cache_misses is not None:
80
+ updates["jax_explain_cache_misses"] = bool(explain_cache_misses)
81
+
82
+ for name, value in updates.items():
83
+ jax.config.update(name, value)
84
+
85
+ return updates
86
+