diff --git a/README.md b/README.md index 71aed60..4f406b5 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,21 @@ 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), 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 @@ -50,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: @@ -62,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/REQUIREMENTS.md b/REQUIREMENTS.md index 14dac68..2befea2 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. backlit_molecule 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 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 - backlit_molecule's probe math). + a renderer's probe math). Nice-to-have, after adoption: diff --git a/SPEC.md b/SPEC.md index 54cd891..2a133f0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,11 +10,12 @@ 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 -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. +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. display-patterns is that importable source: deterministic pattern math, device-free, exact by construction. bmd-signal-gen's SPEC @@ -80,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 @@ -106,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 - 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 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, @@ -125,6 +127,69 @@ 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: 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 +(§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 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: +`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:** 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 +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* @@ -167,7 +232,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 -(backlit_molecule). 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. 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 ( 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/_counter_panel.py b/display_patterns/patterns/_counter_panel.py index b36c228..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 backlit_molecule's probe math (its ``§spec:alignment-probe``) -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 @@ -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: @@ -126,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)``. @@ -136,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 @@ -155,21 +172,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/display_patterns/patterns/_fills.py b/display_patterns/patterns/_fills.py index 06f4e48..b6d3366 100644 --- a/display_patterns/patterns/_fills.py +++ b/display_patterns/patterns/_fills.py @@ -58,6 +58,14 @@ def y2(self) -> int: return self.y + self.height +# 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): """Exception raised when color values are outside the valid range. @@ -94,7 +102,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: @@ -107,6 +117,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, *, @@ -117,6 +144,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. @@ -147,11 +175,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 ------ @@ -159,27 +192,43 @@ 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([xp.zeros_like(even_row), even_row, odd_row]) + + row_inside = (rows >= roi.y) & (rows < min(roi.y2, height)) + tile_row = _FIRST_TILE_ROW + (rows - roi.y) % 2 + return rows_by_kind[xp.where(row_inside, tile_row, _BLANK_ROW)] 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..66acaf9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,21 +7,78 @@ 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. + + 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) + + +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 13f2238..0adcc83 100644 --- a/tests/test_counter_panel.py +++ b/tests/test_counter_panel.py @@ -1,19 +1,23 @@ """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 backlit_molecule's probe (its ``§spec: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. -""" +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 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, + torch_or_skip, +) def _geometry(bits: int) -> PanelGeometry: @@ -112,8 +116,79 @@ 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.""" - 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 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 + ) 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"