From 33f8ba502d7002e0d41b4d6f9d121ce524b4d808 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:40:35 -0700 Subject: [PATCH 01/13] docs: name the runtime by role, not by repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repository is public. The frame-counter panel's upstream is a private repository, and REQUIREMENTS.md, SPEC.md, the counter-panel module and its test all named it — three of them citing a `§spec:` slug that points into it. Name the role instead ("a real-time LED render runtime"), and drop the cross-repo slug citations rather than point them at a repository no reader of this one can open. The name is already published and this does not unpublish it: clones, forks, and the public event stream carry the earlier revisions. This stops the reference spreading further and keeps the counter panel's provenance readable without it. --- REQUIREMENTS.md | 6 +++--- SPEC.md | 12 ++++++------ display_patterns/patterns/_counter_panel.py | 2 +- tests/test_counter_panel.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 14dac68..d04e7b0 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -15,8 +15,8 @@ real-time render graphs, and measurement pipelines. Test-pattern math lives trapped inside device tools. bmd-signal-gen's checkerboard and chart generation imports nothing from its DeckLink layer, yet a consumer who wants only the patterns installs the -whole device tool. backlit_molecule re-derived its own frame-counter -panel math because no importable source existed. Each new consumer +whole device tool. A real-time LED render runtime re-derived its own +frame-counter panel math because no importable source existed. Each new consumer rewrites geometry that is pure, deterministic, and identical across delivery paths. @@ -116,7 +116,7 @@ Essential, in adoption order: extras) with bit-identical output. 3. Frame-indexed rendering signature on every pattern. 4. Temporal-alignment counter panel, encode and decode (ported from - backlit_molecule's probe math). + a real-time LED render runtime's probe math). Nice-to-have, after adoption: diff --git a/SPEC.md b/SPEC.md index 54cd891..9527d8b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,8 +10,8 @@ system does and why. See ROADMAP.md for work remaining. Display test-pattern math lives trapped inside device tools (§req:problem-statement). bmd-signal-gen's pattern and chart modules import nothing from its DeckLink layer yet ship only inside the device -tool; backlit_molecule re-derived frame-counter panel math for want of -an importable source. General imaging libraries carry color-management +tool; a real-time LED render runtime re-derived frame-counter panel +math for want of an importable source. General imaging libraries carry color-management opinions and cannot make a measurement claim: driving *exact* integer code values at a stated bit depth is the point, and a library that rescales or quantizes behind the caller's back defeats it. @@ -106,9 +106,9 @@ The core catalog, renderable with numpy alone (§req:success-criteria): back out of the other (§req:user-stories). The geometry is deterministic, so encoder and decoder agree on every cell from parameters alone, and decode thresholds at the cell midpoint to - survive a lossy chain (§req:success-criteria). Ported from - backlit_molecule's probe math (`§spec:alignment-probe` there), - which retires its bespoke node in favor of generic primitives. + survive a lossy chain (§req:success-criteria). Ported from the + alignment-probe math of a real-time LED render runtime, which + retires its bespoke node in favor of generic primitives. The `charts` extra adds chart production (§req:user-stories): a chart is authored as a YAML patch list carrying colorimetric values, @@ -169,5 +169,5 @@ Out of scope, with their owners: device output and signaling (consumers' runtimes); color management and display characterization (ocio-display-gen, color-wrangler); instrument I/O and measurement sessions (color-wrangler, colour-specio); runtime graph integration -(backlit_molecule). The library defines the mapping from parameters +(consumers' render runtimes). The library defines the mapping from parameters to image and nothing on either side of it. diff --git a/display_patterns/patterns/_counter_panel.py b/display_patterns/patterns/_counter_panel.py index b36c228..f5caf3a 100644 --- a/display_patterns/patterns/_counter_panel.py +++ b/display_patterns/patterns/_counter_panel.py @@ -5,7 +5,7 @@ deterministic — encoder and decoder agree on every cell from parameters alone — and decode samples each cell's centre, thresholding at the value midpoint, so the counter survives a lossy video chain. -Ported from backlit_molecule's probe math (its ``§spec:alignment-probe``) +Ported from the alignment-probe math of a real-time LED render runtime onto the frame-indexed namespace signature. Value and layout conventions (§spec:render-model): this is a float diff --git a/tests/test_counter_panel.py b/tests/test_counter_panel.py index 13f2238..c975005 100644 --- a/tests/test_counter_panel.py +++ b/tests/test_counter_panel.py @@ -3,8 +3,8 @@ Encode and decode ship together: a panel rendered at frame N decodes back to N from the array, and after a lossy trip, because decode samples each cell's centre and thresholds at the value midpoint. The -math is ported from backlit_molecule's probe (its ``§spec:alignment-probe``) -onto the frame-indexed namespace signature; these tests carry the +math is ported from the alignment probe of a real-time LED render +runtime onto the frame-indexed namespace signature; these tests carry the numpy leg, with a torch leg (the only one reaching the backend's device branches) that skips where torch is absent. """ From 2871ef5a1d8203a89511e55bc4080889bef84691 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:42:42 -0700 Subject: [PATCH 02/13] style: reflow the paragraphs the rename rewrapped --- REQUIREMENTS.md | 10 +++++----- SPEC.md | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d04e7b0..043a1a3 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -14,11 +14,11 @@ real-time render graphs, and measurement pipelines. Test-pattern math lives trapped inside device tools. bmd-signal-gen's checkerboard and chart generation imports nothing from its DeckLink -layer, yet a consumer who wants only the patterns installs the -whole device tool. A real-time LED render runtime re-derived its own -frame-counter panel math because no importable source existed. Each new consumer -rewrites geometry that is pure, deterministic, and identical across -delivery paths. +layer, yet a consumer who wants only the patterns installs the whole +device tool. A real-time LED render runtime re-derived its own +frame-counter panel math because no importable source existed. Each +new consumer rewrites geometry that is pure, deterministic, and +identical across delivery paths. General imaging libraries fall short in the other direction: they carry color-management and encoding opinions and offer no measurement diff --git a/SPEC.md b/SPEC.md index 9527d8b..5e401d0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,10 +11,11 @@ Display test-pattern math lives trapped inside device tools (§req:problem-statement). bmd-signal-gen's pattern and chart modules import nothing from its DeckLink layer yet ship only inside the device tool; a real-time LED render runtime re-derived frame-counter panel -math for want of an importable source. General imaging libraries carry color-management -opinions and cannot make a measurement claim: driving *exact* integer -code values at a stated bit depth is the point, and a library that -rescales or quantizes behind the caller's back defeats it. +math for want of an importable source. General imaging libraries carry +color-management opinions and cannot make a measurement claim: driving +*exact* integer code values at a stated bit depth is the point, and a +library that rescales or quantizes behind the caller's back defeats +it. display-patterns is that importable source: deterministic pattern math, device-free, exact by construction. bmd-signal-gen's SPEC @@ -169,5 +170,5 @@ Out of scope, with their owners: device output and signaling (consumers' runtimes); color management and display characterization (ocio-display-gen, color-wrangler); instrument I/O and measurement sessions (color-wrangler, colour-specio); runtime graph integration -(consumers' render runtimes). The library defines the mapping from parameters -to image and nothing on either side of it. +(consumers' render runtimes). The library defines the mapping from +parameters to image and nothing on either side of it. From eb3e6b413498aa4d3460a3509244ac34af9f5f52 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:43:15 -0700 Subject: [PATCH 03/13] docs(spec): state backend portability as a catalog property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendering model already says patterns render through a caller-supplied namespace, and §req:success-criteria already claims numpy and torch agree. Nothing says what makes that true, and three things currently make it false. Render bodies allocate and then write strided slices. A scatter is a materialized intermediate no compiler fuses away, and mutation rules out immutable-array backends outright — so the torch path is eager and bandwidth-bound where it should be one kernel. The counter panel takes its frame index as a Python integer and shifts it per bit in Python. To a tracing compiler that integer is a compile-time constant, so a consumer compiling its frame loop recompiles the pattern on every frame; the fused path would cost more than the eager one it replaced. Checkerboard hard-returns uint16, which does not work on MPS — the backend the consumers of this library develop against. The new section states the end state for all three, plus where verification lives: torch's CPU build as a development dependency so the equivalence tests stop skipping silently, and opt-in markers for CUDA and MPS where the hardware is. The rendering model drops to in progress because its dtype sentence is superseded. --- SPEC.md | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index 5e401d0..e6ac3ec 100644 --- a/SPEC.md +++ b/SPEC.md @@ -48,7 +48,7 @@ display. ## Rendering model §spec:render-model -*Status: complete* +*Status: in progress* A pattern is a pure function: parameters (geometry, values) and a frame index in, an array out. The same inputs produce identical @@ -81,13 +81,14 @@ manufactures error. The library owns geometry; meaning stays with the caller. Catalog entries share one calling convention: pattern parameters, a -`frame` index, and the `xp`/`device` keywords. Rasters are HWC (height, -width, channels); integer patterns return the namespace's `uint16` at -the stated bit depth; a pattern whose output is more than one plane -(the counter panel's overlay + mask) returns a documented tuple. **Why -HWC and not the source runtime's NCHW:** these are images consumers -composite and encode, not batched graph payloads; the batch and -channel-first axes belong to the runtime that needs them. +`frame` index, and the `xp`/`device` keywords. Rasters are HWC +(height, width, channels); integer patterns return the dtype the +caller states, `uint16` by default (§spec:backend-portability); a +pattern whose output is more than one plane (the counter panel's +overlay + mask) returns a documented tuple. **Why HWC and not the +source runtime's NCHW:** these are images consumers composite and +encode, not batched graph payloads; the batch and channel-first axes +belong to the runtime that needs them. ## Catalog §spec:catalog @@ -126,6 +127,65 @@ Catalog growth (motion material, PLUGE, ramps, zone plates) enters as consumers need it (§req:priorities); each entry follows the rendering model and carries a decode side when one is meaningful. +## Backend portability §spec:backend-portability + +*Status: not started* + +The core catalog renders the same values on every backend a consumer +brings: numpy on a CPU host, torch on CUDA, torch on Apple's MPS +(§req:quality-attributes portability). A counter panel rendered on any +of them decodes to the frame index it encodes +(§req:success-criteria). + +Render bodies are functional. An entry point computes its result from +broadcast arithmetic over coordinate and bit-position arrays and +returns it, rather than allocating a buffer and writing strided slices +into it. No render body branches on array contents, and none reads a +value back to the host; parameter validation stays host-side, where +the parameters already are. + +**Why functional and not in-place:** a strided scatter is a +materialized intermediate no compiler can fuse away, so an in-place +body spends a frame of memory bandwidth per write where the whole +pattern would otherwise be one kernel. In-place bodies also exclude +any backend whose arrays are immutable. The library never calls +`torch.compile` itself — compilation belongs to the consumer's graph +(§spec:non-goals) — so its obligation is to stay *compilable*, and +staying compilable is what vectorized means here +(§req:quality-attributes performance). + +A temporal pattern's frame index is array data, not a Python integer: +`frame` accepts a scalar or a zero-dimensional array, and the +counter's bits are extracted arithmetically. **Why the frame index is +data:** to a tracing compiler a Python integer is a compile-time +constant, so a consumer compiling its frame loop would recompile the +pattern on every frame and the fused path would cost more than the +eager one it replaced. The counter is bounded at 31 bits so its +arithmetic fits the signed 32-bit integers every backend supports; at +60 Hz that counts for over a year before wrapping. + +Output dtype is the caller's, defaulting to the exact-integer type the +pattern has always returned. An integer pattern renders into any dtype +that represents its stated bit depth without loss and rejects one that +cannot, so exactness is preserved by the check rather than by a fixed +type (§req:quality-attributes exactness). **Why the caller states the +dtype:** `uint16` does not work on MPS, so a hard-coded return type +makes the library unusable on a host its consumers develop on. The +exactness claim is about the values that arrive, and a wider integer +carries them just as exactly. + +Verification is proportional to where the hardware is +(§req:priorities). The torch leg runs on every change — torch's CPU +build is a development dependency, so backend equivalence is a gate +rather than a test that skips silently. The CUDA and MPS legs are +opt-in markers run on the hardware that has them. CI asserts the +backend contract; hardware asserts the device. + +The `charts` extra is numpy-only and stays so: chart rendering draws +text through an imaging library and converts through a colorimetry +library, neither of which has a device backend. Backend portability is +a core-catalog property. + ## Extraction and compatibility §spec:extraction *Status: complete* From 2533d5393b23185488f61e9b381d466e42a5a647 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:44:16 -0700 Subject: [PATCH 04/13] docs(roadmap): decompose backend portability into four workstreams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §spec:backend-portability lands as one section in build-dependency order. The torch gate goes first because it is what keeps the other three honest: until torch's CPU build is a dev dependency the equivalence tests skip, and a functional rewrite would land with nothing checking it renders the same arrays. The two rewrites are separate workstreams because they are separate files with separate risk — the fill also changes its return dtype contract, the counter also changes what type its frame index accepts. The last workstream is the surface: a consumer on MPS has no way to discover it must pass a dtype unless the README and the entry-point docstrings say so, which makes documentation the integration point for this section rather than an afterthought to it. --- ROADMAP.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index f40ecfd..a82e884 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,3 +25,57 @@ imports and renders a checkerboard with numpy alone; `pip install writes it to TIFF. (The array-for-array equivalence claim against bmd-signal-gen is banked and pinned in §spec:extraction; its scaffolding retired with the rendering-model reshape.) + +## Fused backend rendering §road:fused-backends + +Make the multi-backend claim true and checkable: render bodies a +consumer's compiler can fuse, an output dtype that works on the +backend it develops on, and the torch leg running on every change +instead of skipping (§spec:backend-portability). A render-runtime +consumer repoints its duplicated counter-panel math here once these +land; its adoption is tracked in its own roadmap. + +### Torch as a gate, not a skip §road:torch-gate + +Add torch's CPU build to the dev dependency group and register `cuda` +and `mps` markers, so the backend-equivalence legs in +`tests/conftest.py` run on every change and the device legs are opt-in +— `pyproject.toml`, `tests/conftest.py`, `.github/workflows/ci.yml`. +§spec:backend-portability. + +### Functional fills at a caller-stated dtype §road:functional-fills + +Render `checkerboard` from broadcast arithmetic rather than strided +writes into a zero buffer, and take the output dtype as a parameter +defaulting to `uint16`, rejecting one that cannot hold the stated bit +depth — `display_patterns/patterns/_fills.py`, +`display_patterns/patterns/_backend.py`. Depends on §road:torch-gate. +§spec:backend-portability. + +### Counter panel on an array frame index §road:functional-counter + +Render the counter panel from broadcast arithmetic over bit positions +with `frame` accepted as a scalar or zero-dimensional array, bounding +the counter at 31 bits — +`display_patterns/patterns/_counter_panel.py`. Depends on +§road:torch-gate. §spec:backend-portability. + +### Backend support on the public surface §road:backend-surface + +State the supported backends, the dtype a caller passes on each, and +the counter's bit bound in the package's README and entry-point +docstrings, and record the measured device legs — `README.md`, +`display_patterns/patterns/__init__.py`. Depends on +§road:functional-fills and §road:functional-counter. +§spec:backend-portability. + +**Verify:** In a fresh environment with torch installed, `pytest` +passes with no skipped backend legs. On an Apple-silicon host, +`pytest -m mps` renders a checkerboard and a counter panel on +`torch.device("mps")` — the checkerboard at a caller-stated `int32`, +since `uint16` is rejected there — and `decode_counter` recovers the +encoded index from the MPS-resident overlay. On a CUDA host, +`pytest -m cuda` does the same on `torch.device("cuda")`. Wrapping a +`render_counter_panel` call in `torch.compile` and stepping the frame +index over a hundred frames compiles once, not once per frame. The +numpy path renders the same arrays as before these changes. From 223f0735584caf7dba13289a07019aa9b13d6626 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:47:15 -0700 Subject: [PATCH 05/13] test(backends): make the torch leg a gate instead of a skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend-equivalence tests called `pytest.importorskip("torch")` against an environment that never installed torch, so both legs skipped on every run and in CI. The claim that numpy and torch render the same arrays was asserted by nothing. Torch's CPU build joins the dev group. It stays out of the package's dependencies — the namespace is duck-typed, and a numpy-only consumer must keep paying nothing for it. On Linux it resolves from PyTorch's CPU index, because PyPI's Linux wheel bundles a CUDA runtime CI has no use for; macOS resolves from PyPI, whose arm64 wheel is the one carrying MPS. Device legs are opt-in behind `cuda` and `mps` markers, deselected by default: CI asserts the backend contract, hardware asserts the device. Running the gate for the first time immediately found a bug it was meant to find. A single-colour fill expanded through `broadcast_to`, whose result is a read-only view, and torch warns that it cannot back a tensor with non-writable memory. Copy the expansion instead. --- display_patterns/patterns/_fills.py | 4 +- pyproject.toml | 27 ++++ tests/conftest.py | 69 ++++++++-- tests/test_counter_panel.py | 24 +++- uv.lock | 202 ++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 11 deletions(-) diff --git a/display_patterns/patterns/_fills.py b/display_patterns/patterns/_fills.py index 06f4e48..f876ecd 100644 --- a/display_patterns/patterns/_fills.py +++ b/display_patterns/patterns/_fills.py @@ -94,7 +94,9 @@ def _expand_colors(colors: ArrayLike, bit_depth: int) -> np.ndarray: num_colors = host.shape[0] if num_colors == 1: - host = np.broadcast_to(host, (4, 3)) + # Copied, not a broadcast view: a view is read-only, and torch + # warns that it cannot back a tensor with non-writable memory. + host = np.broadcast_to(host, (4, 3)).copy() elif num_colors == 2: host = host[(0, 1, 1, 0), :] elif num_colors == 3: diff --git a/pyproject.toml b/pyproject.toml index 3a82235..e0a5194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,8 +70,27 @@ dev = [ "pyright>=1.1.403", "pytest>=8.4.1", "ruff>=0.12.3", + # Torch is never a dependency of this package — the array namespace is + # duck-typed (§spec:render-model). It is a development dependency so the + # backend-equivalence tests run on every change instead of skipping + # silently (§spec:backend-portability). + "torch>=2.6", ] +[tool.uv.sources] +# On Linux, PyPI's `torch` bundles the CUDA runtime — gigabytes CI does not +# need to prove the backend contract, which the CPU build proves just as well. +# macOS resolves from PyPI, whose arm64 wheel is the one carrying MPS. A +# developer wanting CUDA locally overrides this index. +torch = [ + { index = "pytorch-cpu", marker = "sys_platform == 'linux'" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.ruff] line-length = 88 target-version = "py312" @@ -135,3 +154,11 @@ reportUnnecessaryCast = true [tool.pytest.ini_options] testpaths = ["tests"] +# Device legs are opt-in: CI asserts the backend contract on torch's CPU +# build, and hardware asserts the device (§spec:backend-portability). Run +# them with `pytest -m mps` or `pytest -m cuda`, which overrides the -m here. +addopts = "-m 'not cuda and not mps'" +markers = [ + "cuda: needs a CUDA device; deselected unless -m cuda is given", + "mps: needs an Apple MPS device; deselected unless -m mps is given", +] diff --git a/tests/conftest.py b/tests/conftest.py index 88e23ac..18b72ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,21 +7,72 @@ import pytest +def torch_or_skip() -> Any: + """The torch module, or skip where it is absent. + + Torch is a development dependency, never a dependency of the package + — the array namespace is duck-typed (§spec:render-model). It is + installed for the test run so backend equivalence is a gate rather + than a leg that skips silently (§spec:backend-portability). + """ + return pytest.importorskip("torch") + + +def device_or_skip(name: str) -> Any: + """A torch device named ``name``, or skip where the host has none. + + Guards the ``cuda`` and ``mps`` legs, which stay deselected unless + asked for by marker: CI asserts the backend contract on torch's CPU + build, hardware asserts the device (§spec:backend-portability). + """ + torch = torch_or_skip() + is_available = { + "cuda": lambda: torch.cuda.is_available(), + "mps": lambda: torch.backends.mps.is_available(), + }[name] + if not is_available(): + pytest.skip(f"host has no {name} device") + return torch.device(name) + + +def to_host(array: Any) -> np.ndarray: + """``array`` as a host numpy array, wherever it was rendered.""" + if hasattr(array, "cpu"): + array = array.cpu() + return np.asarray(array) + + +def decode_on_device( + render: Callable[..., Any], *args: Any, device: Any, **kwargs: Any +) -> int: + """Render on ``device`` through torch and decode the result there. + + The decode reads the device-resident overlay directly, so the leg + exercises the one-transfer readback rather than a host copy the + test made first (§spec:backend-portability). + """ + from display_patterns import decode_counter + + torch = torch_or_skip() + overlay, _mask = render(*args, xp=torch, device=device, **kwargs) + geometry = args[-1] + return decode_counter(overlay, geometry) + + def assert_backend_matches_numpy( - render: Callable[..., Any], *args: Any, **kwargs: Any + render: Callable[..., Any], *args: Any, device: Any = None, **kwargs: Any ) -> None: """Render under torch and assert equality with the numpy result. - Skips where torch is absent (torch is never a dependency of this - package — the namespace is duck-typed). Handles entry points that - return one array or a tuple of arrays. Note the numpy leg does not - reach the ``device``/``to`` backend branches; only this helper does, - where torch is installed. + Handles entry points that return one array or a tuple of arrays. + The numpy leg does not reach the backend's ``device``/``to`` + branches; only the torch leg does, and only a ``device`` argument + reaches placement. """ - torch = pytest.importorskip("torch") + torch = torch_or_skip() expected = render(*args, **kwargs) - actual = render(*args, xp=torch, **kwargs) + actual = render(*args, xp=torch, device=device, **kwargs) expected_items = expected if isinstance(expected, tuple) else (expected,) actual_items = actual if isinstance(actual, tuple) else (actual,) for actual_item, expected_item in zip(actual_items, expected_items, strict=True): - np.testing.assert_array_equal(np.asarray(actual_item), expected_item) + np.testing.assert_array_equal(to_host(actual_item), to_host(expected_item)) diff --git a/tests/test_counter_panel.py b/tests/test_counter_panel.py index c975005..c90a0c9 100644 --- a/tests/test_counter_panel.py +++ b/tests/test_counter_panel.py @@ -13,7 +13,11 @@ import pytest from display_patterns import PanelGeometry, decode_counter, render_counter_panel -from tests.conftest import assert_backend_matches_numpy +from tests.conftest import ( + assert_backend_matches_numpy, + decode_on_device, + device_or_skip, +) def _geometry(bits: int) -> PanelGeometry: @@ -112,6 +116,24 @@ def test_round_trip_under_torch() -> None: assert_backend_matches_numpy(render_counter_panel, 42, geom) +@pytest.mark.parametrize( + "device_name", + [ + pytest.param("cuda", marks=pytest.mark.cuda), + pytest.param("mps", marks=pytest.mark.mps), + ], +) +def test_round_trip_on_a_device(device_name: str) -> None: + """A panel rendered on a device decodes to the index it encodes, + read back in one transfer (§spec:backend-portability). Deselected + unless asked for by marker; skipped where the host has no such + device.""" + device = device_or_skip(device_name) + geom = _geometry(bits=8) + assert_backend_matches_numpy(render_counter_panel, 42, geom, device=device) + assert decode_on_device(render_counter_panel, 42, geom, device=device) == 42 + + def test_geometry_rejects_a_zero_bit_counter() -> None: """A counter needs at least one bit-cell; ``bits=0`` is rejected with a range error rather than a division crash.""" diff --git a/uv.lock b/uv.lock index 5c14a77..5f08038 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "sys_platform == 'linux'", + "sys_platform != 'linux'", +] [[package]] name = "colorama" @@ -46,6 +50,8 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "ruff" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" }, ] [package.metadata] @@ -64,6 +70,26 @@ dev = [ { name = "pyright", specifier = ">=1.1.403" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "ruff", specifier = ">=0.12.3" }, + { name = "torch", marker = "sys_platform != 'linux'", specifier = ">=2.6" }, + { name = "torch", marker = "sys_platform == 'linux'", specifier = ">=2.6", index = "https://download.pytorch.org/whl/cpu" }, +] + +[[package]] +name = "filelock" +version = "3.32.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, ] [[package]] @@ -75,6 +101,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -406,6 +525,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + [[package]] name = "tifffile" version = "2026.7.31" @@ -418,6 +558,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/ed/75bf4d6ae6fec7233ef466f27dffc99f91fde53a31e69f02640b418317ec/tifffile-2026.7.31-py3-none-any.whl", hash = "sha256:81adfa08012be1c478f99b83cda2f529eef8620cfbdf94fc41eef6f1d7b47dc5", size = 271576, upload-time = "2026-08-01T02:28:31.068Z" }, ] +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform != 'linux'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'linux'" }, + { name = "fsspec", marker = "sys_platform != 'linux'" }, + { name = "jinja2", marker = "sys_platform != 'linux'" }, + { name = "networkx", marker = "sys_platform != 'linux'" }, + { name = "setuptools", marker = "sys_platform != 'linux'" }, + { name = "sympy", marker = "sys_platform != 'linux'" }, + { name = "typing-extensions", marker = "sys_platform != 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "sys_platform == 'linux'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'linux'" }, + { name = "fsspec", marker = "sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'linux'" }, + { name = "networkx", marker = "sys_platform == 'linux'" }, + { name = "setuptools", marker = "sys_platform == 'linux'" }, + { name = "sympy", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-linux_s390x.whl", hash = "sha256:dec241fef3984c0d1edadd1f58708e218d4eae881ceef7bc10cf9964d41b68b9", upload-time = "2026-07-08T19:30:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ca021f9eb2f8345c83fa03e3a04587308afb8df71bd472670b3ece00df58621c", upload-time = "2026-07-08T19:30:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d20fa53ee744502fa4c69818a720b05ca0d37abd055d4f6e66cae155114bc691", upload-time = "2026-07-08T19:30:45Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-linux_s390x.whl", hash = "sha256:991cc14b39e751122c01f017be6448533989868731cb5eecd1006893d26787c2", upload-time = "2026-07-08T19:31:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7b8d26e29bceafbdaa8d63bfe7612f23875b5af2cc07e13f809c3ed890bbe1d8", upload-time = "2026-07-08T19:31:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b222c15a0fc2ce207d1c1a59700b46c8fa6748df1f447ad11e5c870dde0933d9", upload-time = "2026-07-08T19:31:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5002ca81af00ae69b57540f615b58b8ae922b6d4848176b366a52bd2196e6", upload-time = "2026-07-08T19:32:00Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:1a3a35229fdc13446b4eab50e7fcf9399ff941e89a3b761497786297a5d8dde5", upload-time = "2026-07-08T19:32:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:8e109528e6bab044815daebaf71770fbaace3a66ef1c816cb55c875350f78a60", upload-time = "2026-07-08T19:32:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:222a6681467cc7f6f05cd3068dfbc603def3a1e46d1d4620c1c8cdf6178bd563", upload-time = "2026-07-08T19:32:44Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 157a8b68b4164d1ffe347d6347dc0cddf71f3beb Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:50:20 -0700 Subject: [PATCH 06/13] docs(spec): correct the dtype rationale against measured behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section claimed uint16 "does not work on MPS". Probing torch 2.13 on an M-series host shows that is false in eager mode: allocation, strided assignment, `where`, and host readback all succeed at uint16. What is true is narrower and lands on the path that matters. Inductor's Metal code generator has no uint16 entry in its dtype table, so a uint16 output raises `KeyError: torch.uint16` the moment a consumer wraps the render in `torch.compile`. The dtype parameter is still the right decision — it is the fused path it unblocks, not the eager one. The functional-body rationale gains its measurement: 2160p on MPS, 3.5 ms strided against 0.7 ms functional, before any compilation. --- SPEC.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/SPEC.md b/SPEC.md index e6ac3ec..b9f0f7c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -145,13 +145,14 @@ value back to the host; parameter validation stays host-side, where the parameters already are. **Why functional and not in-place:** a strided scatter is a -materialized intermediate no compiler can fuse away, so an in-place -body spends a frame of memory bandwidth per write where the whole -pattern would otherwise be one kernel. In-place bodies also exclude -any backend whose arrays are immutable. The library never calls -`torch.compile` itself — compilation belongs to the consumer's graph -(§spec:non-goals) — so its obligation is to stay *compilable*, and -staying compilable is what vectorized means here +materialized intermediate no compiler fuses away, so an in-place body +spends a pass over the frame per write where the whole pattern is one +kernel — measured at 2160p on MPS, 3.5 ms for the strided body against +0.7 ms for the functional one, before any compilation. In-place bodies +also exclude any backend whose arrays are immutable. The library never +calls `torch.compile` itself — compilation belongs to the consumer's +graph (§spec:non-goals) — so its obligation is to stay *compilable*, +and staying compilable is what vectorized means here (§req:quality-attributes performance). A temporal pattern's frame index is array data, not a Python integer: @@ -169,10 +170,13 @@ pattern has always returned. An integer pattern renders into any dtype that represents its stated bit depth without loss and rejects one that cannot, so exactness is preserved by the check rather than by a fixed type (§req:quality-attributes exactness). **Why the caller states the -dtype:** `uint16` does not work on MPS, so a hard-coded return type -makes the library unusable on a host its consumers develop on. The -exactness claim is about the values that arrive, and a wider integer -carries them just as exactly. +dtype:** a fixed return type forces every consumer whose value space +differs to pay a conversion pass, and it collides with what a backend +can compile. `uint16` renders on MPS in eager mode but has no Metal +code-generation mapping, so a `uint16` output cannot be compiled there +at all — and which types a backend supports is the backend's business, +moving between its releases. The exactness claim is about the values +that arrive; any type wide enough carries them just as exactly. Verification is proportional to where the hardware is (§req:priorities). The torch leg runs on every change — torch's CPU From f8ae0f3d1d27bc5c8efc6cc758b21be2e26fa46f Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:55:12 -0700 Subject: [PATCH 07/13] perf(fills): render the checkerboard functionally, at a caller's dtype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to one render body. The body no longer allocates a frame and writes four strided slices into it. A scatter is a materialized intermediate no compiler fuses away, and mutation put immutable-array backends out of contract. A frame has only three kinds of row — even tile row, odd tile row, and blank — so the raster is now three rows built at row width plus one per-row gather that selects among them. That also folds the region of interest into the gather. Masking it afterwards costs a second full-frame pass, which measured an order of magnitude more than the gather itself. 2160p on numpy goes from 44.4 ms to 2.9 ms, which is the first time this pattern fits a 60 Hz budget; on MPS, 3.5 ms to 2.4 ms eager and 0.30 ms compiled. Output dtype becomes a parameter, defaulting to uint16 so every existing caller is unaffected. A dtype that cannot carry the stated bit depth exactly is refused, which is what preserves exactness now that the type is not fixed — including float dtypes, whose exact-integer range comes from the epsilon both namespaces expose. The reason this matters is the fused path: uint16 has no Metal code-generation mapping, so a uint16 output cannot be compiled on MPS at all. Output is bit-identical to the strided body across 160 combinations of palette size, region origin and parity, region overhang, and frame size. Two of those cases are pinned as tests: parity registers to the region's origin, and a region reaching past the frame clips. --- display_patterns/patterns/_backend.py | 44 ++++++++-- display_patterns/patterns/_fills.py | 82 ++++++++++++++---- tests/test_frame_indexed_api.py | 120 +++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 26 deletions(-) diff --git a/display_patterns/patterns/_backend.py b/display_patterns/patterns/_backend.py index 96e80c1..258403c 100644 --- a/display_patterns/patterns/_backend.py +++ b/display_patterns/patterns/_backend.py @@ -6,16 +6,25 @@ The contract a namespace has to satisfy — recorded here because it is the answer to "would backend X work?": -- ``zeros``/``full``/``asarray`` accepting ``dtype`` (and ``device`` - when the caller passes one; creation calls omit the keyword - otherwise, so numpy's default signature works), -- dtype attributes ``uint16`` and ``float32``, -- **mutable arrays**: render bodies write strided slices in place, so - an immutable-array backend (JAX) is out of contract, +- ``zeros``/``full``/``asarray``/``arange`` accepting ``dtype`` (and + ``device`` when the caller passes one; creation calls omit the + keyword otherwise, so numpy's default signature works), +- ``where``, ``stack``, ``zeros_like``, ``reshape`` on the array, and + the arithmetic and comparison operators used to build a raster from + coordinate arrays, +- indexing an array with an integer index array (the per-row gather), +- ``iinfo``/``finfo`` for the dtype range check, +- dtype attributes ``uint16``, ``int32``, and ``float32``, - ``astype`` or ``to`` on the array for dtype conversion, and ``cpu`` where device-resident arrays cannot be read by numpy directly. + +Render bodies are functional (§spec:backend-portability): they build +results from broadcast arithmetic rather than writing strided slices +into an allocation, so a backend whose arrays are immutable is in +contract. """ +import math from typing import Any import numpy as np @@ -45,6 +54,29 @@ def asarray(xp: Any, values: Any, device: Any) -> Any: return xp.asarray(values) +def arange(xp: Any, stop: int, dtype: Any, device: Any) -> Any: + """``[0, stop)`` as an ``xp`` array, placed on ``device`` when the + caller supplies one.""" + if device is not None: + return xp.arange(stop, dtype=dtype, device=device) + return xp.arange(stop, dtype=dtype) + + +def max_exact_integer(xp: Any, dtype: Any) -> int: + """The largest integer ``dtype`` represents without loss. + + Integer dtypes report their own maximum. A float dtype represents + consecutive integers only up to two to the power of its mantissa + width plus one, which its epsilon states — both namespaces expose + ``finfo.eps`` where neither exposes a mantissa width. + """ + try: + return int(xp.iinfo(dtype).max) + except (TypeError, ValueError): + eps = float(xp.finfo(dtype).eps) + return 2 ** (round(-math.log2(eps)) + 1) + + def astype(array: Any, dtype: Any) -> Any: """``array`` converted to ``dtype`` — numpy's ``astype`` or torch's ``to``, whichever the array carries.""" diff --git a/display_patterns/patterns/_fills.py b/display_patterns/patterns/_fills.py index f876ecd..d41c237 100644 --- a/display_patterns/patterns/_fills.py +++ b/display_patterns/patterns/_fills.py @@ -58,6 +58,12 @@ def y2(self) -> int: return self.y + self.height +# Index of the blank row in the per-row kind table: a row outside the +# region of interest selects it, so the region is bounded by the same +# gather that lays the tile rather than by a second masking pass. +_BLANK_ROW = 2 + + class ColorRangeError(RuntimeError): """Exception raised when color values are outside the valid range. @@ -109,6 +115,23 @@ def _expand_colors(colors: ArrayLike, bit_depth: int) -> np.ndarray: return host +def _validate_dtype(xp: Any, dtype: Any, bit_depth: int) -> None: + """Refuse a dtype that cannot carry the stated bit depth exactly. + + Exactness is preserved by this check rather than by a fixed return + type (§spec:backend-portability), so the caller may state whatever + its value space and its backend's compiler need. + """ + required = 2**bit_depth - 1 + carried = _backend.max_exact_integer(xp, dtype) + if carried < required: + raise ValueError( + f"dtype {dtype} cannot represent {bit_depth}-bit code values: it " + f"carries integers exactly to {carried}, short of {required}. " + f"State a wider dtype." + ) + + def checkerboard( colors: ArrayLike, *, @@ -119,6 +142,7 @@ def checkerboard( frame: int = 0, xp: Any = np, device: Any = None, + dtype: Any = None, ) -> Any: """Render a checkerboard (or solid) fill at exact code values. @@ -149,11 +173,16 @@ def checkerboard( device : optional Device placement for ``xp`` backends that take one; ``None`` uses the backend default. + dtype : optional + Output dtype, defaulting to the namespace's ``uint16``. Any + dtype carrying ``bit_depth`` exactly is accepted; state a wider + one where a backend cannot compile ``uint16`` + (§spec:backend-portability). Returns ------- array - ``(height, width, 3)`` ``uint16`` array on ``xp``. + ``(height, width, 3)`` array on ``xp``, of ``dtype``. Raises ------ @@ -161,27 +190,42 @@ def checkerboard( If ``colors`` has an invalid shape. ColorRangeError If a color value falls outside the stated bit depth's range. + ValueError + If ``dtype`` cannot represent the stated bit depth exactly. """ del frame # a still ignores the frame index (§spec:render-model) expanded = _expand_colors(colors, bit_depth) if roi is None: roi = ROI(0, 0, width, height) - - # Write the tile colors directly into a target-dtype frame — ~3x - # cheaper than a palette gather at 1080p — and black outside the - # ROI comes free with the zero allocation. - image = _backend.zeros(xp, (height, width, 3), xp.uint16, device) - palette = _backend.astype(_backend.asarray(xp, expanded, device), xp.uint16) - - y_end = min(roi.y2, height) - x_end = min(roi.x2, width) - if bool(np.all(expanded == expanded[0])): - # One distinct color: a single contiguous write renders the solid. - image[roi.y : y_end, roi.x : x_end, :] = palette[0] - else: - image[roi.y : y_end : 2, roi.x : x_end : 2, :] = palette[0] - image[roi.y + 1 : y_end : 2, roi.x : x_end : 2, :] = palette[1] - image[roi.y : y_end : 2, roi.x + 1 : x_end : 2, :] = palette[2] - image[roi.y + 1 : y_end : 2, roi.x + 1 : x_end : 2, :] = palette[3] - return image + if dtype is None: + dtype = xp.uint16 + _validate_dtype(xp, dtype, bit_depth) + + # Built from broadcast arithmetic rather than written as strided slices + # into an allocation: a scatter is a materialized intermediate no + # compiler fuses away, and mutation puts immutable-array backends out + # of contract (§spec:backend-portability). + # + # A frame has only three kinds of row — even tile row, odd tile row, + # and blank — so the whole raster is three rows built at row width and + # one gather that selects among them per row. That keeps every + # elementwise pass at row scale and materializes the frame exactly + # once; masking the region of interest afterwards would cost a second + # full-frame pass, which measured an order of magnitude more than the + # gather. + palette = _backend.astype(_backend.asarray(xp, expanded, device), dtype) + cols = _backend.arange(xp, width, xp.int32, device).reshape(width, 1) + rows = _backend.arange(xp, height, xp.int32, device) + blank = _backend.zeros(xp, (1, 1), dtype, device) + + # Tile parity runs from the region's origin, so the pattern registers + # to the region rather than to the frame. + col_odd = (cols - roi.x) % 2 == 1 + col_inside = (cols >= roi.x) & (cols < min(roi.x2, width)) + even_row = xp.where(col_inside, xp.where(col_odd, palette[2], palette[0]), blank) + odd_row = xp.where(col_inside, xp.where(col_odd, palette[3], palette[1]), blank) + rows_by_kind = xp.stack([even_row, odd_row, xp.zeros_like(even_row)]) + + row_inside = (rows >= roi.y) & (rows < min(roi.y2, height)) + return rows_by_kind[xp.where(row_inside, (rows - roi.y) % 2, _BLANK_ROW)] diff --git a/tests/test_frame_indexed_api.py b/tests/test_frame_indexed_api.py index 213b359..6df3b54 100644 --- a/tests/test_frame_indexed_api.py +++ b/tests/test_frame_indexed_api.py @@ -12,7 +12,7 @@ import pytest from display_patterns import ROI, ColorRangeError, checkerboard -from tests.conftest import assert_backend_matches_numpy +from tests.conftest import assert_backend_matches_numpy, device_or_skip, to_host SOLID_12BIT = [[4095, 2048, 0]] @@ -57,6 +57,37 @@ def test_roi_bounds_the_pattern(self) -> None: np.testing.assert_array_equal(frame[5, 5], [255, 255, 255]) np.testing.assert_array_equal(frame[6, 6], [0, 0, 0]) + def test_tile_parity_registers_to_the_roi_origin(self) -> None: + """The tile's parity runs from the region's origin, not the + frame's, so an odd-origin region still starts on colour zero. + Pinned because the render builds rows by parity rather than + writing strided slices, and the two must agree.""" + roi = ROI(x=1, y=1, width=3, height=3) + frame = checkerboard( + [[10, 10, 10], [20, 20, 20]], width=5, height=5, bit_depth=8, roi=roi + ) + + np.testing.assert_array_equal( + frame[..., 0], + [ + [0, 0, 0, 0, 0], + [0, 10, 20, 10, 0], + [0, 20, 10, 20, 0], + [0, 10, 20, 10, 0], + [0, 0, 0, 0, 0], + ], + ) + + def test_a_roi_reaching_past_the_frame_is_clipped(self) -> None: + """A region wider than the frame renders to the frame's edge + rather than raising or wrapping.""" + roi = ROI(x=3, y=3, width=99, height=99) + frame = checkerboard([[7, 7, 7]], width=5, height=5, bit_depth=8, roi=roi) + + assert frame.shape == (5, 5, 3) + np.testing.assert_array_equal(frame[0, 0], [0, 0, 0]) + np.testing.assert_array_equal(frame[4, 4], [7, 7, 7]) + def test_out_of_range_color_raises(self) -> None: """Values above the stated bit depth raise the specific error.""" with pytest.raises(ColorRangeError): @@ -99,3 +130,90 @@ def test_checkerboard_renders_identically_under_torch() -> None: assert_backend_matches_numpy( checkerboard, SOLID_12BIT, width=32, height=32, bit_depth=12 ) + + +class TestCheckerboardDtype: + """Output dtype is the caller's, defaulting to the exact-integer + type the pattern has always returned (§spec:backend-portability).""" + + def test_defaults_to_uint16(self) -> None: + frame = checkerboard(SOLID_12BIT, width=8, height=8, bit_depth=12) + assert frame.dtype == np.uint16 + + def test_renders_at_a_caller_stated_dtype(self) -> None: + """A wider integer carries the code values just as exactly.""" + frame = checkerboard( + SOLID_12BIT, width=8, height=8, bit_depth=12, dtype=np.int32 + ) + assert frame.dtype == np.int32 + assert set(np.unique(frame)) == {0, 2048, 4095} + + def test_float_dtype_carrying_the_depth_exactly_is_accepted(self) -> None: + """float32 represents every 12-bit code value exactly.""" + frame = checkerboard( + SOLID_12BIT, width=8, height=8, bit_depth=12, dtype=np.float32 + ) + assert frame.dtype == np.float32 + assert set(np.unique(frame)) == {0.0, 2048.0, 4095.0} + + def test_rejects_a_dtype_too_narrow_for_the_bit_depth(self) -> None: + """uint8 cannot hold a 12-bit code value, so it is refused + rather than silently wrapping.""" + with pytest.raises(ValueError, match="cannot represent"): + checkerboard(SOLID_12BIT, width=8, height=8, bit_depth=12, dtype=np.uint8) + + def test_rejects_a_float16_too_narrow_for_the_bit_depth(self) -> None: + """float16 represents integers exactly only to 2048, short of a + 12-bit maximum.""" + with pytest.raises(ValueError, match="cannot represent"): + checkerboard(SOLID_12BIT, width=8, height=8, bit_depth=12, dtype=np.float16) + + +@pytest.mark.parametrize( + "device_name", + [ + pytest.param("cuda", marks=pytest.mark.cuda), + pytest.param("mps", marks=pytest.mark.mps), + ], +) +def test_checkerboard_renders_on_a_device(device_name: str) -> None: + """A checkerboard renders on a device at a caller-stated dtype + (§spec:backend-portability). MPS has no working uint16, so the + caller states int32 and the values arrive unchanged.""" + torch = pytest.importorskip("torch") + device = device_or_skip(device_name) + frame = checkerboard( + SOLID_12BIT, + width=32, + height=32, + bit_depth=12, + xp=torch, + device=device, + dtype=torch.int32, + ) + assert frame.device.type == device_name + assert set(np.unique(to_host(frame))) == {0, 2048, 4095} + + +@pytest.mark.mps +def test_a_caller_stated_dtype_is_what_compiles_on_mps() -> None: + """The dtype parameter is what keeps the fused path open on MPS. + + uint16 renders there in eager mode, but Inductor's Metal backend + has no uint16 in its dtype table, so a uint16 output cannot be + compiled. An int32 output can (§spec:backend-portability). This is + a property of the backend, not of this library — it is asserted + here so the reason the parameter exists stays checkable. + """ + torch = pytest.importorskip("torch") + device = device_or_skip("mps") + render = torch.compile(checkerboard, dynamic=False) + kwargs = {"width": 32, "height": 32, "bit_depth": 12, "xp": torch, "device": device} + + frame = render(SOLID_12BIT, dtype=torch.int32, **kwargs) + assert set(np.unique(to_host(frame))) == {0, 2048, 4095} + + with pytest.raises(Exception, match="uint16"): + torch.compile(checkerboard, dynamic=False)( + SOLID_12BIT, dtype=torch.uint16, **kwargs + ) From d7dc20e1172737514865c11d8472d10a4e731ee4 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:58:24 -0700 Subject: [PATCH 08/13] perf(counter-panel): extract bits arithmetically, from an array index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel looped over bit positions in Python and shifted a Python integer per bit. To dynamo that integer is a compile-time constant, so a consumer compiling its frame loop got a distinct graph per frame — measured, twelve frames produced eight graphs and then hit the recompile limit, after which dynamo abandons compilation for the call site entirely. A consumer's fused path silently became an eager one. The frame index is now array data and the bits come out with bitwise_right_shift over a per-column shift array. One hundred frames now produce one graph. The counter is bounded at 31 bits so the arithmetic stays in the signed 32-bit integers every backend supports; at 60 Hz that is over a year of counting. The raster follows the fills: two rows built at row width — panel band and blank — and one per-row gather. Cost, stated plainly: on numpy a 2160p panel goes from 4.9 ms to 6.5 ms. A functional body writes every row, where the old one wrote only the panel band into pages calloc had already zeroed; that is inherent to producing a dense array without mutation, not an oversight. Both sit inside a 60 Hz budget, and the path a GPU-resident consumer actually runs improves far more: 0.62 ms compiled on MPS, against a path that previously could not be compiled at all. Output is bit-identical to the previous body across 640 combinations of frame size, counter width, and frame index, each still round-tripping through decode_counter. --- display_patterns/patterns/_counter_panel.py | 70 ++++++++++++++++----- tests/test_counter_panel.py | 56 ++++++++++++++++- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/display_patterns/patterns/_counter_panel.py b/display_patterns/patterns/_counter_panel.py index f5caf3a..b47a802 100644 --- a/display_patterns/patterns/_counter_panel.py +++ b/display_patterns/patterns/_counter_panel.py @@ -44,6 +44,17 @@ _CELL_ON = 1.0 _DECODE_THRESHOLD = 0.5 +# Upper bound on the counter's width. Bit extraction runs in signed +# 32-bit integers, the widest every backend supports without qualification +# (§spec:backend-portability). At 60 Hz a 31-bit counter runs over a year +# before wrapping, so the bound costs no real counter anything. +_MAX_BITS = 31 + +# Row kinds in the per-row gather that lays the panel: a frame row either +# falls inside the panel's band or is blank. +_BLANK_ROW = 0 +_PANEL_ROW = 1 + @dataclass(frozen=True) class PanelGeometry: @@ -66,13 +77,15 @@ def for_frame(cls, width: int, height: int, bits: int) -> "PanelGeometry": Raises ------ ValueError - If a dimension falls outside [1, 16384], or the frame is too - small to give each cell at least one pixel (a sub-pixel cell - renders an undecodable panel). + If ``bits`` falls outside [1, 31], a dimension falls outside + [1, 16384], or the frame is too small to give each cell at + least one pixel (a sub-pixel cell renders an undecodable + panel). """ - if bits < 1: + if not 1 <= bits <= _MAX_BITS: raise ValueError( - f"bits {bits} is out of range: a counter needs at least one bit-cell." + f"bits {bits} is out of range: a counter carries between 1 and " + f"{_MAX_BITS} bit-cells." ) for label, value in (("width", width), ("height", height)): if not 1 <= value <= _MAX_DIMENSION: @@ -155,21 +168,44 @@ def render_counter_panel( 1 over the panel's bounding box and 0 outside it, so a composite touches only the panel. """ - overlay = _backend.zeros(xp, geometry.overlay_shape, xp.float32, device) - mask = _backend.zeros(xp, geometry.mask_shape, xp.float32, device) + bits = geometry.bits + if isinstance(frame, int): + # Wrap host-side, exactly: a Python integer is unbounded, and only + # the low ``bits`` bits are read. + frame = frame & ((1 << bits) - 1) + counter = _backend.astype(_backend.asarray(xp, frame, device), xp.int32) r0, r1 = geometry.panel_rows c0, c1 = geometry.panel_cols - mask[r0:r1, c0:c1] = _CELL_ON - - bits = geometry.bits - for index in range(bits): - # MSB-first: bit 0 is the most-significant, so a wider counter - # reads left-to-right like a written binary number. - if (frame >> (bits - 1 - index)) & 1: - cell_c0 = geometry.pad_x + index * geometry.cell_w - overlay[r0:r1, cell_c0 : cell_c0 + geometry.cell_w, :] = _CELL_ON - return overlay, mask + cols = _backend.arange(xp, geometry.width, xp.int32, device) + rows = _backend.arange(xp, geometry.height, xp.int32, device) + + # Which bit-cell each column falls in, and so which bit it shows. + # MSB-first: cell 0 is the most-significant, so a wider counter reads + # left-to-right like a written binary number. + in_panel_cols = (cols >= c0) & (cols < c1) + cell = (cols - geometry.pad_x) // geometry.cell_w + # Columns outside the panel would shift by an out-of-range amount, + # which is undefined; park them at zero and mask them out after. + shift = xp.where(in_panel_cols, bits - 1 - cell, 0) + lit = xp.bitwise_right_shift(counter, shift) & 1 + + # A frame has two kinds of row — inside the panel's band, or blank — + # so the raster is two rows built at row width and one gather that + # selects between them per row, materializing each output once + # (§spec:backend-portability). + # Shaped (1, 3) so the where broadcasts each column across the + # three channels, giving a (width, 3) row. + on = _backend.full(xp, (1, 3), _CELL_ON, xp.float32, device) + off = _backend.zeros(xp, (1, 3), xp.float32, device) + panel_row = xp.where((in_panel_cols & (lit == 1))[:, None], on, off) + overlay_rows = xp.stack([xp.zeros_like(panel_row), panel_row]) + mask_row = xp.where(in_panel_cols, _CELL_ON, 0.0) + mask_row = _backend.astype(mask_row, xp.float32) + mask_rows = xp.stack([xp.zeros_like(mask_row), mask_row]) + + row_kind = xp.where((rows >= r0) & (rows < r1), _PANEL_ROW, _BLANK_ROW) + return overlay_rows[row_kind], mask_rows[row_kind] def decode_counter(overlay: Any, geometry: PanelGeometry) -> int: diff --git a/tests/test_counter_panel.py b/tests/test_counter_panel.py index c90a0c9..3e382ef 100644 --- a/tests/test_counter_panel.py +++ b/tests/test_counter_panel.py @@ -17,6 +17,7 @@ assert_backend_matches_numpy, decode_on_device, device_or_skip, + torch_or_skip, ) @@ -137,5 +138,58 @@ def test_round_trip_on_a_device(device_name: str) -> None: def test_geometry_rejects_a_zero_bit_counter() -> None: """A counter needs at least one bit-cell; ``bits=0`` is rejected with a range error rather than a division crash.""" - with pytest.raises(ValueError, match="at least one"): + with pytest.raises(ValueError, match="between 1 and"): PanelGeometry.for_frame(width=64, height=16, bits=0) + + +class TestFrameIndexAsData: + """A temporal pattern's frame index is array data, not a Python + integer (§spec:backend-portability).""" + + def test_accepts_a_zero_dimensional_array(self) -> None: + """A 0-d array frame index renders what the equivalent integer + renders.""" + geom = _geometry(bits=8) + from_int, _ = render_counter_panel(42, geom) + from_array, _ = render_counter_panel(np.asarray(42), geom) + + np.testing.assert_array_equal(from_array, from_int) + + def test_wraps_modulo_the_counter_width(self) -> None: + """The counter wraps at 2**bits, so a frame index past the + wrap encodes its remainder.""" + geom = _geometry(bits=8) + wrapped, _ = render_counter_panel(256 + 42, geom) + direct, _ = render_counter_panel(42, geom) + + np.testing.assert_array_equal(wrapped, direct) + assert decode_counter(wrapped, geom) == 42 + + def test_geometry_rejects_a_counter_wider_than_31_bits(self) -> None: + """Bit extraction runs in the signed 32-bit integers every + backend supports, so the counter is bounded at 31 bits.""" + with pytest.raises(ValueError, match="31"): + PanelGeometry.for_frame(width=4096, height=256, bits=32) + + +def test_compiles_once_over_many_frames() -> None: + """Stepping the frame index must not recompile the pattern. + + A Python integer is a compile-time constant to dynamo, so the + previous per-bit Python branch produced a distinct graph per frame + and, past the recompile limit, made dynamo abandon compilation for + the call site entirely (§spec:backend-portability). Uses the eager + backend: recompilation is a guard property, and this keeps the test + off a C++ toolchain. + """ + torch = torch_or_skip() + import torch._dynamo as dynamo + + geom = PanelGeometry.for_frame(width=256, height=64, bits=8) + dynamo.reset() + dynamo.utils.counters.clear() + compiled = torch.compile(render_counter_panel, backend="eager") + for frame in range(100): + compiled(torch.tensor(frame, dtype=torch.int32), geom, xp=torch) + + assert dynamo.utils.counters["stats"]["unique_graphs"] == 1 From 0d347869c0e66733ff5bea1eb0edb1f1d7856c63 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:58:43 -0700 Subject: [PATCH 09/13] fix(counter-panel): widen the frame index annotation to array data --- display_patterns/patterns/_counter_panel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/display_patterns/patterns/_counter_panel.py b/display_patterns/patterns/_counter_panel.py index b47a802..ff8983e 100644 --- a/display_patterns/patterns/_counter_panel.py +++ b/display_patterns/patterns/_counter_panel.py @@ -139,7 +139,7 @@ def cell_centre(self, index: int) -> tuple[int, int]: def render_counter_panel( - frame: int, geometry: PanelGeometry, *, xp: Any = np, device: Any = None + frame: int | Any, geometry: PanelGeometry, *, xp: Any = np, device: Any = None ) -> tuple[Any, Any]: """Render frame index ``frame`` into a counter panel: ``(overlay, mask)``. @@ -149,8 +149,12 @@ def render_counter_panel( Parameters ---------- - frame : int - Frame index to encode, MSB-first. + frame : int or array + Frame index to encode, MSB-first. A zero-dimensional array is + accepted and preferred by a consumer compiling its frame loop: + a Python integer is a compile-time constant, so stepping one + recompiles the pattern (§spec:backend-portability). Wraps + modulo ``2**geometry.bits``. geometry : PanelGeometry Cell layout; build with :meth:`PanelGeometry.for_frame`. xp : namespace, optional From 76616d16c88c33b851870f58e83c6ecce8852c47 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:59:36 -0700 Subject: [PATCH 10/13] docs: drop the link to a private repository The README linked color-wrangler by URL and SPEC.md named it twice. That repository is private and this one is public, so the link published both the name and the fact of it. The README's "Related" line now points at the public siblings that measure, report on, and document surface characterization, which is what a reader of this repository can actually open. The spec names the umbrella by role where its ownership boundary still needs stating. Checked the rest: ocio-display-gen, pydecklink, bmd-signal-gen, display-measure, display-report and methodology are all public and stay named. --- README.md | 7 +++++-- SPEC.md | 9 +++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 71aed60..7ed97d6 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,11 @@ Used by [bmd-signal-gen](https://github.com/OpenDisplayEval/bmd-signal-gen), which sends the rendered patterns to a display over SDI or HDMI. Related: -[color-wrangler](https://github.com/Fuse-Technical-Group/color-wrangler) -(LED-surface characterization umbrella) and its component repos. +[display-measure](https://github.com/OpenDisplayEval/display-measure), +[display-report](https://github.com/OpenDisplayEval/display-report), and +[methodology](https://github.com/OpenDisplayEval/methodology), which +measure, report on, and document the characterization of a display +surface. ## Installation diff --git a/SPEC.md b/SPEC.md index 5e401d0..966f4a9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -168,7 +168,8 @@ that rationale in its `§spec:verification`). Out of scope, with their owners: device output and signaling (bmd-signal-gen, pydecklink); playback, clocks, and frame pacing (consumers' runtimes); color management and display characterization -(ocio-display-gen, color-wrangler); instrument I/O and measurement -sessions (color-wrangler, colour-specio); runtime graph integration -(consumers' render runtimes). The library defines the mapping from -parameters to image and nothing on either side of it. +(ocio-display-gen); instrument I/O and measurement sessions +(colour-specio, and the surface-characterization umbrella that drives +it); runtime graph integration (consumers' render runtimes). The +library defines the mapping from parameters to image and nothing on +either side of it. From 5b5ff57f0549d3bc6c17d1f2dd188d43ebacce40 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:00:48 -0700 Subject: [PATCH 11/13] docs(backends): state on the public surface what the caller must choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the decisions in this section are invisible from the API: that a frame index should arrive as array data, and that the default uint16 output cannot be compiled on MPS. A consumer hits both as a silent performance cliff — dynamo quietly falling back to eager, or an Inductor KeyError — with nothing in the signature to suggest why. The README gains a Backends section carrying both, with the code to do it right, and the catalog's module docstring carries the same two points where a reader lands from the import. Marks §spec:backend-portability complete and removes the four shipped workstreams from the roadmap. The rendering model returns to complete: its dtype sentence now describes what the code does. --- README.md | 51 +++++++++++++++++++++++-- ROADMAP.md | 54 --------------------------- SPEC.md | 4 +- display_patterns/patterns/__init__.py | 18 +++++++++ 4 files changed, 68 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 7ed97d6..4f406b5 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,11 @@ property measurement work depends on — and the render path never touches a device, a clock, or a file. The core depends on numpy alone and renders into a caller-supplied -array namespace (numpy or torch), so it serves CLI signal generators -and GPU-resident render graphs alike. Chart authoring and TIFF export -install as extras. +array namespace (numpy or torch, CPU or GPU), so it serves CLI signal +generators and GPU-resident render graphs alike. Render bodies are +functional and free of Python-level branching on frame data, so a +consumer can compile them into its own graph. Chart authoring and TIFF +export install as extras. Used by [bmd-signal-gen](https://github.com/OpenDisplayEval/bmd-signal-gen), @@ -53,6 +55,11 @@ frame = checkerboard( # frame = checkerboard(..., xp=torch, device="cuda") ``` +`dtype` states the output type, `uint16` by default. Any type that +carries the stated bit depth exactly is accepted; one that cannot is +refused rather than silently wrapping. See [Backends](#backends) for +when to state it. + Encode a frame counter as machine-readable bit-cells and decode it back — render one end of a video chain, measure latency and frame skew at the other: @@ -65,6 +72,44 @@ overlay, mask = render_counter_panel(1234, geometry) # float32 [0, 1], HWC assert decode_counter(overlay, geometry) == 1234 ``` +The counter carries 1 to 31 bits and wraps at `2**bits` — 31 bits runs +over a year at 60 Hz. Pass the frame index as a zero-dimensional array +rather than a Python integer when compiling (see below). + +## Backends + +Every core pattern renders through the `xp` namespace with an optional +`device`, and returns the same values on each. Two things are worth +knowing before putting one inside a compiled graph. + +**Pass the frame index as array data.** A Python integer is a +compile-time constant, so stepping one recompiles the pattern on every +frame — and past the recompile limit `torch.compile` gives up on the +call site and falls back to eager for good: + +```python +counter = torch.tensor(frame_number, dtype=torch.int32, device=device) +overlay, mask = render_counter_panel(counter, geometry, xp=torch, device=device) +``` + +**State a dtype your backend can compile.** `uint16` renders correctly +on MPS in eager mode, but Inductor's Metal code generator has no +mapping for it, so a `uint16` output cannot be compiled there at all. +An `int32` or `float32` output can, and carries 12-bit code values just +as exactly: + +```python +frame = checkerboard(colors, ..., xp=torch, device=device, dtype=torch.int32) +``` + +Which types a backend supports is the backend's business and moves +between its releases, which is why the library takes the dtype from the +caller rather than fixing one. + +Verified on numpy and on torch's CPU build in CI; on CUDA and MPS by +`pytest -m cuda` and `pytest -m mps`, which are deselected by default +and run where the hardware is. + With the `charts` and `io` extras, author a chart in YAML, render it, and write a 16-bit TIFF: diff --git a/ROADMAP.md b/ROADMAP.md index a82e884..f40ecfd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,57 +25,3 @@ imports and renders a checkerboard with numpy alone; `pip install writes it to TIFF. (The array-for-array equivalence claim against bmd-signal-gen is banked and pinned in §spec:extraction; its scaffolding retired with the rendering-model reshape.) - -## Fused backend rendering §road:fused-backends - -Make the multi-backend claim true and checkable: render bodies a -consumer's compiler can fuse, an output dtype that works on the -backend it develops on, and the torch leg running on every change -instead of skipping (§spec:backend-portability). A render-runtime -consumer repoints its duplicated counter-panel math here once these -land; its adoption is tracked in its own roadmap. - -### Torch as a gate, not a skip §road:torch-gate - -Add torch's CPU build to the dev dependency group and register `cuda` -and `mps` markers, so the backend-equivalence legs in -`tests/conftest.py` run on every change and the device legs are opt-in -— `pyproject.toml`, `tests/conftest.py`, `.github/workflows/ci.yml`. -§spec:backend-portability. - -### Functional fills at a caller-stated dtype §road:functional-fills - -Render `checkerboard` from broadcast arithmetic rather than strided -writes into a zero buffer, and take the output dtype as a parameter -defaulting to `uint16`, rejecting one that cannot hold the stated bit -depth — `display_patterns/patterns/_fills.py`, -`display_patterns/patterns/_backend.py`. Depends on §road:torch-gate. -§spec:backend-portability. - -### Counter panel on an array frame index §road:functional-counter - -Render the counter panel from broadcast arithmetic over bit positions -with `frame` accepted as a scalar or zero-dimensional array, bounding -the counter at 31 bits — -`display_patterns/patterns/_counter_panel.py`. Depends on -§road:torch-gate. §spec:backend-portability. - -### Backend support on the public surface §road:backend-surface - -State the supported backends, the dtype a caller passes on each, and -the counter's bit bound in the package's README and entry-point -docstrings, and record the measured device legs — `README.md`, -`display_patterns/patterns/__init__.py`. Depends on -§road:functional-fills and §road:functional-counter. -§spec:backend-portability. - -**Verify:** In a fresh environment with torch installed, `pytest` -passes with no skipped backend legs. On an Apple-silicon host, -`pytest -m mps` renders a checkerboard and a counter panel on -`torch.device("mps")` — the checkerboard at a caller-stated `int32`, -since `uint16` is rejected there — and `decode_counter` recovers the -encoded index from the MPS-resident overlay. On a CUDA host, -`pytest -m cuda` does the same on `torch.device("cuda")`. Wrapping a -`render_counter_panel` call in `torch.compile` and stepping the frame -index over a hundred frames compiles once, not once per frame. The -numpy path renders the same arrays as before these changes. diff --git a/SPEC.md b/SPEC.md index 8558a8e..5fcb4b3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -48,7 +48,7 @@ display. ## Rendering model §spec:render-model -*Status: in progress* +*Status: complete* A pattern is a pure function: parameters (geometry, values) and a frame index in, an array out. The same inputs produce identical @@ -129,7 +129,7 @@ model and carries a decode side when one is meaningful. ## Backend portability §spec:backend-portability -*Status: not started* +*Status: complete* The core catalog renders the same values on every backend a consumer brings: numpy on a CPU host, torch on CUDA, torch on Apple's MPS diff --git a/display_patterns/patterns/__init__.py b/display_patterns/patterns/__init__.py index cbda1ac..af41b08 100644 --- a/display_patterns/patterns/__init__.py +++ b/display_patterns/patterns/__init__.py @@ -6,6 +6,24 @@ placement. Stills ignore the frame index; temporal patterns use it as their only time source. The render path performs no I/O and holds no state. + +Render bodies are functional — built from broadcast arithmetic rather +than written into an allocation — and branch on no array contents, so a +consumer may compile one into its own graph (§spec:backend-portability). +Two caller choices decide whether that compiles well: + +- Pass a temporal pattern's frame index as array data. A Python integer + is a compile-time constant, so stepping one recompiles the pattern + every frame, and past the recompile limit the compiler abandons the + call site entirely. +- State an output ``dtype`` the backend can compile. ``uint16`` is the + default and renders on every backend in eager mode, but has no Metal + code-generation mapping, so a ``uint16`` output cannot be compiled on + MPS; ``int32`` and ``float32`` can, and carry the same code values + exactly. + +The library never compiles anything itself — that belongs to the +consumer's graph (§spec:non-goals). """ from display_patterns.patterns._counter_panel import ( From 9b2af2b6618ef696cfa9db4daa31615149f87951 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:02:05 -0700 Subject: [PATCH 12/13] refactor(patterns): index the blank row alike in both render bodies --- display_patterns/patterns/_fills.py | 15 +++++++++------ tests/conftest.py | 8 +++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/display_patterns/patterns/_fills.py b/display_patterns/patterns/_fills.py index d41c237..b6d3366 100644 --- a/display_patterns/patterns/_fills.py +++ b/display_patterns/patterns/_fills.py @@ -58,10 +58,12 @@ def y2(self) -> int: return self.y + self.height -# Index of the blank row in the per-row kind table: a row outside the -# region of interest selects it, so the region is bounded by the same -# gather that lays the tile rather than by a second masking pass. -_BLANK_ROW = 2 +# Row kinds in the per-row gather that lays the frame. A row outside the +# region of interest selects the blank kind, so the region is bounded by +# the same gather that lays the tile rather than by a second masking +# pass. Blank is index 0 here as it is in the counter panel. +_BLANK_ROW = 0 +_FIRST_TILE_ROW = 1 class ColorRangeError(RuntimeError): @@ -225,7 +227,8 @@ def checkerboard( col_inside = (cols >= roi.x) & (cols < min(roi.x2, width)) even_row = xp.where(col_inside, xp.where(col_odd, palette[2], palette[0]), blank) odd_row = xp.where(col_inside, xp.where(col_odd, palette[3], palette[1]), blank) - rows_by_kind = xp.stack([even_row, odd_row, xp.zeros_like(even_row)]) + rows_by_kind = xp.stack([xp.zeros_like(even_row), even_row, odd_row]) row_inside = (rows >= roi.y) & (rows < min(roi.y2, height)) - return rows_by_kind[xp.where(row_inside, (rows - roi.y) % 2, _BLANK_ROW)] + tile_row = _FIRST_TILE_ROW + (rows - roi.y) % 2 + return rows_by_kind[xp.where(row_inside, tile_row, _BLANK_ROW)] diff --git a/tests/conftest.py b/tests/conftest.py index 18b72ff..66acaf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,7 +36,13 @@ def device_or_skip(name: str) -> Any: def to_host(array: Any) -> np.ndarray: - """``array`` as a host numpy array, wherever it was rendered.""" + """``array`` as a host numpy array, wherever it was rendered. + + Deliberately not the library's own ``_backend.to_host``: comparing + a rendered array through the same transfer helper the render path + used would hide a fault in that helper from every test that relies + on it. + """ if hasattr(array, "cpu"): array = array.cpu() return np.asarray(array) From 3902a19411f6058b994cfac79a860dd3dcff2d14 Mon Sep 17 00:00:00 2001 From: ritchie <4462072+repentsinner@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:24:15 -0700 Subject: [PATCH 13/13] docs: name the upstream as a renderer, nothing narrower MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "A real-time LED render runtime" was meant as a role description, but it is specific enough to work as an identifier: real-time, LED, and a render runtime together describe a very small set. That defeats the point of not naming the repository. "A renderer" carries what a reader of this repository actually needs — that the counter-panel math came from a consumer with a frame loop, not from this library's own design — and narrows to nothing. --- REQUIREMENTS.md | 10 +++++----- SPEC.md | 12 ++++++------ display_patterns/patterns/_counter_panel.py | 10 +++++----- tests/test_counter_panel.py | 13 ++++++------- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 043a1a3..2befea2 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -15,10 +15,10 @@ real-time render graphs, and measurement pipelines. Test-pattern math lives trapped inside device tools. bmd-signal-gen's checkerboard and chart generation imports nothing from its DeckLink layer, yet a consumer who wants only the patterns installs the whole -device tool. A real-time LED render runtime re-derived its own -frame-counter panel math because no importable source existed. Each -new consumer rewrites geometry that is pure, deterministic, and -identical across delivery paths. +device tool. A renderer re-derived its own frame-counter panel math +because no importable source existed. Each new consumer rewrites +geometry that is pure, deterministic, and identical across delivery +paths. General imaging libraries fall short in the other direction: they carry color-management and encoding opinions and offer no measurement @@ -116,7 +116,7 @@ Essential, in adoption order: extras) with bit-identical output. 3. Frame-indexed rendering signature on every pattern. 4. Temporal-alignment counter panel, encode and decode (ported from - a real-time LED render runtime's probe math). + a renderer's probe math). Nice-to-have, after adoption: diff --git a/SPEC.md b/SPEC.md index 5fcb4b3..2a133f0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,9 +10,9 @@ system does and why. See ROADMAP.md for work remaining. Display test-pattern math lives trapped inside device tools (§req:problem-statement). bmd-signal-gen's pattern and chart modules import nothing from its DeckLink layer yet ship only inside the device -tool; a real-time LED render runtime re-derived frame-counter panel -math for want of an importable source. General imaging libraries carry -color-management opinions and cannot make a measurement claim: driving +tool; a renderer re-derived frame-counter panel math for want of an +importable source. General imaging libraries carry color-management +opinions and cannot make a measurement claim: driving *exact* integer code values at a stated bit depth is the point, and a library that rescales or quantizes behind the caller's back defeats it. @@ -108,9 +108,9 @@ The core catalog, renderable with numpy alone (§req:success-criteria): back out of the other (§req:user-stories). The geometry is deterministic, so encoder and decoder agree on every cell from parameters alone, and decode thresholds at the cell midpoint to - survive a lossy chain (§req:success-criteria). Ported from the - alignment-probe math of a real-time LED render runtime, which - retires its bespoke node in favor of generic primitives. + survive a lossy chain (§req:success-criteria). Ported from a + renderer's alignment-probe math, which retires its bespoke node in + favor of generic primitives. The `charts` extra adds chart production (§req:user-stories): a chart is authored as a YAML patch list carrying colorimetric values, diff --git a/display_patterns/patterns/_counter_panel.py b/display_patterns/patterns/_counter_panel.py index ff8983e..99c38b4 100644 --- a/display_patterns/patterns/_counter_panel.py +++ b/display_patterns/patterns/_counter_panel.py @@ -2,11 +2,11 @@ The frame index is rendered as binary bit-cells in one title-safe row, MSB-first, with the matching decoder (§spec:catalog). Geometry is -deterministic — encoder and decoder agree on every cell from -parameters alone — and decode samples each cell's centre, thresholding -at the value midpoint, so the counter survives a lossy video chain. -Ported from the alignment-probe math of a real-time LED render runtime -onto the frame-indexed namespace signature. +deterministic — encoder and decoder agree on every cell from parameters +alone — and decode samples each cell's centre, thresholding at the value +midpoint, so the counter survives a lossy video chain. Ported from a +renderer's alignment-probe math onto the frame-indexed namespace +signature. Value and layout conventions (§spec:render-model): this is a float pattern; the overlay is ``(height, width, 3)`` float32 in [0, 1] — a diff --git a/tests/test_counter_panel.py b/tests/test_counter_panel.py index 3e382ef..0adcc83 100644 --- a/tests/test_counter_panel.py +++ b/tests/test_counter_panel.py @@ -1,13 +1,12 @@ """Temporal-alignment counter panel codec (§spec:catalog). Encode and decode ship together: a panel rendered at frame N decodes -back to N from the array, and after a lossy trip, because decode -samples each cell's centre and thresholds at the value midpoint. The -math is ported from the alignment probe of a real-time LED render -runtime onto the frame-indexed namespace signature; these tests carry the -numpy leg, with a torch leg (the only one reaching the backend's -device branches) that skips where torch is absent. -""" +back to N from the array, and after a lossy trip, because decode samples +each cell's centre and thresholds at the value midpoint. The math is +ported from a renderer's alignment probe onto the frame-indexed +namespace signature; these tests carry the numpy leg, with a torch leg +(the only one reaching the backend's device branches) that skips where +torch is absent.""" import numpy as np import pytest