From b8dbe0afe70d07a0e719c03338f99a6b74e6ef6a Mon Sep 17 00:00:00 2001 From: Noel Ivan Ulloa <127597880+noelEOS@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:02:08 +0800 Subject: [PATCH 1/4] Add global average pooling and an elementwise multiply to the raster lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two raster blocks, Fixed — offline only: global_avg_pool2d, the mean of the whole map onto its one-pixel grid through the private helper pool2d's aligned branch now uses (byte-identical), and multiply, a same-grid product or a one-pixel gate pooled from the map. compile_model recognizes AdaptiveAvgPool2d / F.adaptive_avg_pool2d to size 1, mean over (2, 3) with keepdim=True, and *, torch.mul and Tensor.mul in either operand order. The receptive-field analysis lists a global mean in extent_nodes and refuses every halo, so tiled compilation refuses the model. Mirrors, integer-generated fixtures (one on a 128 x 128 map, by a per-fixture request-grid allowance), tests and the catalogue. --- README.md | 2 +- docs/api.md | 14 + docs/building-blocks.md | 16 +- docs/library-contract.md | 2 +- docs/live-fixtures.md | 3 +- tensor_ee/blocks/raster/__init__.py | 10 +- tensor_ee/blocks/raster/merge.py | 63 +++- tensor_ee/blocks/raster/pooling.py | 68 +++- tensor_ee/compatibility.py | 6 + tensor_ee/compiler.py | 28 +- tensor_ee/core/grid.py | 10 + tensor_ee/receptive_field.py | 29 +- tensor_ee/semantics.py | 96 ++++++ tests/reference/__init__.py | 3 +- tests/reference/activations.py | 22 +- tests/reference/raster_gate.py | 37 +++ tests/test_live_fixtures.py | 53 +++- tests/test_raster_blocks.py | 15 +- tests/test_raster_global_pool_multiply.py | 365 ++++++++++++++++++++++ tests/test_receptive_field.py | 5 +- tests/test_x02b_validated_settings.py | 7 +- tools/fixtures/core.py | 30 +- tools/fixtures/raster.py | 267 +++++++++++++++- tools/live_fixtures.py | 7 +- 24 files changed, 1104 insertions(+), 54 deletions(-) create mode 100644 tests/reference/raster_gate.py create mode 100644 tests/test_raster_global_pool_multiply.py diff --git a/README.md b/README.md index 0a7b927..53793ff 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ graphs byte for byte; numerical and live evidence have narrower scope, detailed | SMP DPT + timm ViT-B/16, 224 × 224 | [DPT conversion](https://github.com/noelEOS/tensor-ee/blob/main/docs/converter.md#smp-050-dpt-at-224--224) | Random weights; full-width graph identity; full-width logits meet the 1e-4 target offline only; live evidence covers reduced twins | You can also compose a model from the [block catalogue](https://github.com/noelEOS/tensor-ee/blob/main/docs/building-blocks.md). -It has 88 blocks: 23 in `tensor_ee/blocks/raster` and 65 in `tensor_ee/blocks/window`. +It has 90 blocks: 25 in `tensor_ee/blocks/raster` and 65 in `tensor_ee/blocks/window`. Recent additions include grouped/depthwise convolutions, bilinear upsampling, standalone BatchNorm and input standardization. These expand the building blocks; whole-model support still depends on the compiler or converter. diff --git a/docs/api.md b/docs/api.md index 1f03bfb..1d583af 100644 --- a/docs/api.md +++ b/docs/api.md @@ -85,6 +85,20 @@ field reports local bounds but refuses a halo guarantee for tiled execution. Merges require identical spatial grids, even when dimensions happen to match; residual addition rejects broadcasting. +Global average pooling (`nn.AdaptiveAvgPool2d` and `F.adaptive_avg_pool2d` to output size +1, `(1, 1)` or `[1, 1]`, and `torch.mean`/`Tensor.mean` over dims `(2, 3)` with +`keepdim=True` and no `dtype`) uses the `global_avg_pool2d` block: the mean of the whole +compiled grid, a `[N, C, 1, 1]` tensor on a one-pixel grid that covers exactly the map. +1 × 1 convolutions and pointwise activations run on that grid unchanged. Elementwise +multiplication (`*`, `torch.mul`, `Tensor.mul`) uses the `multiply` block: two tensors on +one grid with equal channel counts, or a map and a one-pixel gate pooled from that same map, +in either operand order (squeeze-and-excitation's `sigmoid(g) * x`). Adaptive pools to other +sizes, max pools to one pixel, `mean` without `keepdim` or over other dims, scalar products +and channel broadcasts (`[N, 1, H, W]`) are refused. A global mean depends on the whole chip: +the result is per compiled grid, `analyze_receptive_field` lists it in `extent_nodes` and +refuses a halo, and tiled compilation refuses the model. Both blocks have offline evidence +only. + Model and submodules must already be in eval mode. Computational weights, biases and BatchNorm statistics are finite float32 with declared shapes; nonfloating bookkeeping counters are allowed. BatchNorm folds into an unshared diff --git a/docs/building-blocks.md b/docs/building-blocks.md index 929d7b4..fca831d 100644 --- a/docs/building-blocks.md +++ b/docs/building-blocks.md @@ -101,18 +101,18 @@ their own. ## Blocks -88 blocks: 23 in `tensor_ee/blocks/raster` and 65 in `tensor_ee/blocks/window`. +90 blocks: 25 in `tensor_ee/blocks/raster` and 65 in `tensor_ee/blocks/window`. | Layout in → out | Ready | Fixed | Total | |---|---:|---:|---:| -| R → R | 23 | 0 | 23 | +| R → R | 23 | 2 | 25 | | R → W | 2 | 1 | 3 | | W → R | 0 | 1 | 1 | | W → W | 23 | 33 | 56 | | W → V | 2 | 0 | 2 | | V → W | 1 | 0 | 1 | | V → V | 1 | 1 | 2 | -| **All** | **52** | **36** | **88** | +| **All** | **52** | **38** | **90** | ### Raster-layout (R) blocks @@ -136,11 +136,13 @@ their own. | [`conv2d`](../tensor_ee/blocks/raster/conv.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/primitives_float32.json](history/cnn/results/primitives_float32.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json); [cnn/results/tiled_resnet18_2x2_parity.json](history/cnn/results/tiled_resnet18_2x2_parity.json) | | [`concatenate`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | | [`residual_add`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | +| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; clip_footprints=False | `docs/evidence/raster-global-pool-multiply-offline.json` (unresolved) | | [`batch_norm`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/raster-batch-norm-offline.json](evidence/raster-batch-norm-offline.json); [evidence/live-fixtures/20261029T070703Z-batch_norm.json](evidence/live-fixtures/20261029T070703Z-batch_norm.json) | | [`standardize`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `round_each_op` | no | Ready — 6 bands with scale 1e-4; 3 bands without scale | [evidence/raster-standardize-offline.json](evidence/raster-standardize-offline.json); [evidence/live-fixtures/20261030T152829Z-standardize.json](evidence/live-fixtures/20261030T152829Z-standardize.json) | | [`segmentation_outputs`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json); [evidence/t05-primary.json](evidence/t05-primary.json) | | [`binary_threshold`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json) | | [`pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready — aligned avg/max k = 2, 3, 4; max 3×3 stride 1 or 2, padding 1; avg 3×3 stride 1 padding 1, both divisor rules | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [evidence/t05-primary.json](evidence/t05-primary.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | +| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | extent | `float32_operands` | no | Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False | `docs/evidence/raster-global-pool-multiply-offline.json` (unresolved) | | [`bilinear_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | k×k, radius 1 | `float32_operands` | yes (by setting) | Ready — factors 2, 3, 4 (align_corners=False), 2, 4 (align_corners=True) | [evidence/raster-bilinear-offline.json](evidence/raster-bilinear-offline.json); [evidence/live-fixtures/20261029T094157Z-bilinear.json](evidence/live-fixtures/20261029T094157Z-bilinear.json) | | [`nearest_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | pointwise | `float32_operands` | no | Ready — factors 2, 3, 4 | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | @@ -218,13 +220,13 @@ their own. ### Evidence notes -- Every evidence path names an existing file. +- 2 evidence entries name no existing file: `docs/evidence/raster-global-pool-multiply-offline.json`. - Entries in *italics* are notes in the metadata, not files. - Locality and chip edge hold over every validated setting; *(by setting)* marks a property whose exact value at a call's settings is `per_setting`'s (`metadata_at`). ### Validated settings -Each block's `validated` record, the status in machine-readable form: 88 records. Each `{…}` is one configuration, whose values were validated together; a record is their union (∪). A setting no configuration names is not restricted. Names come from the block function, its cost functions and the sources listed. +Each block's `validated` record, the status in machine-readable form: 90 records. Each `{…}` is one configuration, whose values were validated together; a record is their union (∪). A setting no configuration names is not restricted. Names come from the block function, its cost functions and the sources listed. | Block | Layout | Validated settings | Names from | |---|---|---|---| @@ -244,11 +246,13 @@ Each block's `validated` record, the status in machine-readable form: 88 records | [`conv2d`](../tensor_ee/blocks/raster/conv.py) | R → R | any accepted call | — | | [`concatenate`](../tensor_ee/blocks/raster/merge.py) | R → R | {dim 1} | — | | [`residual_add`](../tensor_ee/blocks/raster/merge.py) | R → R | any accepted call | — | +| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | none live | — | | [`batch_norm`](../tensor_ee/blocks/raster/normalization.py) | R → R | {clip_footprints False; eps 1e-05} | `BatchNormConstants` | | [`standardize`](../tensor_ee/blocks/raster/normalization.py) | R → R | {channels 6; clip_footprints False; scale 0.0001} ∪ {channels 3; clip_footprints False; scale None} | `StandardizeConstants` | | [`segmentation_outputs`](../tensor_ee/blocks/raster/outputs.py) | R → R | any accepted call | — | | [`binary_threshold`](../tensor_ee/blocks/raster/outputs.py) | R → R | any accepted call | — | | [`pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | {kernel 2; padding 0; reducer 'mean' or 'max'; stride 2} ∪ {kernel 3; padding 0; reducer 'mean' or 'max'; stride 3} ∪ {kernel 4; padding 0; reducer 'mean' or 'max'; stride 4} ∪ {kernel 3; padding 1; reducer 'max'; stride 1 or 2} ∪ {count_include_pad True or False; kernel 3; padding 1; reducer 'mean'; stride 1} | `PoolConfig` | +| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | none live | — | | [`bilinear_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | {align_corners False; clip_footprints False; factor 2 or 3 or 4} ∪ {align_corners True; clip_footprints False; factor 2 or 4} | `BilinearConstants` | | [`nearest_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | {scale_factor 2 or 3 or 4} | — | | [`gelu`](../tensor_ee/blocks/window/activations.py) | W → W | any accepted call | — | @@ -440,7 +444,7 @@ generated list cannot drift from the code. | Layer | Module | What it holds | Public names | |---|---|---|---| | Core | [`tensor_ee.core.errors`](../tensor_ee/core/errors.py) | Errors shared by every layer of the library. | `UnsupportedOperation`, `GridMismatch`, `SemanticUnknown`, `reject` | -| Core | [`tensor_ee.core.grid`](../tensor_ee/core/grid.py) | The three grid kinds behind one protocol, with explicit conversions. | `GridProtocol`, `Grid`, `TokenGrid`, `FeatureGrid`, `to_grid`, `to_feature_grid`, `to_token_grid`, `check_model_grid` | +| Core | [`tensor_ee.core.grid`](../tensor_ee/core/grid.py) | The three grid kinds behind one protocol, with explicit conversions. | `GridProtocol`, `Grid`, `one_pixel`, `TokenGrid`, `FeatureGrid`, `to_grid`, `to_feature_grid`, `to_token_grid`, `check_model_grid` | | Core | [`tensor_ee.core.metadata`](../tensor_ee/core/metadata.py) | Locality, cost and validated-settings metadata for library blocks (docs/library-contract.md, section 9, and its "As built (Phase 3)" section). | `Locality`, `Between`, `Validated`, `SettingsCheck`, `check_settings`, `describe_validated`, `BlockMetadata`, `validate_metadata`, `metadata_at` | | Core | [`tensor_ee.core.ops`](../tensor_ee/core/ops.py) | The ops interface: one set of array primitives, two implementations. | `Ops`, `image_reshape`, `ArrayOps`, `ImageOps`, `BlockArrayOps`, `BlockImageOps`, `ValueOps`, `WindowOps` | | Core | [`tensor_ee.core.precision`](../tensor_ee/core/precision.py) | Precision policies, and how float32 values enter an Earth Engine graph. | `validate_policy`, `exact_scalar`, `encode_float32`, `constant_value` | diff --git a/docs/library-contract.md b/docs/library-contract.md index a60d668..3df00c8 100644 --- a/docs/library-contract.md +++ b/docs/library-contract.md @@ -429,7 +429,7 @@ Each block's default policy: | Blocks | Default | |---|---| -| R: `conv2d`, `grouped_conv2d`, `pool2d`, `nearest_upsample`, `bilinear_upsample`, `concatenate`, `residual_add`, `batch_norm`, `relu`, `sigmoid`, `leaky_relu`, `silu`, `gelu`, `tanh`, `relu6`, `hardsigmoid`, `hardswish`, `eval_dropout`, `softmax`, `segmentation_outputs`, `binary_threshold` | `float32_operands` (softmax and outputs keep their `toDouble()` score widening) | +| R: `conv2d`, `grouped_conv2d`, `pool2d`, `global_avg_pool2d`, `nearest_upsample`, `bilinear_upsample`, `concatenate`, `residual_add`, `multiply`, `batch_norm`, `relu`, `sigmoid`, `leaky_relu`, `silu`, `gelu`, `tanh`, `relu6`, `hardsigmoid`, `hardswish`, `eval_dropout`, `softmax`, `segmentation_outputs`, `binary_threshold` | `float32_operands` (softmax and outputs keep their `toDouble()` score widening) | | R: `standardize` (input scaling and per-band normalization) | `round_each_op` (a float32 preprocessing pipeline, bit for bit) | | W/V: `naive_attention` (scores, softmax, value product) | `round_each_op` | | W/V: `linear`/`conv1x1`, `layer_norm`, `layer_norm2d`, `gelu`, `batch_norm`, `conv3x3_window`, `window_resize`, `window_matrix_resize`, `window_pool`, `patch_embed`, `patch_merge` | `double_round_once` | diff --git a/docs/live-fixtures.md b/docs/live-fixtures.md index 54a7345..cfc2792 100644 --- a/docs/live-fixtures.md +++ b/docs/live-fixtures.md @@ -60,6 +60,7 @@ A fixture is a frozen record (`tools/fixtures/core.py::Fixture`): | `mirror` | Optional: the NumPy mirror from `tests/reference`, and whether it models Earth Engine's rounding exactly (`exact`) or within a recorded `bound`, which may not exceed the fixture's tolerance | | `tolerance` | 1e-4 absolute against PyTorch. A fixture may set a smaller one, never a larger one (refused by the record). A tolerance of 0 compares float32 bits, so -0.0 against +0.0 fails; selection and copy blocks use it | | `window` | The window of the graph, for the window lane's request budget; it must equal `settings["window"]` (refused otherwise) | +| `raster_side` | Optional, raster lane only: the fixture's own request-grid side above the budget's, by the maintainer's allowance (at most 128); recorded in the spec only when set | | `requests` | The expected number of interactive requests; `--plan` refuses a fixture whose graphs need another number | | `constants(data)` | The embedded-constant count of its blocks, from their `METADATA` cost functions | | `history` | The recorded result the fixture should reproduce, with its file | @@ -93,7 +94,7 @@ covers runs within them. | Limit | Default | |---|---| | Interactive requests per invocation | 40 | -| Request grid, raster lane | 64 × 64 pixels | +| Request grid, raster lane | 64 × 64 pixels; 128 × 128 for `raster/global_avg_pool2d/h128-w128` alone (`Fixture.raster_side`, a maintainer's allowance; a lowered `raster_side` applies to it too) | | Request grid, window lane | 8 × 8 windows (56 × 56 tokens at window 7) | | Output values per request | 1,000,000 | | Serialized request | 2,000,000 bytes | diff --git a/tensor_ee/blocks/raster/__init__.py b/tensor_ee/blocks/raster/__init__.py index cc55165..06dc92a 100644 --- a/tensor_ee/blocks/raster/__init__.py +++ b/tensor_ee/blocks/raster/__init__.py @@ -1,10 +1,10 @@ """Raster-layout (R) blocks of the native EE library: the CNN path's compute blocks. - ``conv``: ``conv2d``, ``grouped_conv2d``; -- ``pooling``: ``pool2d``; +- ``pooling``: ``pool2d``, ``global_avg_pool2d``; - ``resample``: ``nearest_upsample``, ``BilinearConstants``, ``bilinear_upsample``; - ``normalization``: ``BatchNormConstants``, ``batch_norm``, ``StandardizeConstants``, ``standardize``; -- ``merge``: ``concatenate``, ``residual_add``; +- ``merge``: ``concatenate``, ``residual_add``, ``multiply``; - ``activations``: ``relu``, ``sigmoid``, ``leaky_relu``, ``silu``, ``gelu``, ``tanh``, ``relu6``, ``hardsigmoid``, ``hardswish``, ``eval_dropout``, ``common_score_mask``, ``softmax``; @@ -25,16 +25,16 @@ """ from .conv import conv2d, grouped_conv2d -from .pooling import pool2d +from .pooling import global_avg_pool2d, pool2d from .resample import BilinearConstants, bilinear_upsample, nearest_upsample from .normalization import BatchNormConstants, StandardizeConstants, batch_norm, standardize -from .merge import concatenate, residual_add +from .merge import concatenate, multiply, residual_add from .activations import (common_score_mask, eval_dropout, gelu, hardsigmoid, hardswish, leaky_relu, relu, relu6, sigmoid, silu, softmax, tanh) from .outputs import SegmentationOutputs, binary_threshold, segmentation_outputs __all__ = ["conv2d", "pool2d", "nearest_upsample", "concatenate", "residual_add", - "grouped_conv2d", + "grouped_conv2d", "global_avg_pool2d", "multiply", "BilinearConstants", "bilinear_upsample", "BatchNormConstants", "batch_norm", "StandardizeConstants", "standardize", "relu", "sigmoid", "leaky_relu", "silu", "gelu", "tanh", "relu6", "hardsigmoid", diff --git a/tensor_ee/blocks/raster/merge.py b/tensor_ee/blocks/raster/merge.py index acd7730..b375b4f 100644 --- a/tensor_ee/blocks/raster/merge.py +++ b/tensor_ee/blocks/raster/merge.py @@ -1,8 +1,33 @@ -"""R merges: channel concatenation (``torch.cat`` on dim 1) and residual addition. +"""R merges: channel concatenation (``torch.cat`` on dim 1), residual addition and +elementwise multiplication. Moved whole from ``tensor_ee.compiler`` (``concatenate``, ``residual_add``). The old names are re-exports of these objects. Both require identical grids; there is no broadcasting. + +**Multiply** (``multiply(x, other)``): ``x * other`` band by band, in two forms only: + +- ``other`` on ``x``'s grid with ``x``'s channel count (PyTorch's same-shape product); +- ``other`` a one-pixel gate on ``one_pixel(x.grid)`` with ``x``'s channel count: the + ``[N, C, 1, 1]`` tensor ``global_avg_pool2d`` gives for this map, broadcast over its + pixels (squeeze-and-excitation's ``sigmoid(g) * x``). The gate is reprojected onto + ``x``'s grid; nearest resampling gives every pixel of the map the gate's one value. + The grid check is what ties the gate to this map: a gate pooled from another map, + chip or resolution is refused. + +Everything else is refused: a channel broadcast (a ``[N, 1, H, W]`` operand, scSE's +spatial gate), a scalar, other grids and other channel counts. Earth Engine would +broadcast a one-band operand over every band, so the channel count is checked here. +``x``'s bands and grid are kept. Locality ``POINTWISE``: the gate form's dependence on the +whole map travels in the one-pixel edge from the ``EXTENT`` pool, so a gate costs one +``EXTENT`` node, not two. + +Policy ``float32_operands``: the product of two float32 values is exact in double, so its +one float32 rounding at the node boundary gives the correctly rounded float32 product, +bit-equal to PyTorch's (an IEEE product is commutative, so ``x * g`` and ``g * x`` agree). +**Not validated live**: offline evidence only, the same-grid form on a 5 x 7 map and the gate +form on 5 x 7 and, through ``compile_model``, 12 x 20 maps (``raster/multiply/*`` in +``tools/fixtures/raster.py``), clip_footprints=False. """ from __future__ import annotations @@ -10,10 +35,11 @@ from typing import Sequence from ...core.errors import UnsupportedOperation -from ...core.metadata import ANY_SETTINGS, BlockMetadata, Locality, Validated, validate_metadata +from ...core.grid import one_pixel +from ...core.metadata import ANY_SETTINGS, NOT_LIVE, BlockMetadata, Locality, Validated, validate_metadata from ...core.precision import FLOAT32_OPERANDS, validate_policy from ...core.tensor import Raster -from ...layouts.raster import finish as _finish +from ...layouts.raster import finish as _finish, project as _project from .conv import _names, _no_coefficients @@ -44,6 +70,30 @@ def residual_add(left: Raster, right: Raster, *, clip_footprints: bool = False, return _finish(left.image.add(right.image.rename(list(left.bands))), left.bands, left.grid, clip_footprints) +MULTIPLY_FORMS = ("the same grid with the same channel count, or a one-pixel gate on the map's own " + "one-pixel grid (one_pixel(x.grid)) with the map's channel count") +"""The accepted forms, as the refusal names them.""" + + +def multiply(x: Raster, other: Raster, *, clip_footprints: bool = False, + policy: str = FLOAT32_OPERANDS) -> Raster: + """``x * other`` band by band: ``other`` on ``x``'s grid, or a one-pixel gate on + ``one_pixel(x.grid)``, with ``x``'s channel count either way; ``x``'s bands and grid.""" + validate_policy(policy, allowed=FLOAT32_OPERANDS) + if not isinstance(x, Raster) or not isinstance(other, Raster): + raise UnsupportedOperation("Multiplication requires two raster tensors; a scalar operand is unsupported") + if len(other.bands) != len(x.bands): + raise UnsupportedOperation(f"Multiplication requires {MULTIPLY_FORMS}; received {len(other.bands)} " + f"channels for a {len(x.bands)}-channel map (no channel broadcasting)") + if other.grid == x.grid: + factor = other.image.rename(list(x.bands)) + elif other.grid == one_pixel(x.grid): + factor = _project(other.image.rename(list(x.bands)), x.grid, clip_footprints) + else: + raise UnsupportedOperation(f"Multiplication requires {MULTIPLY_FORMS}; the operand's grid is neither") + return _finish(x.image.multiply(factor), x.bands, x.grid, clip_footprints) + + # --------------------------------------------------------------------------- # Locality and cost metadata (contract section 9) # --------------------------------------------------------------------------- @@ -63,4 +113,11 @@ def residual_add(left: Raster, right: Raster, *, clip_footprints: bool = False, evidence=("docs/history/cnn/results/resnet_primitives.json", "docs/history/cnn/results/resnet18_full64_float32_policy.json", "docs/history/cnn/results/segwater_js_parity_1x1.json"), validated=ANY_SETTINGS), + "multiply": BlockMetadata( + name="multiply", layout_in="R", layout_out="R", locality=Locality.POINTWISE, + policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients, + asset_arrays=_no_coefficients, + status=("Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; " + "clip_footprints=False"), + evidence=("docs/evidence/raster-global-pool-multiply-offline.json",), validated=NOT_LIVE), }) diff --git a/tensor_ee/blocks/raster/pooling.py b/tensor_ee/blocks/raster/pooling.py index 2f6bc15..2a8638e 100644 --- a/tensor_ee/blocks/raster/pooling.py +++ b/tensor_ee/blocks/raster/pooling.py @@ -1,5 +1,5 @@ """R pooling: PyTorch ``nn.AvgPool2d`` and ``nn.MaxPool2d`` at a small set of -common CNN configurations. +common CNN configurations, and global average pooling. Moved whole from ``tensor_ee.compiler`` (``pool2d``, both branches) and ``tensor_ee.semantics`` (``pool_options``). The old names are re-exports of @@ -29,6 +29,33 @@ Precision policy ``float32_operands`` (contract section 7). There are no coefficients. The node output is materialized with ``.toFloat()`` by the caller (``compile_model`` or the ``tensor_ee.primitives`` facade). + +**Global average pooling** (``global_avg_pool2d``): PyTorch's ``adaptive_avg_pool2d(x, 1)`` +(``AdaptiveAvgPool2d(1)``, ``x.mean((2, 3), keepdim=True)``) of an H x W map, + + out[c] = (1 / (H W)) * sum over i < H, j < W of x[c, i, j], + +as a ``[N, C, 1, 1]`` tensor: a Raster on ``one_pixel(grid)``, the one pixel that covers +exactly the map (``core.grid.one_pixel``; a non-square map has a non-square pixel). It is +the aligned branch's expression with the map's size as the kernel, through the same private +helper (``_aligned_reduce``): ``reduceResolution(mean, bestEffort=False, +maxPixels=(H + 2)(W + 2))``, pinned to the one-pixel grid. Every pixel of the map is read +whichever shard or tile asks for the one output pixel, so the mean is over the map (the +chip), never over the piece being evaluated. Locality ``EXTENT``: the output depends on the +whole map, so tiling with halos cannot reproduce it, and ``analyze_receptive_field`` and +tiled compilation refuse a model that has one. + +Policy ``float32_operands``: the reducer's mean, rounded once to float32 by the caller. +The weights of ``reduceResolution``'s mean are the input mask times the fraction of the +output pixel covered, so a masked pixel inside the map would shrink the denominator; the +raster lane's tensors are zero-filled and masked only outside their grid, as ``pool2d``'s +aligned average also requires. Earth Engine's accumulation type and order for +``reduceResolution`` are not established: the NumPy mirror (``tests/reference``) sums in +double and divides by H W once, and the fixtures use inputs whose double sums are exact in +every order. ``maxPixels`` above (k + 2)^2 for k = 4 (16,900 at 128 x 128) is untested by +Earth Engine. **Not validated live**: offline evidence only, maps 5 x 7, 12 x 20, 8 x 8, +64 x 64 and 128 x 128 (``raster/global_avg_pool2d/*`` and the squeeze-excitation fixture in +``tools/fixtures/raster.py``), clip_footprints=False. """ from __future__ import annotations @@ -39,7 +66,8 @@ from torch import nn from ...core.errors import UnsupportedOperation -from ...core.metadata import BlockMetadata, Locality, Validated, validate_metadata +from ...core.grid import Grid, one_pixel +from ...core.metadata import NOT_LIVE, BlockMetadata, Locality, Validated, validate_metadata from ...core.precision import FLOAT32_OPERANDS, validate_policy from ...core.tensor import Raster from ...layouts.raster import finish as _finish, project as _project @@ -148,11 +176,36 @@ def pool2d(raster: Raster, module: nn.AvgPool2d | nn.MaxPool2d, # maxPixels (k + 2)^2 keeps today's 16 at k = 2: the k x k block plus a # one-pixel ring. It bounds the input pixels per output pixel; with # bestEffort=False a larger need fails the request rather than changing values. - result = raster.image.reduceResolution(reducer, bestEffort=False, maxPixels=(k + 2) ** 2) + return _aligned_reduce(raster, reducer, grid, (k + 2) ** 2, clip_footprints) + + +def _aligned_reduce(raster: Raster, reducer: ee.Reducer, grid: Grid, max_pixels: int, + clip_footprints: bool) -> Raster: + """``reducer`` over the input pixels of each pixel of ``grid``, a coarser grid whose + pixels each cover a whole block of input pixels (the same corner, an integer factor): + ``reduceResolution`` with ``bestEffort=False`` and ``maxPixels`` input pixels per + output pixel, pinned to ``grid``. The one implementation of ``pool2d``'s aligned + branch and of ``global_avg_pool2d``.""" + result = raster.image.reduceResolution(reducer, bestEffort=False, maxPixels=max_pixels) result = _project(result, grid, clip_footprints) return _finish(result, raster.bands, grid, clip_footprints) +def global_avg_pool2d(raster: Raster, *, clip_footprints: bool = False, + policy: str = FLOAT32_OPERANDS) -> Raster: + """The mean of each band over the whole H x W map, on ``one_pixel(raster.grid)``: + PyTorch's ``adaptive_avg_pool2d(x, 1)``, ``AdaptiveAvgPool2d(1)`` and + ``x.mean((2, 3), keepdim=True)``, as a ``[N, C, 1, 1]`` tensor whose one pixel covers + exactly the map. ``pool2d``'s aligned expression with the kernel the map's size + (``_aligned_reduce``), ``maxPixels`` (H + 2)(W + 2), as ``pool2d`` sizes its ring.""" + validate_policy(policy, allowed=FLOAT32_OPERANDS) + if not isinstance(raster, Raster): + raise UnsupportedOperation("Global average pooling requires a raster tensor") + grid = raster.grid + return _aligned_reduce(raster, ee.Reducer.mean(), one_pixel(grid), (grid.height + 2) * (grid.width + 2), + clip_footprints) + + # --------------------------------------------------------------------------- # Locality and cost metadata (contract section 9) # --------------------------------------------------------------------------- @@ -179,4 +232,13 @@ def pool2d(raster: Raster, module: nn.AvgPool2d | nn.MaxPool2d, {"reducer": ("max",), "kernel": (3,), "stride": (1, 2), "padding": (1,)}, {"reducer": ("mean",), "kernel": (3,), "stride": (1,), "padding": (1,), "count_include_pad": (True, False)}, ), sources=(PoolConfig,))), + # The output depends on every pixel of the map: EXTENT. Not chip-edge dependent in the + # flag's sense (no wrap or mask); EXTENT already makes a stage whole-chip, as for + # window_pool. + "global_avg_pool2d": BlockMetadata( + name="global_avg_pool2d", layout_in="R", layout_out="R", locality=Locality.EXTENT, + policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients, asset_arrays=_no_coefficients, + status=("Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; " + "clip_footprints=False"), + evidence=("docs/evidence/raster-global-pool-multiply-offline.json",), validated=NOT_LIVE), }) diff --git a/tensor_ee/compatibility.py b/tensor_ee/compatibility.py index 3361570..96a82e7 100644 --- a/tensor_ee/compatibility.py +++ b/tensor_ee/compatibility.py @@ -15,6 +15,10 @@ infer_metadata, semantic_call, specialize_plumbing) +WHOLE_CHIP = "global pooling: the output depends on the whole compiled grid; no tiled compilation" +"""The restriction a model with a global mean adds: its result is per compiled chip.""" + + @dataclass(frozen=True) class CompatibilityReport: """Schema-versioned local facts, without an EE execution/capacity guarantee.""" @@ -157,6 +161,8 @@ def finish() -> CompatibilityReport: count += int(call.module.weight.numel()) + int(call.module.bias.numel()) elif call.kind == "batch_norm": count += 4 * call.options["channels"] + elif call.kind == "global_mean" and WHOLE_CHIP not in report["restrictions"]: + report["restrictions"].append(WHOLE_CHIP) if any(metadata.get(x) is None for x in call.inputs): raise SemanticUnknown("Shape depends on an unsupported or unknown predecessor") metadata[node] = infer_metadata(call, tuple(metadata[x] for x in call.inputs)) diff --git a/tensor_ee/compiler.py b/tensor_ee/compiler.py index 5808e61..847f5d0 100644 --- a/tensor_ee/compiler.py +++ b/tensor_ee/compiler.py @@ -9,8 +9,10 @@ Semantics are intentionally strict: finite rectangles, missing input values filled with zero, odd same-padded stride-one/two convolutions, aligned k x k pooling (k = 2, 3, 4), padded 3x3 max pooling (stride one or two) and stride-one -average pooling, integer nearest/bilinear upsampling, residual addition, and channel -concatenation. Outside every tensor's rectangle is zero for the +average pooling, integer nearest/bilinear upsampling, residual addition, channel +concatenation, global average pooling onto the map's one-pixel grid and elementwise +multiplication (same grid, or by a one-pixel gate pooled from the map it multiplies). +Outside every tensor's rectangle is zero for the next convolution, including after a biased layer. Intermediate grids retain the original upper-left corner; strided operations sample PyTorch's correct pixel centers and then relabel the resulting tensor's coordinates. @@ -30,7 +32,7 @@ from torch.fx import GraphModule, Node, symbolic_trace from .core.errors import UnsupportedOperation -from .core.grid import Grid +from .core.grid import Grid, one_pixel from .core.tensor import Raster # R converters (layout and grid consolidation) and float32 helpers moved to the library; the old # names still in use are kept here as re-exports. compile_model looks _finish up here. @@ -41,9 +43,9 @@ # objects. The blocks look their helpers up in their own modules, so tests patch # tensor_ee.blocks.raster.conv._inline_float32_array. from .blocks.raster.conv import _names, _pair, conv2d, grouped_conv2d -from .blocks.raster.pooling import pool2d +from .blocks.raster.pooling import global_avg_pool2d, pool2d from .blocks.raster.resample import BilinearConstants, bilinear_upsample, nearest_upsample -from .blocks.raster.merge import concatenate, residual_add +from .blocks.raster.merge import concatenate, multiply, residual_add from .blocks.raster.normalization import BatchNormConstants, batch_norm # The activations were private here; the library names them without the # underscore. Tests patch tensor_ee.blocks.raster.activations._finish and @@ -73,7 +75,7 @@ class CompiledModel(Raster): nn.ReLU6, nn.Hardsigmoid, nn.Hardswish) _UNSUPPORTED_DROPOUT_MODULES = (nn.Dropout1d, nn.Dropout3d, nn.AlphaDropout, nn.FeatureAlphaDropout) _ROOT_MODULES = (nn.Conv2d, nn.BatchNorm2d, nn.ReLU, nn.AvgPool2d, nn.MaxPool2d, - nn.Upsample, nn.Sigmoid, nn.Identity, *_POINTWISE_MODULES, + nn.AdaptiveAvgPool2d, nn.Upsample, nn.Sigmoid, nn.Identity, *_POINTWISE_MODULES, *_UNSUPPORTED_DROPOUT_MODULES) @@ -251,6 +253,11 @@ def compile_model(model: nn.Module, image: ee.Image, input_bands: Sequence[str], residual tensor addition, torch.relu, F.relu, torch.sigmoid, and F.interpolate with a static integer scale factor, or a proven size with that same integer mapping. Bilinear requires a static align_corners bool and antialias=False; its coefficients stay inline. + Global average pooling (AdaptiveAvgPool2d and F.adaptive_avg_pool2d to size 1, and + torch.mean/Tensor.mean over dims (2, 3) with keepdim=True) gives a [N, C, 1, 1] + tensor on the map's one-pixel grid; the result depends on the whole compiled grid. + Multiplication (``*``, torch.mul, Tensor.mul) takes two tensors on one grid, or a map + and a one-pixel gate pooled from it, in either operand order. Unsupported operators fail explicitly with their FX node. ``nodes`` includes the input placeholder and each tensor operation, keyed by the names in ``graph_module.graph`` for layer-by-layer parity tests. @@ -380,6 +387,15 @@ def convolve(key: str, raster: Raster, module: nn.Conv2d) -> Raster: raster = concatenate(inputs, call.options["dim"], clip_footprints=clip_footprints) elif kind == "add": raster = residual_add(*inputs, clip_footprints=clip_footprints) + elif kind == "global_mean": + raster = global_avg_pool2d(inputs[0], clip_footprints=clip_footprints) + elif kind == "multiply": + # The map is x, the gate is other, whichever operand PyTorch wrote + # first (an IEEE product is commutative): sigmoid(g) * x and x * g. + x, other = inputs + if other.grid != x.grid and x.grid == one_pixel(other.grid): + x, other = other, x + raster = multiply(x, other, clip_footprints=clip_footprints) elif kind == "relu": raster = _relu(inputs[0], clip_footprints=clip_footprints) elif kind == "sigmoid": diff --git a/tensor_ee/core/grid.py b/tensor_ee/core/grid.py index 0904c9e..79c9c67 100644 --- a/tensor_ee/core/grid.py +++ b/tensor_ee/core/grid.py @@ -133,6 +133,16 @@ def fine(self, factor: int) -> Grid: return self.resized(1 / factor, self.width * factor, self.height * factor) +def one_pixel(grid: Grid) -> Grid: + """The one-pixel grid that covers exactly ``grid``'s finite extent: the same corner, + with pixel spacing ``width`` times ``grid``'s in x and ``height`` times in y (so a + non-square map has a non-square pixel). The carrier of a ``[N, C, 1, 1]`` tensor pooled + from the whole map. On a k x k grid it equals ``grid.resized(k, 1, 1)``, the grid + of an aligned k x k pool.""" + a, b, c, d, e, f = grid.transform + return Grid(grid.crs, (a * grid.width, b * grid.width, c, d * grid.height, e * grid.height, f), 1, 1) + + @dataclass(frozen=True) class TokenGrid: """North-up grid with origin (0, 0), `scale` metres per token, rows downward.""" diff --git a/tensor_ee/receptive_field.py b/tensor_ee/receptive_field.py index 9215067..0c43df3 100644 --- a/tensor_ee/receptive_field.py +++ b/tensor_ee/receptive_field.py @@ -7,6 +7,11 @@ approximate receptive-field width, so bounds retain their sampling phase. Bilinear upsampling adds the parent's one-pixel ring. With align_corners=True the report marks extent dependence and refuses a halo guarantee. +A global mean (global average pooling onto the map's one-pixel grid) depends on +every pixel of the chip: the report lists it in ``extent_nodes`` and refuses every +halo guarantee. Its node keeps its input's local bounds, so an elementwise multiply +by such a gate can take the union of its operands; those bounds describe the local +paths only. For a full-resolution output, ``safe_halo`` is rounded to ``alignment`` and is guaranteed for cores whose start and exclusive end align to that lattice. Tile @@ -114,8 +119,13 @@ class ReceptiveFieldReport(SpatialSupport): alignment: int nodes: dict[str, SpatialSupport] chip_edge_dependent: bool = False + extent_nodes: tuple[str, ...] = () def _require_tileable(self): + if self.extent_nodes: + raise UnsupportedReceptiveField( + f"Global pooling needs the whole chip (nodes {', '.join(self.extent_nodes)}); " + "halos cannot reproduce it") if self.chip_edge_dependent: raise UnsupportedReceptiveField( "Bilinear align_corners=True depends on the full source extent; halos cannot reproduce it") @@ -150,8 +160,9 @@ def as_dict(self, *, include_nodes: bool = False) -> dict[str, Any]: result.update({ "alignment": self.alignment, "period": self.period, - "safe_halo": None if self.chip_edge_dependent else self.safe_halo, - "halo_for_any_phase": None if self.chip_edge_dependent else self.halo_for_any_phase, + "safe_halo": None if self.chip_edge_dependent or self.extent_nodes else self.safe_halo, + "halo_for_any_phase": (None if self.chip_edge_dependent or self.extent_nodes + else self.halo_for_any_phase), "aligned_core_requirement": "start and exclusive end divisible by alignment", "context_requirement": "origin and dimensions divisible by alignment", "bounds_convention": "inclusive input pixel indices; structural dependency envelope", @@ -159,6 +170,9 @@ def as_dict(self, *, include_nodes: bool = False) -> dict[str, Any]: if self.chip_edge_dependent: result["chip_edge_dependent"] = True result["context_requirement"] = "full source extent required" + if self.extent_nodes: + result["extent_nodes"] = list(self.extent_nodes) + result["context_requirement"] = "full source extent required" if include_nodes: result["nodes"] = {name: support.as_dict() for name, support in self.nodes.items()} return result @@ -209,7 +223,8 @@ def analyze_receptive_field(model: nn.Module | GraphModule) -> ReceptiveFieldRep Supports the compiler's odd same-padded convolutions, its pools (aligned k x k average/max for k in 2, 3, 4; padded 3x3 max at stride 1 or 2; padded 3x3 stride-1 average), fixed integer nearest/bilinear upsampling, inference BatchNorm, - ReLU/Sigmoid/Identity, residual addition, and channel concatenation. Pools + ReLU/Sigmoid/Identity, residual addition, channel concatenation, the elementwise + multiply, and global means (listed in ``extent_nodes``; no halo then). Pools and convolutions share one window rule (kernel, stride, padding), so an aligned k x k pool sets the lattice alignment k and a stride-1 padded pool adds a one-pixel radius. @@ -225,6 +240,7 @@ def analyze_receptive_field(model: nn.Module | GraphModule) -> ReceptiveFieldRep placeholders = 0 output = None chip_edge_dependent = False + extent_nodes: list[str] = [] def source(node: Any) -> SpatialSupport: if not isinstance(node, Node) or node not in supports: @@ -264,7 +280,10 @@ def source(node: Any) -> SpatialSupport: support = SpatialSupport(_upsample_axis(_window_axis(support.y, 3, 1, 1), scale), _upsample_axis(_window_axis(support.x, 3, 1, 1), scale)) chip_edge_dependent |= call.options["align_corners"] - elif call.kind in ("cat", "add"): + elif call.kind == "global_mean": + # Every pixel of the chip: no finite halo. The local bounds pass through. + extent_nodes.append(node.name) + elif call.kind in ("cat", "add", "multiply"): branches = [source(item) for item in call.inputs] support = SpatialSupport(_union_axis([branch.y for branch in branches]), _union_axis([branch.x for branch in branches])) @@ -275,4 +294,4 @@ def source(node: Any) -> SpatialSupport: alignment = math.lcm(alignment, support.y.input_shift, support.x.input_shift) if output is None: raise UnsupportedReceptiveField("Graph has no single supported image tensor output") - return ReceptiveFieldReport(output.y, output.x, alignment, nodes, chip_edge_dependent) + return ReceptiveFieldReport(output.y, output.x, alignment, nodes, chip_edge_dependent, tuple(extent_nodes)) diff --git a/tensor_ee/semantics.py b/tensor_ee/semantics.py index 6ac8110..7e9f7da 100644 --- a/tensor_ee/semantics.py +++ b/tensor_ee/semantics.py @@ -67,6 +67,62 @@ def _interpolate_arguments(input, size=None, scale_factor=None, mode="nearest", pass +def _adaptive_pool_arguments(input, output_size): + pass + + +def _mean_arguments(input, dim=None, keepdim=False, *, dtype=None, out=None): + pass + + +def _method_mean_arguments(input, dim=None, keepdim=False, *, dtype=None): + pass + + +def _operator_mul_arguments(input, other, /): + pass + + +def _mul_arguments(input, other, *, out=None): + pass + + +def _method_mul_arguments(input, other): + pass + + +_GLOBAL_DIMS = ((2, 3), (3, 2), (-2, -1), (-1, -2)) +"""The spatial axes of an NCHW mean that pool the whole map, as ``dim`` may name them.""" + + +def _static_int(value) -> bool: + return isinstance(value, Integral) and not isinstance(value, bool) + + +def global_pool_size(output_size: Any) -> None: + """Accept only the adaptive average pool to one pixel: ``1``, ``(1, 1)`` or ``[1, 1]``.""" + if _static_int(output_size) and output_size == 1: + return + if (isinstance(output_size, (tuple, list)) and len(output_size) == 2 + and all(_static_int(x) and x == 1 for x in output_size)): + return + _reject("Adaptive average pooling is supported only to one pixel (output_size 1, (1, 1) or [1, 1]): " + f"global pooling; received output_size={output_size!r}") + + +def global_mean_options(options: dict[str, Any]) -> None: + """Accept only the mean over both spatial axes with ``keepdim=True`` and no ``dtype``.""" + dim = options["dim"] + if (not isinstance(dim, (tuple, list)) or len(dim) != 2 or not all(_static_int(x) for x in dim) + or tuple(int(x) for x in dim) not in _GLOBAL_DIMS): + _reject("mean is supported only over both spatial axes, dim (2, 3), (3, 2), (-2, -1) or (-1, -2), " + f"with keepdim=True; received dim={dim!r}") + if options["keepdim"] is not True: + _reject(f"mean requires keepdim=True (a [N, C, 1, 1] tensor); received keepdim={options['keepdim']!r}") + if options["dtype"] is not None: + _reject(f"mean requires dtype=None; received dtype={options['dtype']!r}") + + def interpolate_options(options: dict[str, Any], input_metadata: TensorMetadata | None = None) -> int: if options["mode"] == "bilinear": return bilinear_options(options, input_metadata) @@ -438,6 +494,9 @@ def semantic_call(node: Node, graph: GraphModule) -> SemanticCall: predecessor, source, fused = folded return SemanticCall("batchnorm", inputs, {"source": source, "predecessor": predecessor}, fused) return SemanticCall("batch_norm", inputs, _module_batchnorm_options(module)) + if isinstance(module, nn.AdaptiveAvgPool2d): + global_pool_size(module.output_size) + return SemanticCall("global_mean", inputs) _reject(f"Unsupported module {type(module).__name__}", "unsupported_operator") if node.op == "call_function": target = node.target @@ -477,7 +536,30 @@ def semantic_call(node: Node, graph: GraphModule) -> SemanticCall: if target is F.interpolate: options = _bind_options(_interpolate_arguments, node.args, node.kwargs) return _interpolation_call(_inputs((options["input"],)), options) + if target is F.adaptive_avg_pool2d: + options = _bind_options(_adaptive_pool_arguments, node.args, node.kwargs) + global_pool_size(options["output_size"]) + return SemanticCall("global_mean", _inputs((options["input"],))) + if target is torch.mean: + options = _bind_options(_mean_arguments, node.args, node.kwargs) + if "out" in node.kwargs: + _reject("out is unsupported for mean") + global_mean_options(options) + return SemanticCall("global_mean", _inputs((options["input"],))) + if target in (operator.mul, torch.mul): + options = _bind_options(_operator_mul_arguments if target is operator.mul else _mul_arguments, + node.args, node.kwargs) + if "out" in node.kwargs: + _reject("out is unsupported for multiplication") + return SemanticCall("multiply", _inputs((options["input"], options["other"]))) _reject(f"Unsupported function {target}", "unsupported_operator") + if node.op == "call_method" and node.target == "mean": + options = _bind_options(_method_mean_arguments, node.args, node.kwargs) + global_mean_options(options) + return SemanticCall("global_mean", _inputs((options["input"],))) + if node.op == "call_method" and node.target == "mul": + options = _bind_options(_method_mul_arguments, node.args, node.kwargs) + return SemanticCall("multiply", _inputs((options["input"], options["other"]))) if node.op == "call_method" and node.target in ("relu", "sigmoid", "add"): options = _bind_options(_add_arguments if node.target == "add" else _input_arguments, node.args, node.kwargs) if "out" in node.kwargs or isinstance(options.get("alpha", 1), bool) or options.get("alpha", 1) != 1: @@ -511,6 +593,20 @@ def infer_metadata(call: SemanticCall, inputs: tuple[TensorMetadata, ...]) -> Te if call.kind in ("nearest", "bilinear"): factor = call.options["factor"] return TensorMetadata((n, c, h * factor, w * factor), first.scale / factor) + if call.kind == "global_mean": + # The [N, C, 1, 1] tensor on the map's one-pixel grid keeps the map's scale: the + # shape relation stands for the extent, and compile_model checks the grids exactly. + return TensorMetadata((n, c, 1, 1), first.scale) + if call.kind == "multiply": + second = inputs[1] + if second.shape == first.shape and second.scale == first.scale: + return first + for map_, gate in ((first, second), (second, first)): + if (gate.shape == (map_.shape[0], map_.shape[1], 1, 1) and gate.scale == map_.scale): + return map_ + _reject("Multiplication requires identical shapes on one grid, or a [N, C, 1, 1] gate pooled from " + "the [N, C, H, W] map it multiplies; scalars, channel and other broadcasts are unsupported", + "input_shape") if call.kind in ("cat", "add"): for other in inputs[1:]: if other.shape[0] != n or other.shape[2:] != (h, w) or other.scale != first.scale: diff --git a/tests/reference/__init__.py b/tests/reference/__init__.py index 0fe9bef..98d535e 100644 --- a/tests/reference/__init__.py +++ b/tests/reference/__init__.py @@ -14,7 +14,8 @@ - ``synthetic``: the deterministic hash-generated rasters those references use; - ``cnn``: the PyTorch-side helpers of the CNN fixture, ResNet and tiling tests; - ``activations``: the NumPy mirrors of the raster lane's bounded activations (``relu6``, - ``hardsigmoid``, ``hardswish``). + ``hardsigmoid``, ``hardswish``) and of its stable ``sigmoid`` and ``silu``; +- ``raster_gate``: the raster lane's global average pooling and elementwise multiply. Nothing here is shipped or imported by ``tensor_ee``. A mirror is a NumPy model of the arithmetic the library emits, not the library itself: the library's graphs are checked by diff --git a/tests/reference/activations.py b/tests/reference/activations.py index 892ad92..11a0559 100644 --- a/tests/reference/activations.py +++ b/tests/reference/activations.py @@ -1,4 +1,5 @@ -"""NumPy mirrors of the bounded activations the raster lane emits. +"""NumPy mirrors of the bounded activations the raster lane emits, and of its stable +``sigmoid`` and ``silu`` (used by the squeeze-excitation fixture's mirror). ``relu6``, ``hardsigmoid`` and ``hardswish`` (``tensor_ee/blocks/raster/activations.py``) are pointwise expressions over float32 operands, materialized with ``.toFloat()`` at the node @@ -46,3 +47,22 @@ def hardsigmoid(values: np.ndarray) -> np.ndarray: def hardswish(values: np.ndarray) -> np.ndarray: """``x * min(max(x + 3, 0), 6) / 6``, the product first, rounded to float32 once.""" return (np.asarray(values, np.float64) * _clamp_shifted(values) / 6.0).astype(np.float32) + + +def _stable_sigmoid(values: np.ndarray) -> np.ndarray: + """The raster blocks' sigmoid in float64: ``m = exp(-|x|)``, then ``1 / (1 + m)`` for + ``x >= 0`` and ``m / (1 + m)`` for ``x < 0``; no positive exponent.""" + x = np.asarray(values, np.float64) + magnitude = np.exp(-np.abs(x)) + return np.where(x < 0, magnitude / (magnitude + 1.0), 1.0 / (magnitude + 1.0)) + + +def sigmoid(values: np.ndarray) -> np.ndarray: + """The raster ``sigmoid``'s stable form in float64, rounded to float32 once. Not exact: + Earth Engine's ``exp`` and NumPy's may differ in the last double bit.""" + return _stable_sigmoid(values).astype(np.float32) + + +def silu(values: np.ndarray) -> np.ndarray: + """The raster ``silu``: ``x * sigmoid(x)`` in float64, rounded to float32 once.""" + return (np.asarray(values, np.float64) * _stable_sigmoid(values)).astype(np.float32) diff --git a/tests/reference/raster_gate.py b/tests/reference/raster_gate.py new file mode 100644 index 0000000..7a4b7e8 --- /dev/null +++ b/tests/reference/raster_gate.py @@ -0,0 +1,37 @@ +"""NumPy mirrors of the raster lane's global average pooling and elementwise multiply. + +``global_avg_pool2d`` (``tensor_ee/blocks/raster/pooling.py``): each channel's float32 values +summed in double in row-major order, divided once by the exact count H W, rounded once to +float32 (the node boundary of policy ``float32_operands``). Earth Engine's accumulation order +and type for ``reduceResolution`` are not established; with inputs whose double sums are +exact (dyadic values of bounded magnitude), every order in double gives this result. + +``multiply`` (``tensor_ee/blocks/raster/merge.py``): the product of two float32 values in +double, rounded once to float32: the correctly rounded float32 product, in either operand +order. A ``[1, 1, C]`` gate broadcasts over the map's pixels. + +Arrays are [H, W, C]. Values only; nothing reads the library. +""" +from __future__ import annotations + +import numpy as np + + +def global_avg_pool2d(x) -> np.ndarray: + """[H, W, C] -> [1, 1, C] float32.""" + x = np.asarray(x, np.float32) + height, width, channels = x.shape + total = np.zeros(channels, np.float64) + for i in range(height): + for j in range(width): + total = total + x[i, j].astype(np.float64) + return (total / float(height * width)).astype(np.float32).reshape(1, 1, channels) + + +def multiply(x, other) -> np.ndarray: + """[H, W, C] times [H, W, C] or [1, 1, C] -> [H, W, C] float32.""" + x = np.asarray(x, np.float32) + other = np.asarray(other, np.float32) + if other.shape != x.shape and other.shape != (1, 1, x.shape[2]): + raise ValueError("multiply takes a same-shape operand or a [1, 1, C] gate") + return (x.astype(np.float64) * other.astype(np.float64)).astype(np.float32) diff --git a/tests/test_live_fixtures.py b/tests/test_live_fixtures.py index e71fe37..9d0e4ad 100644 --- a/tests/test_live_fixtures.py +++ b/tests/test_live_fixtures.py @@ -87,8 +87,9 @@ # block passed its separate SegFormer G3-live gate, and the bounded activations and the # standalone raster BatchNorm, raster bilinear upsampling and raster grouped convolution # theirs, and row concatenation, window depth-to-space and class-token attention -# theirs, and raster input standardization theirs; no offline-only block remains. -OFFLINE_ONLY_FIXED: set[str] = set() +# theirs, and raster input standardization theirs. Raster global average pooling and the +# elementwise multiply are offline only. +OFFLINE_ONLY_FIXED: set[str] = {"global_avg_pool2d", "multiply"} COMPOSITION_FIXTURES = {"window/concat_rows/prithvi-crop-logits", "window/concat_rows/prithvi-region-2x2", "window/concat_rows/vit-encoder-reduced", "window/concat_rows/vit-dpt-reduced"} """Runnable composition checks excluded from block coverage and evidence counts.""" @@ -735,7 +736,8 @@ def test_the_calibration_covers_every_fixed_block_of_phase_2(plan): offline = {name for (_, name), entry in catalogue.items() if entry.status.startswith("Fixed — offline only:")} assert offline == OFFLINE_ONLY_FIXED - new_covered = {name for f in select(["segformer_b0", "hard_activations", "batch_norm", "bilinear", "grouped_conv", "class_token", "prithvi_depth_to_space", "prithvi_settings", "prithvi_model", "standardize", "vit_layer_norm", "class_token_query_scale"]) + new_covered = {name for f in select(["segformer_b0", "hard_activations", "batch_norm", "bilinear", "grouped_conv", "class_token", "prithvi_depth_to_space", "prithvi_settings", "prithvi_model", "standardize", "vit_layer_norm", "class_token_query_scale", + "global_pool_multiply"]) if f.id not in COMPOSITION_FIXTURES and f.mirror is not None and not f.calibration for name in (f.block, *f.parts)} assert (SEGFORMER_G3_BLOCKS | HARD_ACTIVATION_G3_BLOCKS | BATCH_NORM_G3_BLOCKS | BILINEAR_G3_BLOCKS @@ -874,3 +876,48 @@ def test_conv3x3_stride2_fixture_ids_and_data_are_stable(): for fixture in fixtures: data = fixture.data(fixture.seed) assert (data.inputs_sha256, data.weights_sha256) == expected[fixture.id] + + +def test_global_pool_multiply_fixture_ids_and_data_are_stable(): + empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + expected = { + "raster/global_avg_pool2d/h5-w7": ("53e3cb2954d467e11eeab6a7e27e84a4e615ab1ab6f2ea2d48b782606baab601", empty), + "raster/global_avg_pool2d/h8-w8": ("78debda045f0499f6d23bb1aaa5f28d02bc037dc29c8e5937337c848e032afba", empty), + "raster/global_avg_pool2d/h64-w64": ("c2f04a66246d2ac1f6674720ce70a4eb6b3e2932ca55a8d82254ebc8dd588ee5", empty), + "raster/global_avg_pool2d/h128-w128": ("d474c9442b8e2026dcb5ae92ec1e24616870e08bb2cc297972bb0c0eeb4da2b9", + empty), + "raster/multiply/same-grid-h5-w7": ("1d0d6df749ef7661b1947aa4a282de2812709e35cc6731ca97be61145d9f2440", empty), + "raster/multiply/gate-h5-w7": ("25dfbc44d7b6563d6c031d40e4e04f4ad1828da0640b8473bd1c47b761b87417", empty), + "raster/multiply/squeeze-excitation-h12-w20": ( + "079a42c9badf04f0a6d6561ba3e02c67953c129b9d3dc49a92137233dc44cb59", + "1bb7efa27be975c4bc2c363307fb8ebdd6db43c85b32486efbc2e7bdf162f657"), + } + fixtures = select(["global_pool_multiply"]) + assert [fixture.id for fixture in fixtures] == list(expected) + for fixture in fixtures: + data = fixture.data(fixture.seed) + assert (data.inputs_sha256, data.weights_sha256) == expected[fixture.id] + # Integer-generated: no exact zero in a gate or a product operand. + assert all(np.count_nonzero(value == 0) == 0 for value in data.inputs.values()), fixture.id + + +def test_only_the_128_global_pool_fixture_raises_the_raster_grid(): + """The maintainer's allowance is one fixture's own request-grid side (128); the budget's + default stays 64 for every other fixture, and a lowered --budget applies to that one too.""" + raised = {f.id: f.raster_side for f in registry().values() if f.raster_side is not None} + assert raised == {"raster/global_avg_pool2d/h128-w128": 128} + assert LF.DEFAULT_BUDGET.raster_side == 64 and core.RAISED_RASTER_SIDE == 128 + fixture = registry()["raster/global_avg_pool2d/h128-w128"] + plan = LF.make_plan([fixture]) + assert plan.violations == [] and max(r.width for r in plan.all_requests) == 128 + lowered = LF.make_plan([fixture], LF.parse_budget("raster_side=63")) + assert any("exceeds 63 per side" in v for v in lowered.violations) + plain = dataclasses.replace(fixture, raster_side=None) + assert any("exceeds 64 per side" in v for v in LF.make_plan([plain]).violations) + assert fixture.record()["raster_side"] == 128 + assert all("raster_side" not in f.record() for f in registry().values() if f.id != fixture.id) + for bad in (129, 0, True, 64.0): + with pytest.raises(ValueError, match="raster_side"): + dataclasses.replace(fixture, raster_side=bad) + with pytest.raises(ValueError, match="raster_side"): + dataclasses.replace(registry()["window/patch_merge/w7"], raster_side=64) diff --git a/tests/test_raster_blocks.py b/tests/test_raster_blocks.py index 2d4b0b1..ab5b498 100644 --- a/tests/test_raster_blocks.py +++ b/tests/test_raster_blocks.py @@ -94,7 +94,7 @@ # The public R blocks: the names of the moved map targets. BLOCKS = {"conv2d": "conv", "pool2d": "pooling", "nearest_upsample": "resample", - "grouped_conv2d": "conv", + "grouped_conv2d": "conv", "global_avg_pool2d": "pooling", "multiply": "merge", "bilinear_upsample": "resample", "batch_norm": "normalization", "standardize": "normalization", "concatenate": "merge", "residual_add": "merge", @@ -171,7 +171,8 @@ def test_every_map_entry_owned_by_l03_is_at_its_target(): assert BLOCKS[name] == path.split("/")[-1][:-3] # Later blocks are additions, so the historical module map does not list them. assert {e["target"].split("::")[1] for e in moved} == set(BLOCKS) - { - "relu6", "hardsigmoid", "hardswish", "batch_norm", "bilinear_upsample", "grouped_conv2d", "standardize"} + "relu6", "hardsigmoid", "hardswish", "batch_norm", "bilinear_upsample", "grouped_conv2d", "standardize", + "global_avg_pool2d", "multiply"} for entry in stays: path, name = entry["target"].split("::") assert entry["id"].split(":")[0] == path # the converter stays where it is @@ -205,6 +206,8 @@ def test_public_api_names_resolve_to_the_same_objects(): POSITIONAL, KEYWORD = inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY _EMPTY = inspect.Parameter.empty BASE_PARAMETERS = { + "global_avg_pool2d": [("raster", POSITIONAL, _EMPTY), ("clip_footprints", KEYWORD, False)], + "multiply": [("x", POSITIONAL, _EMPTY), ("other", POSITIONAL, _EMPTY), ("clip_footprints", KEYWORD, False)], "grouped_conv2d": [("raster", POSITIONAL, _EMPTY), ("module", POSITIONAL, _EMPTY), ("parameters", POSITIONAL, None), ("clip_footprints", KEYWORD, False)], "bilinear_upsample": [("raster", POSITIONAL, _EMPTY), ("constants", POSITIONAL, _EMPTY), @@ -289,6 +292,9 @@ def _grouped_fixture(): CALLS = { # block name -> call(function, fixture, **policy) + "global_avg_pool2d": lambda f, x, **kw: f(x[1], **kw), + # Both forms: the same grid, and the one-pixel gate pooled from the map. + "multiply": lambda f, x, **kw: (f(x[1], x[1], **kw), f(x[1], PL.global_avg_pool2d(x[1]), **kw)), "grouped_conv2d": lambda f, x, **kw: f(x[1], _grouped_fixture(), **kw), "bilinear_upsample": lambda f, x, **kw: f(x[1], RS.BilinearConstants( np.repeat(np.eye(4, dtype=np.float32), 2, axis=0), @@ -367,6 +373,11 @@ def test_compile_model_keeps_the_per_node_float32_cast(offline_ee): # Metadata: every public R block, locality from the inventory and receptive field # --------------------------------------------------------------------------- EXPECTED = { # block -> (locality, radius, status) + "global_avg_pool2d": (M.Locality.EXTENT, None, + "Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False"), + "multiply": (M.Locality.POINTWISE, None, + "Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; " + "clip_footprints=False"), "grouped_conv2d": (M.Locality.KERNEL, 2, "Ready — depthwise 3×3 stride 1 or 2, 5×5 stride 1; 3×3 stride 1 with 2 channels per group or channel multiplier 2"), "bilinear_upsample": (M.Locality.KERNEL, 1, "Ready — factors 2, 3, 4 (align_corners=False), 2, 4 (align_corners=True)"), diff --git a/tests/test_raster_global_pool_multiply.py b/tests/test_raster_global_pool_multiply.py new file mode 100644 index 0000000..af7d8b3 --- /dev/null +++ b/tests/test_raster_global_pool_multiply.py @@ -0,0 +1,365 @@ +"""Raster global average pooling and the elementwise multiply, offline. + +- ``global_avg_pool2d`` shares ``pool2d``'s aligned expression (``_aligned_reduce``): on a + k x k map it is byte-identical to ``pool2d(AvgPool2d(k))``; on any map its output is on + ``one_pixel(grid)``, the one pixel covering exactly the map. +- ``multiply`` accepts a same-grid operand or a one-pixel gate on the map's own one-pixel + grid, with the map's channel count, and refuses everything else. +- ``compile_model`` recognizes ``AdaptiveAvgPool2d(1)``, ``F.adaptive_avg_pool2d(x, 1)``, + ``torch.mean``/``Tensor.mean`` over (2, 3) with ``keepdim=True``, and ``*``, ``torch.mul`` + and ``Tensor.mul``, in either operand order; the gate's convolutions and activations run + on the one-pixel grid. +- ``analyze_receptive_field`` lists a global mean in ``extent_nodes`` and refuses every + halo, so tiled compilation refuses the model; ``check_compatibility`` reports the stand-ins + supported and the EfficientNet-B3 U-Net's pools and products no longer blocked. + +Graphs are built against ``ee.apitestcase.ApiTestCase``; no Earth Engine request. +""" +from __future__ import annotations + +import ee +from ee.apitestcase import ApiTestCase +import numpy as np +import pytest +import torch +from torch import nn +from torch.nn import functional as F + +from tensor_ee import Grid, Raster, UnsupportedOperation, check_compatibility, compile_model, from_image +from tensor_ee.blocks.raster import activations as A, merge as MG, pooling as PL +from tensor_ee.core.grid import one_pixel +from tensor_ee.core.metadata import NOT_LIVE, Locality +from tensor_ee.receptive_field import UnsupportedReceptiveField, analyze_receptive_field +from tensor_ee.tiling import compile_tiled_model, plan_tiles +from reference.raster_gate import global_avg_pool2d as pool_mirror, multiply as multiply_mirror + + +@pytest.fixture +def offline_ee(): + case = ApiTestCase() + case.setUp() + try: + yield + finally: + case.tearDown() + + +def grid(width=7, height=5, scale=10): + return Grid("EPSG:3857", (scale, 0, 1000, 0, -scale, 2000), width, height) + + +def raster(g, channels=2): + names = [f"b{i}" for i in range(channels)] + return from_image(ee.Image.constant([0.0] * channels).rename(names), names, g) + + +# --------------------------------------------------------------------------- +# one_pixel and global_avg_pool2d +# --------------------------------------------------------------------------- +def test_one_pixel_covers_exactly_the_map(): + g = grid(7, 5) + one = one_pixel(g) + assert (one.width, one.height, one.crs) == (1, 1, g.crs) + assert one.transform == (70, 0, 1000, 0, -50, 2000) # a non-square pixel for a non-square map + for k in (2, 3, 4, 128): + square = grid(k, k) + assert one_pixel(square) == square.resized(k, 1, 1) + assert one_pixel(square).transform == square.resized(k, 1, 1).transform + assert one_pixel(one) == one + + +@pytest.mark.parametrize("k", [2, 3, 4]) +def test_global_pool_of_a_k_by_k_map_is_pool2d_avg_k(offline_ee, k): + """One implementation: the aligned k x k average and the global mean of a k x k map are + the same expression, byte for byte.""" + x = raster(grid(k, k)) + pooled, aligned = PL.global_avg_pool2d(x), PL.pool2d(x, nn.AvgPool2d(k)) + assert pooled.image.serialize() == aligned.image.serialize() + assert (pooled.bands, pooled.grid) == (aligned.bands, aligned.grid) + + +def test_global_pool_reduces_onto_the_one_pixel_grid(offline_ee): + x = raster(grid(20, 12), 3) + pooled = PL.global_avg_pool2d(x) + assert pooled.grid == one_pixel(x.grid) and pooled.bands == x.bands + text = pooled.image.serialize() + assert text.count('"Image.reduceResolution"') == 1 and '"Reducer.mean"' in text + assert "308" in text # maxPixels (12 + 2)(20 + 2) + with pytest.raises(UnsupportedOperation, match="raster tensor"): + PL.global_avg_pool2d(ee.Image.constant(0)) + + +def test_metadata_is_offline_only_and_extent(): + pool, product = PL.METADATA["global_avg_pool2d"], MG.METADATA["multiply"] + assert pool.locality is Locality.EXTENT and product.locality is Locality.POINTWISE + assert pool.validated == product.validated == NOT_LIVE + assert not pool.chip_edge_dependent and not product.chip_edge_dependent + for entry in (pool, product): + assert entry.status.startswith("Fixed — offline only: ") and "Ready" not in entry.status + assert entry.embedded_constants() == entry.asset_arrays() == 0 + + +# --------------------------------------------------------------------------- +# multiply: the two forms and the refusals +# --------------------------------------------------------------------------- +def test_multiply_same_grid_and_one_pixel_gate(offline_ee): + x = raster(grid()) + same = MG.multiply(x, x) + assert (same.bands, same.grid) == (x.bands, x.grid) + gate = PL.global_avg_pool2d(x) + gated = MG.multiply(x, gate) + assert (gated.bands, gated.grid) == (x.bands, x.grid) + text = gated.image.serialize() + assert text.count('"Image.multiply"') == 1 and '"Image.reproject"' in text + + +def test_multiply_refuses_every_other_form(offline_ee): + x = raster(grid()) + gate = PL.global_avg_pool2d(x) + other_map = PL.global_avg_pool2d(raster(grid(8, 5))) # a gate pooled from another map + with pytest.raises(UnsupportedOperation, match="scalar"): + MG.multiply(x, 2.0) + with pytest.raises(UnsupportedOperation, match="no channel broadcasting"): + MG.multiply(x, raster(grid(), 1)) # a [N, 1, H, W] spatial gate + with pytest.raises(UnsupportedOperation, match="no channel broadcasting"): + MG.multiply(x, PL.global_avg_pool2d(raster(grid(), 3))) + with pytest.raises(UnsupportedOperation, match="neither"): + MG.multiply(x, other_map) + with pytest.raises(UnsupportedOperation, match="neither"): + MG.multiply(x, raster(grid(scale=20))) + with pytest.raises(UnsupportedOperation, match="neither"): + MG.multiply(gate, x) # the block takes the map first + + +def test_relu_runs_on_the_one_pixel_grid(offline_ee): + pooled = PL.global_avg_pool2d(raster(grid(20, 12))) + out = A.relu(pooled) + assert out.grid == one_pixel(grid(20, 12)) and out.bands == pooled.bands + + +# --------------------------------------------------------------------------- +# Mirrors (NumPy) against PyTorch, locally +# --------------------------------------------------------------------------- +def test_mirrors_agree_with_pytorch(): + from tools.fixtures.core import dyadic, hwc, nchw + + x = dyadic(3, "x", (12, 20, 3), scale=4.0, offset=2.0) + g = dyadic(3, "g", (1, 1, 3), scale=0.25, offset=0.5, bits=23) + with torch.no_grad(): + pooled = hwc(F.adaptive_avg_pool2d(nchw(x), 1)) + product = hwc(nchw(g) * nchw(x)) + assert np.abs(pool_mirror(x).astype(np.float64) - pooled).max() <= 2.4e-7 + exact = np.add.reduce(x.astype(np.float64), axis=(0, 1)) / (12 * 20) + assert np.array_equal(pool_mirror(x)[0, 0], exact.astype(np.float32)) + assert np.array_equal(multiply_mirror(x, g), product) # the same correctly rounded product + assert np.array_equal(multiply_mirror(x, g), multiply_mirror(x, np.broadcast_to(g, x.shape))) + with pytest.raises(ValueError): + multiply_mirror(x, g[:, :, :1]) + + +# --------------------------------------------------------------------------- +# compile_model: recognition, operand order, refusals +# --------------------------------------------------------------------------- +class Functional(nn.Module): + def __init__(self, function, *modules): + super().__init__() + self.layers = nn.ModuleList(modules) + self.function = function + + def forward(self, x): + return self.function(self, x) + + +def squeeze_excitation(left=True, activation=None): + """A squeeze-excitation stand-in with nn.Conv2d 1 x 1 (SMP MBConv's order: sigmoid(g) * x).""" + class SqueezeExcite(nn.Module): + def __init__(self): + super().__init__() + self.reduce = nn.Conv2d(4, 2, 1) + self.act = activation or nn.SiLU() + self.expand = nn.Conv2d(2, 4, 1) + + def forward(self, x): + gate = torch.sigmoid(self.expand(self.act(self.reduce(F.adaptive_avg_pool2d(x, 1))))) + return gate * x if left else x * gate + + model = SqueezeExcite() + with torch.no_grad(): + for index, parameter in enumerate(model.parameters()): + parameter.copy_(torch.arange(parameter.numel(), dtype=torch.float32).reshape(parameter.shape) / 16 + - index / 8) + return model.eval() + + +def compile_on(model, g, channels=4): + names = [f"b{i}" for i in range(channels)] + return compile_model(model, ee.Image.constant([0.0] * channels).rename(names), names, g) + + +def test_every_global_mean_form_compiles_to_the_same_graph(offline_ee): + g = grid(20, 12) + forms = [nn.AdaptiveAvgPool2d(1), nn.AdaptiveAvgPool2d((1, 1)), + Functional(lambda self, x: F.adaptive_avg_pool2d(x, 1)), + Functional(lambda self, x: F.adaptive_avg_pool2d(x, [1, 1])), + Functional(lambda self, x: torch.mean(x, (2, 3), keepdim=True)), + Functional(lambda self, x: torch.mean(x, dim=[-1, -2], keepdim=True)), + Functional(lambda self, x: x.mean((3, 2), keepdim=True)), + Functional(lambda self, x: x.mean(dim=(-2, -1), keepdim=True))] + graphs = set() + for model in forms: + compiled = compile_on(model.eval(), g) + assert compiled.grid == one_pixel(g) and compiled.bands == ("c0", "c1", "c2", "c3") + graphs.add(compiled.image.serialize()) + assert len(graphs) == 1 + nodes = compile_on(nn.Identity().eval(), g).nodes + placeholder = nodes[next(iter(nodes))] # the float32 input node + assert graphs == {PL.global_avg_pool2d(placeholder).image.toFloat().serialize()} + + +def test_every_multiply_form_and_order_compiles_to_the_same_graph(offline_ee): + g = grid(20, 12) + def graphs(*functions): + return {compile_on(Functional(f).eval(), g).image.serialize() for f in functions} + + # Same grid: the call forms agree for one operand order (the order is kept: either + # graph gives the same correctly rounded products). + assert len(graphs(lambda self, x: x * torch.sigmoid(x), lambda self, x: torch.mul(x, torch.sigmoid(x)), + lambda self, x: x.mul(torch.sigmoid(x)), lambda self, x: x.mul(other=torch.sigmoid(x)))) == 1 + assert len(graphs(lambda self, x: torch.sigmoid(x) * x, lambda self, x: torch.mul(torch.sigmoid(x), x))) == 1 + # A one-pixel gate: the map is x in either operand order, so the graphs are identical. + left, right = squeeze_excitation(True), squeeze_excitation(False) + a, b = compile_on(left, g), compile_on(right, g) + assert a.image.serialize() == b.image.serialize() + assert (a.grid, a.bands) == (g, ("c0", "c1", "c2", "c3")) + gate = a.nodes["sigmoid"] + assert gate.grid == one_pixel(g) and a.nodes["reduce"].grid == one_pixel(g) + assert a.nodes["adaptive_avg_pool2d"].grid == one_pixel(g) + + +def test_squeeze_excitation_with_relu_compiles_on_the_one_pixel_grid(offline_ee): + compiled = compile_on(squeeze_excitation(activation=nn.ReLU()), grid(16, 16)) + assert compiled.nodes["act"].grid == one_pixel(grid(16, 16)) + assert compiled.grid == grid(16, 16) + + +@pytest.mark.parametrize("function, message", [ + (lambda self, x: F.adaptive_avg_pool2d(x, 2), "only to one pixel"), + (lambda self, x: F.adaptive_avg_pool2d(x, (1, None)), "only to one pixel"), + (lambda self, x: F.adaptive_avg_pool2d(x, True), "only to one pixel"), + (lambda self, x: F.adaptive_max_pool2d(x, 1), "Unsupported function"), + (lambda self, x: torch.amax(x, (2, 3), keepdim=True), "Unsupported function"), + (lambda self, x: x.mean((2, 3)), "keepdim=True"), + (lambda self, x: torch.mean(x, (2, 3), keepdim=False), "keepdim=True"), + (lambda self, x: x.mean((1, 2), keepdim=True), "both spatial axes"), + (lambda self, x: x.mean(3, keepdim=True), "both spatial axes"), + (lambda self, x: x.mean((True, 3), keepdim=True), "both spatial axes"), + (lambda self, x: x.mean((2, 3), keepdim=1), "keepdim=True"), + (lambda self, x: x.mean((2, 3), keepdim=True, dtype=torch.float32), "dtype=None"), + (lambda self, x: x * 2, "tensor inputs"), + (lambda self, x: torch.mul(x, 0.5), "tensor inputs"), + (lambda self, x: x * x[:, :1], "getitem"), +]) +def test_unsupported_pooling_and_products_are_refused(offline_ee, function, message): + with pytest.raises(UnsupportedOperation, match=message): + compile_on(Functional(function).eval(), grid(8, 8)) + + +def test_broadcasts_and_foreign_gates_are_refused(offline_ee): + channel_gate = Functional(lambda self, x: x * torch.sigmoid(self.layers[0](x)), nn.Conv2d(4, 1, 1)).eval() + with pytest.raises(UnsupportedOperation, match="Multiplication requires identical shapes"): + compile_on(channel_gate, grid(8, 8)) + # A gate pooled from the stride-2 map, multiplying the full map: the scales differ. + strided = Functional(lambda self, x: x * F.adaptive_avg_pool2d(self.layers[0](x), 1), + nn.Conv2d(4, 4, 3, stride=2, padding=1)).eval() + with pytest.raises(UnsupportedOperation, match="Multiplication requires identical shapes"): + compile_on(strided, grid(8, 8)) + # Same scale and shape relation, another map: 15 -> 8 (stride 2) -> 16 (x 2), pooled, times + # the 15 x 15 input. The shapes pass; the block's grid check refuses the foreign gate. + foreign = Functional(lambda self, x: x * F.adaptive_avg_pool2d( + F.interpolate(self.layers[0](x), scale_factor=2, mode="nearest"), 1), + nn.Conv2d(4, 4, 3, stride=2, padding=1)).eval() + with pytest.raises(UnsupportedOperation, match="neither"): + compile_on(foreign, grid(15, 15)) + with pytest.raises(UnsupportedOperation, match="Unsupported module AdaptiveMaxPool2d"): + compile_on(nn.Sequential(nn.AdaptiveMaxPool2d(1)).eval(), grid(8, 8)) + + +# --------------------------------------------------------------------------- +# Receptive field and tiling: a global mean needs the whole chip +# --------------------------------------------------------------------------- +def test_receptive_field_lists_the_global_mean_and_refuses_halos(): + report = analyze_receptive_field(squeeze_excitation()) + assert report.extent_nodes == ("adaptive_avg_pool2d",) + for halo in ("safe_halo", "halo_for_any_phase"): + with pytest.raises(UnsupportedReceptiveField, match="whole chip"): + getattr(report, halo) + metadata = report.as_dict() + assert metadata["safe_halo"] is metadata["halo_for_any_phase"] is None + assert metadata["extent_nodes"] == ["adaptive_avg_pool2d"] + assert metadata["context_requirement"] == "full source extent required" + root = analyze_receptive_field(nn.AdaptiveAvgPool2d((1, 1))) + assert root.extent_nodes == ("_0",) + # Without a global mean nothing changes: no extent_nodes key. + assert "extent_nodes" not in analyze_receptive_field(nn.Conv2d(2, 2, 3, padding=1)).as_dict() + + +def test_tiled_compilation_refuses_a_model_with_a_global_mean(offline_ee): + plan = plan_tiles(grid(16, 16), 8, 4, alignment=1) + assert len(plan.tiles) == 4 + with pytest.raises(UnsupportedReceptiveField, match="whole chip"): + compile_tiled_model(squeeze_excitation(), ee.Image.constant([0.0] * 4).rename(["a", "b", "c", "d"]), + ["a", "b", "c", "d"], plan) + + +# --------------------------------------------------------------------------- +# Compatibility +# --------------------------------------------------------------------------- +class MBConv(nn.Module): + """An EfficientNet MBConv stand-in with module convolutions: expand 1 x 1, depthwise 3 x 3, + squeeze-excitation (SiLU), project 1 x 1, BatchNorm after each convolution, identity skip.""" + + def __init__(self, channels=4, expanded=8, squeezed=2): + super().__init__() + self.expand = nn.Sequential(nn.Conv2d(channels, expanded, 1, bias=False), nn.BatchNorm2d(expanded), nn.SiLU()) + self.depthwise = nn.Sequential(nn.Conv2d(expanded, expanded, 3, padding=1, groups=expanded, bias=False), + nn.BatchNorm2d(expanded), nn.SiLU()) + self.se_reduce, self.se_act = nn.Conv2d(expanded, squeezed, 1), nn.SiLU() + self.se_expand = nn.Conv2d(squeezed, expanded, 1) + self.project = nn.Sequential(nn.Conv2d(expanded, channels, 1, bias=False), nn.BatchNorm2d(channels)) + + def forward(self, x): + y = self.depthwise(self.expand(x)) + gate = self.se_expand(self.se_act(self.se_reduce(F.adaptive_avg_pool2d(y, 1)))) + return self.project(torch.sigmoid(gate) * y) + x + + +def test_compatibility_reports_the_stand_ins_supported(offline_ee): + for model, shape, pooled in ((squeeze_excitation(), (1, 4, 12, 20), 4), (MBConv().eval(), (1, 4, 16, 16), 8)): + report = check_compatibility(model, shape).to_dict() + assert report["status"] == "supported", report["blockers"] + assert report["output_shape"] == list(shape) + assert any("global pooling" in restriction for restriction in report["restrictions"]) + nodes = {node["name"]: node for node in report["nodes"]} + assert nodes["adaptive_avg_pool2d"]["output_shape"] == [1, pooled, 1, 1] + compiled = compile_on(MBConv().eval(), grid(16, 16)) + assert compiled.grid == grid(16, 16) + assert "global pooling" not in " ".join(check_compatibility(nn.Conv2d(2, 2, 1).eval(), (1, 2, 8, 8)) + .to_dict()["restrictions"]) + + +def test_efficientnet_b3_unet_pools_and_products_are_no_longer_blocked(): + """Trace only, no forward: SMP 0.5.0's EfficientNet-B3 U-Net at 256 px keeps its other + blockers (functional conv2d with get_attr weights, ZeroPad2d, in-place ReLU, ...), but its 26 + squeeze-excitation pools and products are no longer among them.""" + smp = pytest.importorskip("segmentation_models_pytorch") + model = smp.Unet("efficientnet-b3", encoder_weights=None, in_channels=8, classes=3).eval() + report = check_compatibility(model, (1, 8, 256, 256)).to_dict() + nodes = {node["name"]: node for node in report["nodes"]} + pools = [n for n in nodes.values() if n["target"].endswith("functional.adaptive_avg_pool2d")] + products = [n for n in nodes.values() if n["target"] == "_operator.mul"] + assert len(pools) == len(products) == 26 + blocked = {b["node"] for b in report["blockers"]} + assert not blocked & {n["name"] for n in pools + products} + assert all(n["status"] != "unsupported" for n in pools + products) + assert not any("adaptive_avg_pool2d" in b["message"] or "mul" in b["message"].split() for b in report["blockers"]) + assert report["status"] == "unsupported" # the functional convolutions remain diff --git a/tests/test_receptive_field.py b/tests/test_receptive_field.py index bdb0c63..c06f5e0 100644 --- a/tests/test_receptive_field.py +++ b/tests/test_receptive_field.py @@ -79,7 +79,10 @@ def test_standalone_primitive_is_wrapped_like_the_compiler(): @pytest.mark.parametrize("module", [ - nn.AdaptiveAvgPool2d((1, 1)), + # Global pooling (output size 1) is analysed, listed in extent_nodes and refused a halo + # (tests/test_raster_global_pool_multiply.py); other adaptive sizes stay unsupported. + nn.AdaptiveAvgPool2d((2, 2)), + nn.AdaptiveMaxPool2d((1, 1)), nn.Upsample(scale_factor=2, mode="bicubic", align_corners=False), nn.Upsample(size=(16, 16), mode="nearest"), nn.Upsample(scale_factor=1.5, mode="nearest"), diff --git a/tests/test_x02b_validated_settings.py b/tests/test_x02b_validated_settings.py index de2dcce..22ac896 100644 --- a/tests/test_x02b_validated_settings.py +++ b/tests/test_x02b_validated_settings.py @@ -91,13 +91,14 @@ def _blocks() -> dict: # --------------------------------------------------------------------------- # The record names real parameters # --------------------------------------------------------------------------- -OFFLINE_ONLY: set[str] = set() +OFFLINE_ONLY: set[str] = {"raster/global_avg_pool2d", "raster/multiply"} """The blocks with no live validation yet, by lane-qualified key (two lanes may share a block -name): their status is "Fixed — offline only" and their record is ``NOT_LIVE``. None today.""" +name): their status is "Fixed — offline only" and their record is ``NOT_LIVE``. Raster global +average pooling and the elementwise multiply have offline fixtures only.""" def test_every_block_has_a_record(): - assert len(BLOCKS) == 88 + assert len(BLOCKS) == 90 for key, entry in ENTRIES.items(): assert isinstance(entry.validated, Validated), key offline = key in OFFLINE_ONLY diff --git a/tools/fixtures/core.py b/tools/fixtures/core.py index 69a7b8c..2fd4e46 100644 --- a/tools/fixtures/core.py +++ b/tools/fixtures/core.py @@ -36,6 +36,11 @@ TOLERANCE = 1e-4 """The unchanged 1e-4 absolute target against PyTorch (a fixture may set a smaller one).""" +RAISED_RASTER_SIDE = 128 +"""The largest raster-lane request-grid side one fixture may declare (``Fixture.raster_side``), +above the budget's 64: the maintainer's allowance for one global-pooling fixture on a +128 x 128 map, the largest map squeeze-excitation pools in the models it serves.""" + LANES = ("raster", "window") KINDS = ("output", "input") _ID = re.compile(r"^[a-z]+(/[a-z0-9_.-]+)+$") @@ -223,6 +228,9 @@ class Fixture: window: int = 7 calibration: bool = False notes: str = "" + raster_side: int | None = None + """A raster-lane fixture's own request-grid side, above the budget's default, by the + maintainer's allowance (at most ``RAISED_RASTER_SIDE``); None: the budget's.""" def __post_init__(self): if not _ID.match(self.id): @@ -240,16 +248,24 @@ def __post_init__(self): if self.lane == "window" and self.settings.get("window") != self.window: raise ValueError(f"{self.id}: a window-lane fixture's window ({self.window}) must equal " f"settings['window'] ({self.settings.get('window')!r})") + if self.raster_side is not None and ( + self.lane != "raster" or isinstance(self.raster_side, bool) or not isinstance(self.raster_side, int) + or not 0 < self.raster_side <= RAISED_RASTER_SIDE): + raise ValueError(f"{self.id}: raster_side is a raster-lane fixture's request-grid side, " + f"an integer of at most {RAISED_RASTER_SIDE}") def record(self) -> dict: """The spec without its callables (evidence and ``--list``).""" - return {"id": self.id, "lane": self.lane, "block": self.block, "parts": list(self.parts), - "settings": jsonable(self.settings), "policy": self.policy, - "shapes": jsonable(self.shapes), "seed": self.seed, "tolerance": self.tolerance, - "requests": self.requests, "reference": self.reference_source, - "mirror": None if self.mirror is None else { - "source": self.mirror.source, "exact": self.mirror.exact, "bound": self.mirror.bound}, - "history": self.history, "calibration": self.calibration, "notes": self.notes} + record = {"id": self.id, "lane": self.lane, "block": self.block, "parts": list(self.parts), + "settings": jsonable(self.settings), "policy": self.policy, + "shapes": jsonable(self.shapes), "seed": self.seed, "tolerance": self.tolerance, + "requests": self.requests, "reference": self.reference_source, + "mirror": None if self.mirror is None else { + "source": self.mirror.source, "exact": self.mirror.exact, "bound": self.mirror.bound}, + "history": self.history, "calibration": self.calibration, "notes": self.notes} + if self.raster_side is not None: + record["raster_side"] = self.raster_side + return record def jsonable(value): diff --git a/tools/fixtures/raster.py b/tools/fixtures/raster.py index 10cdc0b..9c154a7 100644 --- a/tools/fixtures/raster.py +++ b/tools/fixtures/raster.py @@ -2,7 +2,9 @@ (calibration), at additional pooling and nearest-resize settings (the selection ``p02``) and the bounded activations (``relu6``, ``hardsigmoid``, ``hardswish``; ``hard_activations``), standalone inference BatchNorm (``batch_norm``), integer bilinear resizing (``bilinear``), -and grouped/depthwise convolution (``grouped_conv``). +grouped/depthwise convolution (``grouped_conv``), and global average pooling with the +elementwise multiply, alone and as a squeeze-excitation gate through ``compile_model`` +(``global_pool_multiply``). Every fixture runs the library block in ``tensor_ee/blocks/raster`` plus float32 materialization (through the public facade where available, as ``compile_model`` @@ -61,9 +63,9 @@ def _echo(raster, source: str = "x") -> Readout: def _metadata(block: str): - from tensor_ee.blocks.raster import activations, conv, normalization, pooling, resample + from tensor_ee.blocks.raster import activations, conv, merge, normalization, pooling, resample - for module in (conv, pooling, resample, activations, normalization): + for module in (conv, pooling, resample, activations, normalization, merge): if block in module.METADATA: return module.METADATA[block] raise KeyError(block) @@ -799,6 +801,259 @@ def _standardize_fixtures(cost) -> list[Fixture]: ) for index, spec in enumerate(_STANDARDIZE)] +# --------------------------------------------------------------------------- +# Global average pooling and the elementwise multiply (selection global_pool_multiply) +# --------------------------------------------------------------------------- +_GATE_HISTORY = "none: a new block, offline evidence only" +_POOL_BOUND = 1e-6 +"""The pool mirror's recorded bound against Earth Engine: one float32 step of a mean of +magnitude at most 6 is 4.8e-7. Every double sum of these inputs is exact (multiples of 2^-21 +below 2^17 in magnitude), so a double accumulation in any order equals the mirror; the bound +allows one rounding step, and Earth Engine's order is recorded rather than required.""" +_GATE_BOUND = 1e-5 +"""The squeeze-excitation mirror's recorded bound against Earth Engine: a float32 step at each +node boundary of the gate (|gate| < 1, about 6e-8 each) times |x| <= 6 stays below 1e-6; the +bound leaves ten times that for the convolutions' accumulation order and ``exp``.""" + + +@dataclass(frozen=True) +class _GlobalPool: + """One global-pooling fixture: the map's shape, and its own request-grid side.""" + + name: str + seed: int + shape: tuple # (H, W); two channels + raster_side: int | None = None + + +_GLOBAL_POOLS = ( + _GlobalPool("h5-w7", 71, (5, 7)), + _GlobalPool("h8-w8", 72, (8, 8)), + _GlobalPool("h64-w64", 73, (64, 64)), + # The maintainer's allowance: the one fixture on a 128 x 128 map (the largest map a + # squeeze-excitation gate pools in the EfficientNet-B3 U-Net at 256 px); maxPixels 16,900. + _GlobalPool("h128-w128", 74, (128, 128), raster_side=128), +) + + +def _gate_values(seed: int, stream: str, shape) -> np.ndarray: + """Dyadic values in [-2, 6): |x| <= 6, nonzero means, sums exact in double.""" + return dyadic(seed, stream, shape, scale=4.0, offset=2.0) + + +def _global_pool_data(spec: _GlobalPool): + def data(seed: int) -> FixtureData: + return FixtureData(inputs={"x": _gate_values(seed, "x", (*spec.shape, 2))}, weights={}) + return data + + +def _global_pool_build(spec: _GlobalPool): + def build(ee, data: FixtureData) -> list: + from tensor_ee.blocks.raster.pooling import global_avg_pool2d + from tensor_ee.layouts.raster import _float32 + + raster = _input(ee, _rect_grid(10, *spec.shape), data.inputs["x"]) + # The library block plus the node's float32 materialization, as compile_model runs it. + out = _float32(global_avg_pool2d(raster, policy=POLICY)) + return [Readout("out", out.image, 2, raster_grid(out.grid)), _echo(raster)] + return build + + +def _global_pool_reference(data: FixtureData) -> dict: + import torch + from torch.nn import functional as F + + with torch.no_grad(): + return {"out": hwc(F.adaptive_avg_pool2d(nchw(data.inputs["x"]), 1))} + + +def _global_pool_mirror(data: FixtureData) -> dict: + from reference.raster_gate import global_avg_pool2d + + return {"out": global_avg_pool2d(data.inputs["x"])} + + +def _multiply_data(gate: bool): + def data(seed: int) -> FixtureData: + x = dyadic(seed, "x", (5, 7, 2), scale=8.0) + # The other operand: a gate in [0.25, 0.75) (never zero) or a same-grid map. + other = (dyadic(seed, "g", (1, 1, 2), scale=0.25, offset=0.5, bits=23) if gate + else dyadic(seed, "y", (5, 7, 2), scale=2.0)) + return FixtureData(inputs={"x": x, "g" if gate else "y": other}, weights={}) + return data + + +def _multiply_build(gate: bool): + def build(ee, data: FixtureData) -> list: + from tensor_ee.blocks.raster.merge import multiply + from tensor_ee.core.grid import one_pixel + from tensor_ee.layouts.raster import _float32 + + grid = _rect_grid(10, 5, 7) + x = _input(ee, grid, data.inputs["x"]) + name = "g" if gate else "y" + other = _input(ee, one_pixel(grid) if gate else grid, data.inputs[name]) + out = _float32(multiply(x, other, policy=POLICY)) + return [Readout("out", out.image, 2, raster_grid(out.grid)), _echo(x), + Readout(f"input_{name}", other.image, 2, raster_grid(other.grid), "input", name)] + return build + + +def _multiply_reference(gate: bool): + def reference(data: FixtureData) -> dict: + import torch + + with torch.no_grad(): + x = nchw(data.inputs["x"]) + # The gate on the left, as squeeze-excitation writes sigmoid(g) * x. + return {"out": hwc(nchw(data.inputs["g"]) * x if gate else x * nchw(data.inputs["y"]))} + return reference + + +def _multiply_mirror(gate: bool): + def mirror(data: FixtureData) -> dict: + from reference.raster_gate import multiply + + return {"out": multiply(data.inputs["x"], data.inputs["g" if gate else "y"])} + return mirror + + +_SE_SHAPE = (12, 20, 4) +_SE_SQUEEZED = 2 +_SE_BANDS = ("b0", "b1", "b2", "b3") + + +def _se_data(seed: int) -> FixtureData: + channels, squeezed = _SE_SHAPE[2], _SE_SQUEEZED + return FixtureData(inputs={"x": _gate_values(seed, "x", _SE_SHAPE)}, weights={ + "reduce.weight": dyadic(seed, "reduce.weight", (squeezed, channels, 1, 1), scale=0.5), + "reduce.bias": dyadic(seed, "reduce.bias", (squeezed,), scale=0.125), + "expand.weight": dyadic(seed, "expand.weight", (channels, squeezed, 1, 1), scale=0.5), + "expand.bias": dyadic(seed, "expand.bias", (channels,), scale=0.125)}) + + +def _se_module(data: FixtureData): + """A squeeze-excitation stand-in: ``F.adaptive_avg_pool2d``, ``Conv2d`` 1 x 1, ``SiLU``, + ``Conv2d`` 1 x 1, ``torch.sigmoid``, then ``sigmoid(g) * x`` (the EfficientNet MBConv order, + with module convolutions).""" + import torch + from torch import nn + from torch.nn import functional as F + + class SqueezeExcite(nn.Module): + def __init__(self): + super().__init__() + self.reduce = nn.Conv2d(_SE_SHAPE[2], _SE_SQUEEZED, 1) + self.act = nn.SiLU() + self.expand = nn.Conv2d(_SE_SQUEEZED, _SE_SHAPE[2], 1) + + def forward(self, x): + pooled = F.adaptive_avg_pool2d(x, 1) + gate = torch.sigmoid(self.expand(self.act(self.reduce(pooled)))) + return gate * x + + module = SqueezeExcite() + with torch.no_grad(): + for name, value in module.named_parameters(): + value.copy_(torch.from_numpy(data.weights[name])) + return module.eval() + + +def _se_build(ee, data: FixtureData) -> list: + import tensor_ee + + grid = _rect_grid(10, *_SE_SHAPE[:2]) + image = inline_raster(ee, grid.projection(), data.inputs["x"], _SE_BANDS) + compiled = tensor_ee.compile_model(_se_module(data), image, _SE_BANDS, grid) + pooled, gate = compiled.nodes["adaptive_avg_pool2d"], compiled.nodes["sigmoid"] + one = raster_grid(pooled.grid) + return [Readout("out", compiled.image, 4, raster_grid(compiled.grid)), + Readout("input", compiled.nodes["x"].image, 4, raster_grid(grid), "input", "x"), + Readout("pooled", pooled.image, 4, one), Readout("gate", gate.image, 4, one)] + + +def _se_reference(data: FixtureData) -> dict: + import torch + from torch.nn import functional as F + + module = _se_module(data) + with torch.no_grad(): + x = nchw(data.inputs["x"]) + pooled = F.adaptive_avg_pool2d(x, 1) + gate = torch.sigmoid(module.expand(module.act(module.reduce(pooled)))) + return {"out": hwc(module(x)), "pooled": hwc(pooled), "gate": hwc(gate)} + + +def _se_mirror(data: FixtureData) -> dict: + from reference.activations import sigmoid, silu + from reference.raster_conv import grouped_conv2d + from reference.raster_gate import global_avg_pool2d, multiply + + w = data.weights + pooled = global_avg_pool2d(data.inputs["x"]) + reduced = grouped_conv2d(pooled, w["reduce.weight"], w["reduce.bias"], groups=1) + expanded = grouped_conv2d(silu(reduced), w["expand.weight"], w["expand.bias"], groups=1) + gate = sigmoid(expanded) + return {"out": multiply(data.inputs["x"], gate), "pooled": pooled, "gate": gate} + + +def _gate_fixtures(cost) -> list[Fixture]: + import tensor_ee + from tensor_ee.blocks.raster.merge import multiply + from tensor_ee.blocks.raster.pooling import global_avg_pool2d + + fixtures = [Fixture( + id=f"raster/global_avg_pool2d/{spec.name}", lane=LANE, block="global_avg_pool2d", + settings={"clip_footprints": False}, policy=POLICY, + shapes={"input": [*spec.shape, 2], "output": [1, 1, 2]}, seed=spec.seed, + data=_global_pool_data(spec), build=_global_pool_build(spec), reference=_global_pool_reference, + reference_source="torch.nn.functional.adaptive_avg_pool2d(x, 1)", + mirror=MirrorSpec(_global_pool_mirror, False, "reference.raster_gate.global_avg_pool2d", + bound=_POOL_BOUND), + requests=2, constants=cost("global_avg_pool2d", lambda data: ()), history=_GATE_HISTORY, + accepts=(global_avg_pool2d,), raster_side=spec.raster_side, + notes=(f"The mean of a {spec.shape[0]} x {spec.shape[1]} map onto its one-pixel grid " + f"(reduceResolution, maxPixels {(spec.shape[0] + 2) * (spec.shape[1] + 2):,}). Inputs in " + "[-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's " + "reduceResolution order is recorded, not required.") + + (" The input echo needs a 128 x 128 request grid: the maintainer's allowance for this " + "fixture alone." if spec.raster_side else "") + ) for spec in _GLOBAL_POOLS] + for gate in (False, True): + fixtures.append(Fixture( + id=f"raster/multiply/{'gate-h5-w7' if gate else 'same-grid-h5-w7'}", lane=LANE, block="multiply", + settings={"clip_footprints": False}, policy=POLICY, + shapes={"input": [5, 7, 2], "other": [1, 1, 2] if gate else [5, 7, 2], "output": [5, 7, 2]}, + seed=75 + gate, data=_multiply_data(gate), build=_multiply_build(gate), + reference=_multiply_reference(gate), + reference_source="torch: g * x, g of shape [1, 2, 1, 1]" if gate else "torch: x * y", + mirror=MirrorSpec(_multiply_mirror(gate), True, "reference.raster_gate.multiply"), + requests=2 if gate else 1, constants=cost("multiply", lambda data: ()), history=_GATE_HISTORY, + accepts=(multiply,), + notes=("A one-pixel gate in [0.25, 0.75) on the map's own one-pixel grid, broadcast over a " + "5 x 7 map; " if gate else "Two 5 x 7 maps on one grid; ") + + "one product of float32 values, rounded once: equal to PyTorch's bit for bit.")) + fixtures.append(Fixture( + id="raster/multiply/squeeze-excitation-h12-w20", lane=LANE, block="multiply", + parts=("global_avg_pool2d", "conv2d", "silu", "sigmoid"), + settings={"clip_footprints": False}, policy=POLICY, + shapes={"input": list(_SE_SHAPE), "squeezed": _SE_SQUEEZED, "pooled": [1, 1, _SE_SHAPE[2]], + "output": list(_SE_SHAPE)}, + seed=77, data=_se_data, build=_se_build, reference=_se_reference, + reference_source=("a squeeze-excitation stand-in: F.adaptive_avg_pool2d, Conv2d 1x1, SiLU, Conv2d 1x1, " + "torch.sigmoid, sigmoid(g) * x"), + mirror=MirrorSpec(_se_mirror, False, "reference.raster_gate with reference.raster_conv and " + "reference.activations", bound=_GATE_BOUND), + requests=2, + constants=lambda data: {"conv2d": sum(_metadata("conv2d").embedded_constants(m) for m in + (_se_module(data).reduce, _se_module(data).expand))}, + history=_GATE_HISTORY, accepts=(tensor_ee.compile_model, multiply, global_avg_pool2d), + notes=("compile_model on a non-square 12 x 20 map: the gate's 1 x 1 convolutions, SiLU and sigmoid " + "run on the map's one-pixel grid (a non-square pixel), and the gate multiplies the map. " + "The pooled mean and the gate are read on that grid as well."))) + return fixtures + + SELECTIONS = { # raster-setting validation: every new setting, plus today's pooling and nearest settings as regression checks # (three calibration fixtures). @@ -812,6 +1067,9 @@ def _standardize_fixtures(cost) -> list[Fixture]: for factor, align in _BILINEAR_SETTINGS), "grouped_conv": tuple(f"raster/grouped_conv2d/{spec.name}" for spec in _GROUPED), "standardize": tuple(f"raster/standardize/{spec.name}" for spec in _STANDARDIZE), + "global_pool_multiply": (*(f"raster/global_avg_pool2d/{spec.name}" for spec in _GLOBAL_POOLS), + "raster/multiply/same-grid-h5-w7", "raster/multiply/gate-h5-w7", + "raster/multiply/squeeze-excitation-h12-w20"), } """This lane's named selections (``tools.fixtures.select``).""" @@ -886,4 +1144,5 @@ def cost(block, args): notes="A copy: Earth Engine must equal PyTorch bit for bit (tolerance 0)."), ] return (calibration + _p02_fixtures(cost) + _hard_fixtures(cost) + _batch_norm_fixtures(cost) - + _bilinear_fixtures(cost) + _grouped_fixtures(cost) + _standardize_fixtures(cost)) + + _bilinear_fixtures(cost) + _grouped_fixtures(cost) + _standardize_fixtures(cost) + + _gate_fixtures(cost)) diff --git a/tools/live_fixtures.py b/tools/live_fixtures.py index 819353e..2534816 100644 --- a/tools/live_fixtures.py +++ b/tools/live_fixtures.py @@ -29,7 +29,8 @@ only ``algorithms.list`` (initialization, at most 2) and ``image.computePixels`` through. The budget is enforced in code: at most 40 requests per invocation; per request a grid -of at most 64 x 64 pixels (raster lane) or 8 x 8 windows (window lane), at most 1,000,000 +of at most 64 x 64 pixels (raster lane; one fixture declares 128 x 128 by the maintainer's +allowance, ``Fixture.raster_side``) or 8 x 8 windows (window lane), at most 1,000,000 output values and at most 2,000,000 bytes of request; zero assets and zero tasks; requests are sent one at a time (the limit is 4 in flight). ``--budget key=value,...`` may lower a limit, never raise it. A request's NPY response header (``npy_header_length``) also stays @@ -393,6 +394,10 @@ def check_budget(fixtures, requests: dict, budget: Budget) -> list[str]: if len(built) != fixture.requests: violations.append(f"{fixture.id}: declares {fixture.requests} requests but builds {len(built)}") limit = side_limit(fixture.lane, budget, fixture.window) + if fixture.raster_side is not None and budget.raster_side == DEFAULT_BUDGET.raster_side: + # The maintainer's allowance for this fixture alone (Fixture.raster_side); a + # lowered --budget raster_side applies to it as to every other fixture. + limit = fixture.raster_side for r in built: where = f"{fixture.id} request {r.index}" if r.width > limit or r.height > limit: From 0b47dabdac2b861cbf6676ecb4f44b1ff42c35b0 Mon Sep 17 00:00:00 2001 From: Noel Ivan Ulloa <127597880+noelEOS@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:02:44 +0800 Subject: [PATCH 2/4] Record the offline check of the global pooling and multiply fixtures tools/live_fixtures.py --offline global_pool_multiply on the previous commit (clean tree): 7 fixtures passed, zero cloud calls; largest mirror-vs-PyTorch error 2.38e-07. The catalogue now resolves the blocks' evidence. --- docs/building-blocks.md | 6 +- .../raster-global-pool-multiply-offline.json | 1011 +++++++++++++++++ 2 files changed, 1014 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/raster-global-pool-multiply-offline.json diff --git a/docs/building-blocks.md b/docs/building-blocks.md index fca831d..81d6bf4 100644 --- a/docs/building-blocks.md +++ b/docs/building-blocks.md @@ -136,13 +136,13 @@ their own. | [`conv2d`](../tensor_ee/blocks/raster/conv.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/primitives_float32.json](history/cnn/results/primitives_float32.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json); [cnn/results/tiled_resnet18_2x2_parity.json](history/cnn/results/tiled_resnet18_2x2_parity.json) | | [`concatenate`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | | [`residual_add`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | -| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; clip_footprints=False | `docs/evidence/raster-global-pool-multiply-offline.json` (unresolved) | +| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; clip_footprints=False | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json) | | [`batch_norm`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/raster-batch-norm-offline.json](evidence/raster-batch-norm-offline.json); [evidence/live-fixtures/20261029T070703Z-batch_norm.json](evidence/live-fixtures/20261029T070703Z-batch_norm.json) | | [`standardize`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `round_each_op` | no | Ready — 6 bands with scale 1e-4; 3 bands without scale | [evidence/raster-standardize-offline.json](evidence/raster-standardize-offline.json); [evidence/live-fixtures/20261030T152829Z-standardize.json](evidence/live-fixtures/20261030T152829Z-standardize.json) | | [`segmentation_outputs`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json); [evidence/t05-primary.json](evidence/t05-primary.json) | | [`binary_threshold`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json) | | [`pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready — aligned avg/max k = 2, 3, 4; max 3×3 stride 1 or 2, padding 1; avg 3×3 stride 1 padding 1, both divisor rules | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [evidence/t05-primary.json](evidence/t05-primary.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | -| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | extent | `float32_operands` | no | Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False | `docs/evidence/raster-global-pool-multiply-offline.json` (unresolved) | +| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | extent | `float32_operands` | no | Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json) | | [`bilinear_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | k×k, radius 1 | `float32_operands` | yes (by setting) | Ready — factors 2, 3, 4 (align_corners=False), 2, 4 (align_corners=True) | [evidence/raster-bilinear-offline.json](evidence/raster-bilinear-offline.json); [evidence/live-fixtures/20261029T094157Z-bilinear.json](evidence/live-fixtures/20261029T094157Z-bilinear.json) | | [`nearest_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | pointwise | `float32_operands` | no | Ready — factors 2, 3, 4 | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | @@ -220,7 +220,7 @@ their own. ### Evidence notes -- 2 evidence entries name no existing file: `docs/evidence/raster-global-pool-multiply-offline.json`. +- Every evidence path names an existing file. - Entries in *italics* are notes in the metadata, not files. - Locality and chip edge hold over every validated setting; *(by setting)* marks a property whose exact value at a call's settings is `per_setting`'s (`metadata_at`). diff --git a/docs/evidence/raster-global-pool-multiply-offline.json b/docs/evidence/raster-global-pool-multiply-offline.json new file mode 100644 index 0000000..4e0719b --- /dev/null +++ b/docs/evidence/raster-global-pool-multiply-offline.json @@ -0,0 +1,1011 @@ +{ + "schema_version": 1, + "kind": "offline_fixture_check", + "cloud_calls": 0, + "status": "passed", + "revision": "b8dbe0afe70d07a0e719c03338f99a6b74e6ef6a", + "dirty": false, + "tensor_ee": "tensor_ee/__init__.py", + "versions": { + "python": "3.14.6", + "earthengine-api": "1.7.45", + "torch": "2.14.0", + "timm": "1.0.30", + "segmentation-models-pytorch": "0.5.0", + "numpy": "2.5.3" + }, + "platform": { + "system": "Darwin", + "machine": "arm64" + }, + "plan": { + "totals": { + "fixtures": 7, + "requests": 13, + "values": 64687, + "payload_bytes": 258748, + "request_bytes": 1074864, + "max_values": 49152, + "max_request_bytes": 374955, + "max_bands": 10, + "embedded_constants": 44, + "inline_values": 84520, + "assets": 0, + "tasks": 0, + "in_flight": 1 + }, + "violations": [] + }, + "fixtures": [ + { + "spec": { + "id": "raster/global_avg_pool2d/h5-w7", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 71, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 5 x 7 map onto its one-pixel grid (reduceResolution, maxPixels 63). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "53e3cb2954d467e11eeab6a7e27e84a4e615ab1ab6f2ea2d48b782606baab601", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 70, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -50, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 9459, + "graph_sha256": "b1c720a6460543cc4f09b9e0cac1c043082688544043454eba278a291047f6ea", + "graph_bytes": 9231, + "embedded_constants": 0, + "inline_values": 70 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 105, + "payload_bytes": 420, + "request_bytes": 6893, + "graph_sha256": "064fb2ac312fc3942fb098dee108086ab96b06672cf941c864e6be0c21d19771", + "graph_bytes": 6665, + "embedded_constants": 0, + "inline_values": 70 + } + ], + "errors": [], + "seconds": 0.015, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 1.3061414957046509, + "expected": 1.3061414957046509 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h8-w8", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 8, + 8, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 72, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 8 x 8 map onto its one-pixel grid (reduceResolution, maxPixels 100). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "78debda045f0499f6d23bb1aaa5f28d02bc037dc29c8e5937337c848e032afba", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 80, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -80, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 10056, + "graph_sha256": "62667b86db90314b6363e95ed9e5be3bbca293bb09b88c5907d908b1d359399f", + "graph_bytes": 9828, + "embedded_constants": 0, + "inline_values": 128 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 8, + "height": 8 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 8, + "height": 8, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 192, + "payload_bytes": 768, + "request_bytes": 7489, + "graph_sha256": "177a797a9637183c1c630d0dd4e970c3f69b6c5efdd15d2b1ed5dc1b66d4c3d8", + "graph_bytes": 7261, + "embedded_constants": 0, + "inline_values": 128 + } + ], + "errors": [], + "seconds": 0.014, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.947051763534546, + "expected": 1.9470515251159668 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h64-w64", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 64, + 64, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 73, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 64 x 64 map onto its one-pixel grid (reduceResolution, maxPixels 4,356). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "c2f04a66246d2ac1f6674720ce70a4eb6b3e2932ca55a8d82254ebc8dd588ee5", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 640, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -640, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 100300, + "graph_sha256": "9dca46a6dda57b19b2af8a39cf8880eb12736dfc271191db971eab400e670649", + "graph_bytes": 100070, + "embedded_constants": 0, + "inline_values": 8192 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 64, + "height": 64 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 64, + "height": 64, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 12288, + "payload_bytes": 49152, + "request_bytes": 97730, + "graph_sha256": "f4d5a2bdeaf0e709070b9751a172277f942639519a59e6e34eefe61ab05e670b", + "graph_bytes": 97500, + "embedded_constants": 0, + "inline_values": 8192 + } + ], + "errors": [], + "seconds": 0.016, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 2.0378997325897217, + "expected": 2.0378994941711426 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h128-w128", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 128, + 128, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 74, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 128 x 128 map onto its one-pixel grid (reduceResolution, maxPixels 16,900). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required. The input echo needs a 128 x 128 request grid: the maintainer's allowance for this fixture alone.", + "raster_side": 128 + }, + "inputs_sha256": "d474c9442b8e2026dcb5ae92ec1e24616870e08bb2cc297972bb0c0eeb4da2b9", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 1280, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -1280, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 374955, + "graph_sha256": "25bf19e30b397d97c1b379b776bcb5a8ff363a5b4604e3452f71bac1cbb1db28", + "graph_bytes": 374723, + "embedded_constants": 0, + "inline_values": 32768 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 128, + "height": 128 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 128, + "height": 128, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 49152, + "payload_bytes": 196608, + "request_bytes": 372382, + "graph_sha256": "67cb4029f57cf34688051e78c7d5b50a902c7e2b116c18d45d60749279749a6e", + "graph_bytes": 372150, + "embedded_constants": 0, + "inline_values": 32768 + } + ], + "errors": [], + "seconds": 0.02, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.0182695388793945, + "expected": 2.0182695388793945 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/multiply/same-grid-h5-w7", + "lane": "raster", + "block": "multiply", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "other": [ + 5, + 7, + 2 + ], + "output": [ + 5, + 7, + 2 + ] + }, + "seed": 75, + "tolerance": 0.0001, + "requests": 1, + "reference": "torch: x * y", + "mirror": { + "source": "reference.raster_gate.multiply", + "exact": true, + "bound": null + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "Two 5 x 7 maps on one grid; one product of float32 values, rounded once: equal to PyTorch's bit for bit." + }, + "inputs_sha256": "1d0d6df749ef7661b1947aa4a282de2812709e35cc6731ca97be61145d9f2440", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + }, + { + "name": "input", + "kind": "input", + "channels": 2 + }, + { + "name": "input_y", + "kind": "input", + "channels": 2 + } + ], + "bands": 9, + "values": 315, + "payload_bytes": 1260, + "request_bytes": 12835, + "graph_sha256": "fcf91450442895a02a81a7ea09f9185b13211d43811c96c7932cf81103ad9aae", + "graph_bytes": 12607, + "embedded_constants": 0, + "inline_values": 140 + } + ], + "errors": [], + "seconds": 0.014, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": -2.201176643371582, + "expected": -2.201176643371582 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/multiply/gate-h5-w7", + "lane": "raster", + "block": "multiply", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "other": [ + 1, + 1, + 2 + ], + "output": [ + 5, + 7, + 2 + ] + }, + "seed": 76, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch: g * x, g of shape [1, 2, 1, 1]", + "mirror": { + "source": "reference.raster_gate.multiply", + "exact": true, + "bound": null + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "A one-pixel gate in [0.25, 0.75) on the map's own one-pixel grid, broadcast over a 5 x 7 map; one product of float32 values, rounded once: equal to PyTorch's bit for bit." + }, + "inputs_sha256": "25dfbc44d7b6563d6c031d40e4e04f4ad1828da0640b8473bd1c47b761b87417", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + }, + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 6, + "values": 210, + "payload_bytes": 840, + "request_bytes": 13561, + "graph_sha256": "21fde7668708aadfe93014fc67a7ae5a34b177119f1fbe39db6673fb7e4c9741", + "graph_bytes": 13333, + "embedded_constants": 0, + "inline_values": 72 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 70, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -50, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "input_g", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 6071, + "graph_sha256": "e6f4854f8560b50bb96cdbbaef77ee6d59ccffe953f7a0f23325e802f7a403c2", + "graph_bytes": 5843, + "embedded_constants": 0, + "inline_values": 72 + } + ], + "errors": [], + "seconds": 0.014, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.8676347732543945, + "expected": 2.8676347732543945 + } + } + } + }, + "status": "passed" + }, + { + "spec": { + "id": "raster/multiply/squeeze-excitation-h12-w20", + "lane": "raster", + "block": "multiply", + "parts": [ + "global_avg_pool2d", + "conv2d", + "silu", + "sigmoid" + ], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 12, + 20, + 4 + ], + "squeezed": 2, + "pooled": [ + 1, + 1, + 4 + ], + "output": [ + 12, + 20, + 4 + ] + }, + "seed": 77, + "tolerance": 0.0001, + "requests": 2, + "reference": "a squeeze-excitation stand-in: F.adaptive_avg_pool2d, Conv2d 1x1, SiLU, Conv2d 1x1, torch.sigmoid, sigmoid(g) * x", + "mirror": { + "source": "reference.raster_gate with reference.raster_conv and reference.activations", + "exact": false, + "bound": 1e-05 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "compile_model on a non-square 12 x 20 map: the gate's 1 x 1 convolutions, SiLU and sigmoid run on the map's one-pixel grid (a non-square pixel), and the gate multiplies the map. The pooled mean and the gate are read on that grid as well." + }, + "inputs_sha256": "079a42c9badf04f0a6d6561ba3e02c67953c129b9d3dc49a92137233dc44cb59", + "weights_sha256": "1bb7efa27be975c4bc2c363307fb8ebdd6db43c85b32486efbc2e7bdf162f657", + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 20, + "height": 12 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 20, + "height": 12, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 4 + }, + { + "name": "input", + "kind": "input", + "channels": 4 + } + ], + "bands": 10, + "values": 2400, + "payload_bytes": 9600, + "request_bytes": 31945, + "graph_sha256": "ee0a845406f1ee86c486e9dce7920d93d8cd74c7e331c7e29e8a2e68d2e033d8", + "graph_bytes": 31715, + "embedded_constants": 22, + "inline_values": 960 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 200, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -120, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "pooled", + "kind": "output", + "channels": 4 + }, + { + "name": "gate", + "kind": "output", + "channels": 4 + } + ], + "bands": 10, + "values": 10, + "payload_bytes": 40, + "request_bytes": 31188, + "graph_sha256": "e13fc627cc8426070078d36acfe96e31d05e380da1e0c5187685be6e888931c0", + "graph_bytes": 30958, + "embedded_constants": 22, + "inline_values": 960 + } + ], + "errors": [], + "seconds": 0.016, + "outputs": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 960, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.35512134432792664, + "expected": 0.35512134432792664 + } + } + }, + "pooled": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 1.1920928955078125e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.8901211023330688, + "expected": 1.8901212215423584 + } + } + }, + "gate": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.5179181098937988, + "expected": 0.5179181098937988 + } + } + } + }, + "status": "passed" + } + ] +} From 4b30c592f569b5f46afe2f89d54b69955380f2fa Mon Sep 17 00:00:00 2001 From: Noel Ivan Ulloa <127597880+noelEOS@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:32:02 +0800 Subject: [PATCH 3/4] Scale the one-pixel grid's shear terms by the matching dimension x = a*col + b*row + c, so a pixel spanning the whole map takes b times the height and d times the width. Grid refuses any shear, so both terms are zero and no graph changes. --- tensor_ee/core/grid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tensor_ee/core/grid.py b/tensor_ee/core/grid.py index 79c9c67..750c81a 100644 --- a/tensor_ee/core/grid.py +++ b/tensor_ee/core/grid.py @@ -140,7 +140,7 @@ def one_pixel(grid: Grid) -> Grid: from the whole map. On a k x k grid it equals ``grid.resized(k, 1, 1)``, the grid of an aligned k x k pool.""" a, b, c, d, e, f = grid.transform - return Grid(grid.crs, (a * grid.width, b * grid.width, c, d * grid.height, e * grid.height, f), 1, 1) + return Grid(grid.crs, (a * grid.width, b * grid.height, c, d * grid.width, e * grid.height, f), 1, 1) @dataclass(frozen=True) From c0007253860c08157ba31e8d97642f39e5991d99 Mon Sep 17 00:00:00 2001 From: Noel Ivan Ulloa <127597880+noelEOS@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:19:33 +0800 Subject: [PATCH 4/4] Promote global average pooling and multiply to Ready after the live run The global_pool_multiply selection ran live once: 7 fixtures, 13 requests, all passed. Earth Engine equals the NumPy mirror in every value; against float32 PyTorch the largest absolute error is 2.38e-07 (the pooled mean) and 0 for the product. reduceResolution accepted maxPixels 16,900 on the 128 x 128 map. Both statuses name the maps that ran, since the map's size is the input's grid and not a parameter; the validated records hold clip_footprints False. Larger maps and Earth Engine's accumulation order remain unvalidated. --- docs/api.md | 4 +- docs/building-blocks.md | 12 +- ...20261112T151417Z-global_pool_multiply.json | 1629 +++++++++++++++++ tensor_ee/blocks/raster/merge.py | 18 +- tensor_ee/blocks/raster/pooling.py | 25 +- tests/test_live_fixtures.py | 10 +- tests/test_raster_blocks.py | 5 +- tests/test_raster_global_pool_multiply.py | 9 +- tests/test_x02b_validated_settings.py | 10 +- tools/fixtures/raster.py | 3 +- 10 files changed, 1686 insertions(+), 39 deletions(-) create mode 100644 docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json diff --git a/docs/api.md b/docs/api.md index 1d583af..0e33a06 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,8 +96,8 @@ in either operand order (squeeze-and-excitation's `sigmoid(g) * x`). Adaptive po sizes, max pools to one pixel, `mean` without `keepdim` or over other dims, scalar products and channel broadcasts (`[N, 1, H, W]`) are refused. A global mean depends on the whole chip: the result is per compiled grid, `analyze_receptive_field` lists it in `extent_nodes` and -refuses a halo, and tiled compilation refuses the model. Both blocks have offline evidence -only. +refuses a halo, and tiled compilation refuses the model. Both blocks were validated live at +the map sizes their catalogue rows list; no whole model that uses them has been. Model and submodules must already be in eval mode. Computational weights, biases and BatchNorm statistics are finite float32 with declared shapes; diff --git a/docs/building-blocks.md b/docs/building-blocks.md index 81d6bf4..6bc3742 100644 --- a/docs/building-blocks.md +++ b/docs/building-blocks.md @@ -105,14 +105,14 @@ their own. | Layout in → out | Ready | Fixed | Total | |---|---:|---:|---:| -| R → R | 23 | 2 | 25 | +| R → R | 25 | 0 | 25 | | R → W | 2 | 1 | 3 | | W → R | 0 | 1 | 1 | | W → W | 23 | 33 | 56 | | W → V | 2 | 0 | 2 | | V → W | 1 | 0 | 1 | | V → V | 1 | 1 | 2 | -| **All** | **52** | **38** | **90** | +| **All** | **54** | **36** | **90** | ### Raster-layout (R) blocks @@ -136,13 +136,13 @@ their own. | [`conv2d`](../tensor_ee/blocks/raster/conv.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/primitives_float32.json](history/cnn/results/primitives_float32.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json); [cnn/results/tiled_resnet18_2x2_parity.json](history/cnn/results/tiled_resnet18_2x2_parity.json) | | [`concatenate`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | | [`residual_add`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready | [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | -| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; clip_footprints=False | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json) | +| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | pointwise | `float32_operands` | no | Ready — same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json); [evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json](evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json) | | [`batch_norm`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/raster-batch-norm-offline.json](evidence/raster-batch-norm-offline.json); [evidence/live-fixtures/20261029T070703Z-batch_norm.json](evidence/live-fixtures/20261029T070703Z-batch_norm.json) | | [`standardize`](../tensor_ee/blocks/raster/normalization.py) | R → R | pointwise | `round_each_op` | no | Ready — 6 bands with scale 1e-4; 3 bands without scale | [evidence/raster-standardize-offline.json](evidence/raster-standardize-offline.json); [evidence/live-fixtures/20261030T152829Z-standardize.json](evidence/live-fixtures/20261030T152829Z-standardize.json) | | [`segmentation_outputs`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json); [evidence/t05-primary.json](evidence/t05-primary.json) | | [`binary_threshold`](../tensor_ee/blocks/raster/outputs.py) | R → R | pointwise | `float32_operands` | no | Ready | [evidence/t03-outputs.json](evidence/t03-outputs.json) | | [`pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | k×k, radius 3 | `float32_operands` | no | Ready — aligned avg/max k = 2, 3, 4; max 3×3 stride 1 or 2, padding 1; avg 3×3 stride 1 padding 1, both divisor rules | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [evidence/t05-primary.json](evidence/t05-primary.json); [cnn/results/resnet_primitives.json](history/cnn/results/resnet_primitives.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | -| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | extent | `float32_operands` | no | Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json) | +| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | extent | `float32_operands` | no | Ready — maps 5 × 7, 8 × 8, 12 × 20, 64 × 64 and 128 × 128 | [evidence/raster-global-pool-multiply-offline.json](evidence/raster-global-pool-multiply-offline.json); [evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json](evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json) | | [`bilinear_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | k×k, radius 1 | `float32_operands` | yes (by setting) | Ready — factors 2, 3, 4 (align_corners=False), 2, 4 (align_corners=True) | [evidence/raster-bilinear-offline.json](evidence/raster-bilinear-offline.json); [evidence/live-fixtures/20261029T094157Z-bilinear.json](evidence/live-fixtures/20261029T094157Z-bilinear.json) | | [`nearest_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | pointwise | `float32_operands` | no | Ready — factors 2, 3, 4 | [evidence/live-fixtures/20261013T065257Z-p02.json](evidence/live-fixtures/20261013T065257Z-p02.json); [evidence/p02-raster-offline.json](evidence/p02-raster-offline.json); [cnn/results/parity.json](history/cnn/results/parity.json); [cnn/results/resnet18_full64_float32_policy.json](history/cnn/results/resnet18_full64_float32_policy.json); [cnn/results/segwater_js_parity_1x1.json](history/cnn/results/segwater_js_parity_1x1.json) | @@ -246,13 +246,13 @@ Each block's `validated` record, the status in machine-readable form: 90 records | [`conv2d`](../tensor_ee/blocks/raster/conv.py) | R → R | any accepted call | — | | [`concatenate`](../tensor_ee/blocks/raster/merge.py) | R → R | {dim 1} | — | | [`residual_add`](../tensor_ee/blocks/raster/merge.py) | R → R | any accepted call | — | -| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | none live | — | +| [`multiply`](../tensor_ee/blocks/raster/merge.py) | R → R | {clip_footprints False} | — | | [`batch_norm`](../tensor_ee/blocks/raster/normalization.py) | R → R | {clip_footprints False; eps 1e-05} | `BatchNormConstants` | | [`standardize`](../tensor_ee/blocks/raster/normalization.py) | R → R | {channels 6; clip_footprints False; scale 0.0001} ∪ {channels 3; clip_footprints False; scale None} | `StandardizeConstants` | | [`segmentation_outputs`](../tensor_ee/blocks/raster/outputs.py) | R → R | any accepted call | — | | [`binary_threshold`](../tensor_ee/blocks/raster/outputs.py) | R → R | any accepted call | — | | [`pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | {kernel 2; padding 0; reducer 'mean' or 'max'; stride 2} ∪ {kernel 3; padding 0; reducer 'mean' or 'max'; stride 3} ∪ {kernel 4; padding 0; reducer 'mean' or 'max'; stride 4} ∪ {kernel 3; padding 1; reducer 'max'; stride 1 or 2} ∪ {count_include_pad True or False; kernel 3; padding 1; reducer 'mean'; stride 1} | `PoolConfig` | -| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | none live | — | +| [`global_avg_pool2d`](../tensor_ee/blocks/raster/pooling.py) | R → R | {clip_footprints False} | — | | [`bilinear_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | {align_corners False; clip_footprints False; factor 2 or 3 or 4} ∪ {align_corners True; clip_footprints False; factor 2 or 4} | `BilinearConstants` | | [`nearest_upsample`](../tensor_ee/blocks/raster/resample.py) | R → R | {scale_factor 2 or 3 or 4} | — | | [`gelu`](../tensor_ee/blocks/window/activations.py) | W → W | any accepted call | — | diff --git a/docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json b/docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json new file mode 100644 index 0000000..674c7b1 --- /dev/null +++ b/docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json @@ -0,0 +1,1629 @@ +{ + "schema_version": 1, + "kind": "live_fixture_run", + "status": "passed", + "command": "python tools/live_fixtures.py --run global_pool_multiply --project tensor-ee --budget requests=13", + "selection": [ + "raster/global_avg_pool2d/h5-w7", + "raster/global_avg_pool2d/h8-w8", + "raster/global_avg_pool2d/h64-w64", + "raster/global_avg_pool2d/h128-w128", + "raster/multiply/same-grid-h5-w7", + "raster/multiply/gate-h5-w7", + "raster/multiply/squeeze-excitation-h12-w20" + ], + "project": "tensor-ee", + "credentials": "adc", + "revision": "4b30c592f569b5f46afe2f89d54b69955380f2fa", + "dirty": false, + "tensor_ee": "tensor_ee/__init__.py", + "versions": { + "python": "3.14.6", + "earthengine-api": "1.7.45", + "torch": "2.14.0", + "timm": "1.0.30", + "segmentation-models-pytorch": "0.5.0", + "numpy": "2.5.3" + }, + "platform": { + "system": "Darwin", + "machine": "arm64" + }, + "started_utc": "2026-11-12T15:14:17+00:00", + "budget": { + "requests": 13, + "raster_side": 64, + "window_side": 8, + "values": 1000000, + "request_bytes": 2000000, + "in_flight": 4, + "assets": 0, + "tasks": 0 + }, + "plan": { + "fixtures": 7, + "requests": 13, + "values": 64687, + "payload_bytes": 258748, + "request_bytes": 1074864, + "max_values": 49152, + "max_request_bytes": 374955, + "max_bands": 10, + "embedded_constants": 44, + "inline_values": 84520, + "assets": 0, + "tasks": 0, + "in_flight": 1 + }, + "max_in_flight": 1, + "errors": [], + "fixtures": [ + { + "spec": { + "id": "raster/global_avg_pool2d/h5-w7", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 71, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 5 x 7 map onto its one-pixel grid (reduceResolution, maxPixels 63). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "53e3cb2954d467e11eeab6a7e27e84a4e615ab1ab6f2ea2d48b782606baab601", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 0.769, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 1.3061414957046509, + "expected": 1.3061414957046509 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 70, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -50, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 9459, + "graph_sha256": "b1c720a6460543cc4f09b9e0cac1c043082688544043454eba278a291047f6ea", + "graph_bytes": 9231, + "embedded_constants": 0, + "inline_values": 70, + "status": "ok", + "wall_seconds": 0.407 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 105, + "payload_bytes": 420, + "request_bytes": 6893, + "graph_sha256": "064fb2ac312fc3942fb098dee108086ab96b06672cf941c864e6be0c21d19771", + "graph_bytes": 6665, + "embedded_constants": 0, + "inline_values": 70, + "status": "ok", + "wall_seconds": 0.361 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 1, + 1, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 1.3061414957046509, + "expected": 1.3061414957046509 + } + }, + "mirror": { + "exact": false, + "bound": 1e-06, + "values": 2, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 1, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h8-w8", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 8, + 8, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 72, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 8 x 8 map onto its one-pixel grid (reduceResolution, maxPixels 100). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "78debda045f0499f6d23bb1aaa5f28d02bc037dc29c8e5937337c848e032afba", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 0.745, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.947051763534546, + "expected": 1.9470515251159668 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 80, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -80, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 10056, + "graph_sha256": "62667b86db90314b6363e95ed9e5be3bbca293bb09b88c5907d908b1d359399f", + "graph_bytes": 9828, + "embedded_constants": 0, + "inline_values": 128, + "status": "ok", + "wall_seconds": 0.401 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 8, + "height": 8 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 8, + "height": 8, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 192, + "payload_bytes": 768, + "request_bytes": 7489, + "graph_sha256": "177a797a9637183c1c630d0dd4e970c3f69b6c5efdd15d2b1ed5dc1b66d4c3d8", + "graph_bytes": 7261, + "embedded_constants": 0, + "inline_values": 128, + "status": "ok", + "wall_seconds": 0.344 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 1, + 1, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.947051763534546, + "expected": 1.9470515251159668 + } + }, + "mirror": { + "exact": false, + "bound": 1e-06, + "values": 2, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 1, + "shape": [ + 8, + 8, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 128, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h64-w64", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 64, + 64, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 73, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 64 x 64 map onto its one-pixel grid (reduceResolution, maxPixels 4,356). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required." + }, + "inputs_sha256": "c2f04a66246d2ac1f6674720ce70a4eb6b3e2932ca55a8d82254ebc8dd588ee5", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 1.452, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 2.0378997325897217, + "expected": 2.0378994941711426 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 640, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -640, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 100300, + "graph_sha256": "9dca46a6dda57b19b2af8a39cf8880eb12736dfc271191db971eab400e670649", + "graph_bytes": 100070, + "embedded_constants": 0, + "inline_values": 8192, + "status": "ok", + "wall_seconds": 0.709 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 64, + "height": 64 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 64, + "height": 64, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 12288, + "payload_bytes": 49152, + "request_bytes": 97730, + "graph_sha256": "f4d5a2bdeaf0e709070b9751a172277f942639519a59e6e34eefe61ab05e670b", + "graph_bytes": 97500, + "embedded_constants": 0, + "inline_values": 8192, + "status": "ok", + "wall_seconds": 0.743 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 1, + 1, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 2.384185791015625e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 2.0378997325897217, + "expected": 2.0378994941711426 + } + }, + "mirror": { + "exact": false, + "bound": 1e-06, + "values": 2, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 1, + "shape": [ + 64, + 64, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 8192, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/global_avg_pool2d/h128-w128", + "lane": "raster", + "block": "global_avg_pool2d", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 128, + 128, + 2 + ], + "output": [ + 1, + 1, + 2 + ] + }, + "seed": 74, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch.nn.functional.adaptive_avg_pool2d(x, 1)", + "mirror": { + "source": "reference.raster_gate.global_avg_pool2d", + "exact": false, + "bound": 1e-06 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "The mean of a 128 x 128 map onto its one-pixel grid (reduceResolution, maxPixels 16,900). Inputs in [-2, 6) whose double sums are exact, so the mirror is order-independent; Earth Engine's reduceResolution order is recorded, not required. The input echo needs a 128 x 128 request grid: the maintainer's allowance for this fixture alone.", + "raster_side": 128 + }, + "inputs_sha256": "d474c9442b8e2026dcb5ae92ec1e24616870e08bb2cc297972bb0c0eeb4da2b9", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 5.343, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.0182695388793945, + "expected": 2.0182695388793945 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 1280, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -1280, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 374955, + "graph_sha256": "25bf19e30b397d97c1b379b776bcb5a8ff363a5b4604e3452f71bac1cbb1db28", + "graph_bytes": 374723, + "embedded_constants": 0, + "inline_values": 32768, + "status": "ok", + "wall_seconds": 2.384 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 128, + "height": 128 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 128, + "height": 128, + "readouts": [ + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 49152, + "payload_bytes": 196608, + "request_bytes": 372382, + "graph_sha256": "67cb4029f57cf34688051e78c7d5b50a902c7e2b116c18d45d60749279749a6e", + "graph_bytes": 372150, + "embedded_constants": 0, + "inline_values": 32768, + "status": "ok", + "wall_seconds": 2.958 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 1, + 1, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 2, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.0182695388793945, + "expected": 2.0182695388793945 + } + }, + "mirror": { + "exact": false, + "bound": 1e-06, + "values": 2, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 1, + "shape": [ + 128, + 128, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 32768, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/multiply/same-grid-h5-w7", + "lane": "raster", + "block": "multiply", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "other": [ + 5, + 7, + 2 + ], + "output": [ + 5, + 7, + 2 + ] + }, + "seed": 75, + "tolerance": 0.0001, + "requests": 1, + "reference": "torch: x * y", + "mirror": { + "source": "reference.raster_gate.multiply", + "exact": true, + "bound": null + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "Two 5 x 7 maps on one grid; one product of float32 values, rounded once: equal to PyTorch's bit for bit." + }, + "inputs_sha256": "1d0d6df749ef7661b1947aa4a282de2812709e35cc6731ca97be61145d9f2440", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 0.311, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": -2.201176643371582, + "expected": -2.201176643371582 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + }, + { + "name": "input", + "kind": "input", + "channels": 2 + }, + { + "name": "input_y", + "kind": "input", + "channels": 2 + } + ], + "bands": 9, + "values": 315, + "payload_bytes": 1260, + "request_bytes": 12835, + "graph_sha256": "fcf91450442895a02a81a7ea09f9185b13211d43811c96c7932cf81103ad9aae", + "graph_bytes": 12607, + "embedded_constants": 0, + "inline_values": 140, + "status": "ok", + "wall_seconds": 0.311 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": -2.201176643371582, + "expected": -2.201176643371582 + } + }, + "mirror": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 0, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input_y": { + "kind": "input", + "request": 0, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/multiply/gate-h5-w7", + "lane": "raster", + "block": "multiply", + "parts": [], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 5, + 7, + 2 + ], + "other": [ + 1, + 1, + 2 + ], + "output": [ + 5, + 7, + 2 + ] + }, + "seed": 76, + "tolerance": 0.0001, + "requests": 2, + "reference": "torch: g * x, g of shape [1, 2, 1, 1]", + "mirror": { + "source": "reference.raster_gate.multiply", + "exact": true, + "bound": null + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "A one-pixel gate in [0.25, 0.75) on the map's own one-pixel grid, broadcast over a 5 x 7 map; one product of float32 values, rounded once: equal to PyTorch's bit for bit." + }, + "inputs_sha256": "25dfbc44d7b6563d6c031d40e4e04f4ad1828da0640b8473bd1c47b761b87417", + "weights_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "status": "passed", + "wall_seconds": 1.509, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.8676347732543945, + "expected": 2.8676347732543945 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 7, + "height": 5 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 7, + "height": 5, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 2 + }, + { + "name": "input", + "kind": "input", + "channels": 2 + } + ], + "bands": 6, + "values": 210, + "payload_bytes": 840, + "request_bytes": 13561, + "graph_sha256": "21fde7668708aadfe93014fc67a7ae5a34b177119f1fbe39db6673fb7e4c9741", + "graph_bytes": 13333, + "embedded_constants": 0, + "inline_values": 72, + "status": "ok", + "wall_seconds": 1.206 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 70, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -50, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "input_g", + "kind": "input", + "channels": 2 + } + ], + "bands": 3, + "values": 3, + "payload_bytes": 12, + "request_bytes": 6071, + "graph_sha256": "e6f4854f8560b50bb96cdbbaef77ee6d59ccffe953f7a0f23325e802f7a403c2", + "graph_bytes": 5843, + "embedded_constants": 0, + "inline_values": 72, + "status": "ok", + "wall_seconds": 0.303 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 70, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 2.8676347732543945, + "expected": 2.8676347732543945 + } + }, + "mirror": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 0, + "shape": [ + 5, + 7, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 70, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input_g": { + "kind": "input", + "request": 1, + "shape": [ + 1, + 1, + 2 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 2, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + }, + { + "spec": { + "id": "raster/multiply/squeeze-excitation-h12-w20", + "lane": "raster", + "block": "multiply", + "parts": [ + "global_avg_pool2d", + "conv2d", + "silu", + "sigmoid" + ], + "settings": { + "clip_footprints": false + }, + "policy": "float32_operands", + "shapes": { + "input": [ + 12, + 20, + 4 + ], + "squeezed": 2, + "pooled": [ + 1, + 1, + 4 + ], + "output": [ + 12, + 20, + 4 + ] + }, + "seed": 77, + "tolerance": 0.0001, + "requests": 2, + "reference": "a squeeze-excitation stand-in: F.adaptive_avg_pool2d, Conv2d 1x1, SiLU, Conv2d 1x1, torch.sigmoid, sigmoid(g) * x", + "mirror": { + "source": "reference.raster_gate with reference.raster_conv and reference.activations", + "exact": false, + "bound": 1e-05 + }, + "history": "none: a new block, offline evidence only", + "calibration": false, + "notes": "compile_model on a non-square 12 x 20 map: the gate's 1 x 1 convolutions, SiLU and sigmoid run on the map's one-pixel grid (a non-square pixel), and the gate multiplies the map. The pooled mean and the gate are read on that grid as well." + }, + "inputs_sha256": "079a42c9badf04f0a6d6561ba3e02c67953c129b9d3dc49a92137233dc44cb59", + "weights_sha256": "1bb7efa27be975c4bc2c363307fb8ebdd6db43c85b32486efbc2e7bdf162f657", + "status": "passed", + "wall_seconds": 0.916, + "failures": [], + "offline": { + "out": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 960, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.35512134432792664, + "expected": 0.35512134432792664 + } + } + }, + "pooled": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 1.1920928955078125e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.8901211023330688, + "expected": 1.8901212215423584 + } + } + }, + "gate": { + "mirror_vs_pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.5179181098937988, + "expected": 0.5179181098937988 + } + } + } + }, + "requests": [ + { + "index": 0, + "grid": { + "dimensions": { + "width": 20, + "height": 12 + }, + "affineTransform": { + "scaleX": 10, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -10, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 20, + "height": 12, + "readouts": [ + { + "name": "out", + "kind": "output", + "channels": 4 + }, + { + "name": "input", + "kind": "input", + "channels": 4 + } + ], + "bands": 10, + "values": 2400, + "payload_bytes": 9600, + "request_bytes": 31945, + "graph_sha256": "ee0a845406f1ee86c486e9dce7920d93d8cd74c7e331c7e29e8a2e68d2e033d8", + "graph_bytes": 31715, + "embedded_constants": 22, + "inline_values": 960, + "status": "ok", + "wall_seconds": 0.493 + }, + { + "index": 1, + "grid": { + "dimensions": { + "width": 1, + "height": 1 + }, + "affineTransform": { + "scaleX": 200, + "shearX": 0, + "translateX": 1000, + "shearY": 0, + "scaleY": -120, + "translateY": 2000 + }, + "crsCode": "EPSG:3857" + }, + "width": 1, + "height": 1, + "readouts": [ + { + "name": "pooled", + "kind": "output", + "channels": 4 + }, + { + "name": "gate", + "kind": "output", + "channels": 4 + } + ], + "bands": 10, + "values": 10, + "payload_bytes": 40, + "request_bytes": 31188, + "graph_sha256": "e13fc627cc8426070078d36acfe96e31d05e380da1e0c5187685be6e888931c0", + "graph_bytes": 30958, + "embedded_constants": 22, + "inline_values": 960, + "status": "ok", + "wall_seconds": 0.422 + } + ], + "outputs": { + "out": { + "kind": "output", + "request": 0, + "shape": [ + 12, + 20, + 4 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 960, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.35512134432792664, + "expected": 0.35512134432792664 + } + }, + "mirror": { + "exact": false, + "bound": 1e-05, + "values": 960, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "input": { + "kind": "input", + "request": 0, + "shape": [ + 12, + 20, + 4 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "input": { + "exact": true, + "bound": null, + "values": 960, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "pooled": { + "kind": "output", + "request": 1, + "shape": [ + 1, + 1, + 4 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 1.1920928955078125e-07, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 1, + "actual": 1.8901211023330688, + "expected": 1.8901212215423584 + } + }, + "mirror": { + "exact": false, + "bound": 1e-05, + "values": 4, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + }, + "gate": { + "kind": "output", + "request": 1, + "shape": [ + 1, + 1, + 4 + ], + "dtypes": [ + "float32" + ], + "float32": true, + "invalid_pixels": 0, + "pytorch": { + "tolerance": 0.0001, + "comparison": "absolute", + "values": 4, + "max_abs": 0.0, + "over_tolerance": 0, + "pass": true, + "worst": { + "row": 0, + "col": 0, + "channel": 0, + "actual": 0.5179181098937988, + "expected": 0.5179181098937988 + } + }, + "mirror": { + "exact": false, + "bound": 1e-05, + "values": 4, + "differing": 0, + "max_abs": 0.0, + "first": [], + "pass": true + } + } + } + } + ], + "cloud_calls": { + "requests": 13, + "computePixels": 13, + "algorithms_list": 1, + "refused": [] + }, + "finished_utc": "2026-11-12T15:14:32+00:00", + "wall_seconds": 14.933 +} diff --git a/tensor_ee/blocks/raster/merge.py b/tensor_ee/blocks/raster/merge.py index b375b4f..f3747d2 100644 --- a/tensor_ee/blocks/raster/merge.py +++ b/tensor_ee/blocks/raster/merge.py @@ -25,9 +25,10 @@ Policy ``float32_operands``: the product of two float32 values is exact in double, so its one float32 rounding at the node boundary gives the correctly rounded float32 product, bit-equal to PyTorch's (an IEEE product is commutative, so ``x * g`` and ``g * x`` agree). -**Not validated live**: offline evidence only, the same-grid form on a 5 x 7 map and the gate -form on 5 x 7 and, through ``compile_model``, 12 x 20 maps (``raster/multiply/*`` in -``tools/fixtures/raster.py``), clip_footprints=False. +Validated live in the same-grid form on a 5 x 7 map and in the gate form on 5 x 7 and, +through ``compile_model``, 12 x 20 maps (``raster/multiply/*`` in +``tools/fixtures/raster.py``), clip_footprints=False: Earth Engine equal to the NumPy mirror +and to float32 PyTorch in every value. Other map sizes are not validated. """ from __future__ import annotations @@ -36,7 +37,7 @@ from ...core.errors import UnsupportedOperation from ...core.grid import one_pixel -from ...core.metadata import ANY_SETTINGS, NOT_LIVE, BlockMetadata, Locality, Validated, validate_metadata +from ...core.metadata import ANY_SETTINGS, BlockMetadata, Locality, Validated, validate_metadata from ...core.precision import FLOAT32_OPERANDS, validate_policy from ...core.tensor import Raster from ...layouts.raster import finish as _finish, project as _project @@ -117,7 +118,10 @@ def multiply(x: Raster, other: Raster, *, clip_footprints: bool = False, name="multiply", layout_in="R", layout_out="R", locality=Locality.POINTWISE, policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients, asset_arrays=_no_coefficients, - status=("Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; " - "clip_footprints=False"), - evidence=("docs/evidence/raster-global-pool-multiply-offline.json",), validated=NOT_LIVE), + # The maps' sizes are the operands' grids, not parameters: the record holds only + # clip_footprints (never varied) and the status names the forms and maps that ran. + status="Ready — same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps", + evidence=("docs/evidence/raster-global-pool-multiply-offline.json", + "docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json"), + validated=Validated(({"clip_footprints": (False,)},))), }) diff --git a/tensor_ee/blocks/raster/pooling.py b/tensor_ee/blocks/raster/pooling.py index 2a8638e..a84c80d 100644 --- a/tensor_ee/blocks/raster/pooling.py +++ b/tensor_ee/blocks/raster/pooling.py @@ -52,10 +52,15 @@ aligned average also requires. Earth Engine's accumulation type and order for ``reduceResolution`` are not established: the NumPy mirror (``tests/reference``) sums in double and divides by H W once, and the fixtures use inputs whose double sums are exact in -every order. ``maxPixels`` above (k + 2)^2 for k = 4 (16,900 at 128 x 128) is untested by -Earth Engine. **Not validated live**: offline evidence only, maps 5 x 7, 12 x 20, 8 x 8, -64 x 64 and 128 x 128 (``raster/global_avg_pool2d/*`` and the squeeze-excitation fixture in -``tools/fixtures/raster.py``), clip_footprints=False. +every order. + +Validated live on maps 5 x 7, 8 x 8, 12 x 20, 64 x 64 and 128 x 128 +(``raster/global_avg_pool2d/*`` and the squeeze-excitation fixture in +``tools/fixtures/raster.py``), clip_footprints=False: Earth Engine equal to the NumPy mirror +in every value, and within 2.4e-7 of float32 PyTorch. Earth Engine accepted ``maxPixels`` +16,900 (the 128 x 128 map); a larger map needs a larger ``maxPixels`` and is not validated. +Those inputs' sums are exact in every order, so the runs do not establish Earth Engine's +accumulation order either. """ from __future__ import annotations @@ -67,7 +72,7 @@ from ...core.errors import UnsupportedOperation from ...core.grid import Grid, one_pixel -from ...core.metadata import NOT_LIVE, BlockMetadata, Locality, Validated, validate_metadata +from ...core.metadata import BlockMetadata, Locality, Validated, validate_metadata from ...core.precision import FLOAT32_OPERANDS, validate_policy from ...core.tensor import Raster from ...layouts.raster import finish as _finish, project as _project @@ -234,11 +239,13 @@ def global_avg_pool2d(raster: Raster, *, clip_footprints: bool = False, ), sources=(PoolConfig,))), # The output depends on every pixel of the map: EXTENT. Not chip-edge dependent in the # flag's sense (no wrap or mask); EXTENT already makes a stage whole-chip, as for - # window_pool. + # window_pool. The map's size is the input's grid, not a parameter, so the record holds + # only clip_footprints (never varied) and the status names the maps that ran. "global_avg_pool2d": BlockMetadata( name="global_avg_pool2d", layout_in="R", layout_out="R", locality=Locality.EXTENT, policy=FLOAT32_OPERANDS, embedded_constants=_no_coefficients, asset_arrays=_no_coefficients, - status=("Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; " - "clip_footprints=False"), - evidence=("docs/evidence/raster-global-pool-multiply-offline.json",), validated=NOT_LIVE), + status="Ready — maps 5 × 7, 8 × 8, 12 × 20, 64 × 64 and 128 × 128", + evidence=("docs/evidence/raster-global-pool-multiply-offline.json", + "docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json"), + validated=Validated(({"clip_footprints": (False,)},))), }) diff --git a/tests/test_live_fixtures.py b/tests/test_live_fixtures.py index 9d0e4ad..cda2cfc 100644 --- a/tests/test_live_fixtures.py +++ b/tests/test_live_fixtures.py @@ -87,9 +87,9 @@ # block passed its separate SegFormer G3-live gate, and the bounded activations and the # standalone raster BatchNorm, raster bilinear upsampling and raster grouped convolution # theirs, and row concatenation, window depth-to-space and class-token attention -# theirs, and raster input standardization theirs. Raster global average pooling and the -# elementwise multiply are offline only. -OFFLINE_ONLY_FIXED: set[str] = {"global_avg_pool2d", "multiply"} +# theirs, and raster input standardization theirs, and raster global average pooling and the +# elementwise multiply theirs; no offline-only block remains. +OFFLINE_ONLY_FIXED: set[str] = set() COMPOSITION_FIXTURES = {"window/concat_rows/prithvi-crop-logits", "window/concat_rows/prithvi-region-2x2", "window/concat_rows/vit-encoder-reduced", "window/concat_rows/vit-dpt-reduced"} """Runnable composition checks excluded from block coverage and evidence counts.""" @@ -101,6 +101,7 @@ GROUPED_CONV_G3_BLOCKS = {"grouped_conv2d"} PRITHVI_G3_BLOCKS = {"concat_rows", "window_depth_to_space", "class_token_attention"} STANDARDIZE_G3_BLOCKS = {"standardize"} +GLOBAL_POOL_MULTIPLY_G3_BLOCKS = {"global_avg_pool2d", "multiply"} # The SegFormer composites promoted from the model (X01a): Fixed at the settings the # SegFormer-B0 live runs cover, outside Phase 2's calibration census. @@ -741,7 +742,8 @@ def test_the_calibration_covers_every_fixed_block_of_phase_2(plan): if f.id not in COMPOSITION_FIXTURES and f.mirror is not None and not f.calibration for name in (f.block, *f.parts)} assert (SEGFORMER_G3_BLOCKS | HARD_ACTIVATION_G3_BLOCKS | BATCH_NORM_G3_BLOCKS | BILINEAR_G3_BLOCKS - | GROUPED_CONV_G3_BLOCKS | PRITHVI_G3_BLOCKS | STANDARDIZE_G3_BLOCKS | OFFLINE_ONLY_FIXED <= new_covered) + | GROUPED_CONV_G3_BLOCKS | PRITHVI_G3_BLOCKS | STANDARDIZE_G3_BLOCKS | GLOBAL_POOL_MULTIPLY_G3_BLOCKS + | OFFLINE_ONLY_FIXED <= new_covered) covered = {name for f in plan.fixtures if f.id not in COMPOSITION_FIXTURES for name in (f.block, *f.parts)} assert PHASE2_FIXED <= covered diff --git a/tests/test_raster_blocks.py b/tests/test_raster_blocks.py index ab5b498..eac46d7 100644 --- a/tests/test_raster_blocks.py +++ b/tests/test_raster_blocks.py @@ -374,10 +374,9 @@ def test_compile_model_keeps_the_per_node_float32_cast(offline_ee): # --------------------------------------------------------------------------- EXPECTED = { # block -> (locality, radius, status) "global_avg_pool2d": (M.Locality.EXTENT, None, - "Fixed — offline only: maps 5 × 7, 12 × 20, 8 × 8, 64 × 64 and 128 × 128; clip_footprints=False"), + "Ready — maps 5 × 7, 8 × 8, 12 × 20, 64 × 64 and 128 × 128"), "multiply": (M.Locality.POINTWISE, None, - "Fixed — offline only: same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps; " - "clip_footprints=False"), + "Ready — same grid on a 5 × 7 map; one-pixel gate on 5 × 7 and 12 × 20 maps"), "grouped_conv2d": (M.Locality.KERNEL, 2, "Ready — depthwise 3×3 stride 1 or 2, 5×5 stride 1; 3×3 stride 1 with 2 channels per group or channel multiplier 2"), "bilinear_upsample": (M.Locality.KERNEL, 1, "Ready — factors 2, 3, 4 (align_corners=False), 2, 4 (align_corners=True)"), diff --git a/tests/test_raster_global_pool_multiply.py b/tests/test_raster_global_pool_multiply.py index af7d8b3..6de3e7b 100644 --- a/tests/test_raster_global_pool_multiply.py +++ b/tests/test_raster_global_pool_multiply.py @@ -28,7 +28,7 @@ from tensor_ee import Grid, Raster, UnsupportedOperation, check_compatibility, compile_model, from_image from tensor_ee.blocks.raster import activations as A, merge as MG, pooling as PL from tensor_ee.core.grid import one_pixel -from tensor_ee.core.metadata import NOT_LIVE, Locality +from tensor_ee.core.metadata import Locality, Validated from tensor_ee.receptive_field import UnsupportedReceptiveField, analyze_receptive_field from tensor_ee.tiling import compile_tiled_model, plan_tiles from reference.raster_gate import global_avg_pool2d as pool_mirror, multiply as multiply_mirror @@ -89,13 +89,14 @@ def test_global_pool_reduces_onto_the_one_pixel_grid(offline_ee): PL.global_avg_pool2d(ee.Image.constant(0)) -def test_metadata_is_offline_only_and_extent(): +def test_metadata_is_live_validated_and_extent(): pool, product = PL.METADATA["global_avg_pool2d"], MG.METADATA["multiply"] assert pool.locality is Locality.EXTENT and product.locality is Locality.POINTWISE - assert pool.validated == product.validated == NOT_LIVE + assert pool.validated == product.validated == Validated(({"clip_footprints": (False,)},)) assert not pool.chip_edge_dependent and not product.chip_edge_dependent for entry in (pool, product): - assert entry.status.startswith("Fixed — offline only: ") and "Ready" not in entry.status + assert entry.status.startswith("Ready — ") and "128" not in product.status + assert any(path.startswith("docs/evidence/live-fixtures/") for path in entry.evidence) assert entry.embedded_constants() == entry.asset_arrays() == 0 diff --git a/tests/test_x02b_validated_settings.py b/tests/test_x02b_validated_settings.py index 22ac896..1a63a4b 100644 --- a/tests/test_x02b_validated_settings.py +++ b/tests/test_x02b_validated_settings.py @@ -91,10 +91,9 @@ def _blocks() -> dict: # --------------------------------------------------------------------------- # The record names real parameters # --------------------------------------------------------------------------- -OFFLINE_ONLY: set[str] = {"raster/global_avg_pool2d", "raster/multiply"} +OFFLINE_ONLY: set[str] = set() """The blocks with no live validation yet, by lane-qualified key (two lanes may share a block -name): their status is "Fixed — offline only" and their record is ``NOT_LIVE``. Raster global -average pooling and the elementwise multiply have offline fixtures only.""" +name): their status is "Fixed — offline only" and their record is ``NOT_LIVE``. None today.""" def test_every_block_has_a_record(): @@ -160,6 +159,8 @@ def test_the_record_names_real_parameters(name): "window/batch_norm": {"eps"}, "raster/batch_norm": {"eps", "clip_footprints"}, "raster/standardize": {"clip_footprints"}, + "raster/global_avg_pool2d": {"clip_footprints"}, + "raster/multiply": {"clip_footprints"}, "window/shifted_window_mask": {"value"}, "window/group_product": {"k"}, "window/patch_merge": {"bias"}, @@ -171,6 +172,9 @@ def test_the_record_names_real_parameters(name): # block -> (status numbers that name no setting of the block, the reason). "raster/grouped_conv2d": ({1, 2, 3, 5}, "kernel, stride, channels per group and multiplier are settings " "of the PyTorch module, not parameters of the block"), + "raster/global_avg_pool2d": ({5, 7, 8, 12, 20, 64, 128}, "the map's size is the input's grid, not a parameter " + "of the block"), + "raster/multiply": ({5, 7, 12, 20}, "the maps' sizes are the operands' grids, not parameters of the block"), "window/decimate": ({32, 64, 160, 256}, "decimate takes no width: the widths say where the model ran each factor"), "window/mix_ffn": ({4}, "hidden 4 × width, the MLP ratio, read from the weights"), "window/swin_mlp_branch": ({4}, "hidden 4 × width, the MLP ratio, read from the weights"), diff --git a/tools/fixtures/raster.py b/tools/fixtures/raster.py index 9c154a7..4d6acaa 100644 --- a/tools/fixtures/raster.py +++ b/tools/fixtures/raster.py @@ -804,7 +804,8 @@ def _standardize_fixtures(cost) -> list[Fixture]: # --------------------------------------------------------------------------- # Global average pooling and the elementwise multiply (selection global_pool_multiply) # --------------------------------------------------------------------------- -_GATE_HISTORY = "none: a new block, offline evidence only" +_GATE_HISTORY = ("docs/evidence/live-fixtures/20261112T151417Z-global_pool_multiply.json " + "(passed; Earth Engine equal to the mirror)") _POOL_BOUND = 1e-6 """The pool mirror's recorded bound against Earth Engine: one float32 step of a mean of magnitude at most 6 is 4.8e-7. Every double sum of these inputs is exact (multiples of 2^-21