tensor-ee 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. tensor_ee/__init__.py +28 -0
  2. tensor_ee/_swin_parameters.py +304 -0
  3. tensor_ee/blocks/__init__.py +0 -0
  4. tensor_ee/blocks/raster/__init__.py +36 -0
  5. tensor_ee/blocks/raster/activations.py +183 -0
  6. tensor_ee/blocks/raster/conv.py +183 -0
  7. tensor_ee/blocks/raster/merge.py +66 -0
  8. tensor_ee/blocks/raster/outputs.py +211 -0
  9. tensor_ee/blocks/raster/pooling.py +182 -0
  10. tensor_ee/blocks/raster/resample.py +64 -0
  11. tensor_ee/blocks/window/__init__.py +44 -0
  12. tensor_ee/blocks/window/activations.py +54 -0
  13. tensor_ee/blocks/window/attention.py +366 -0
  14. tensor_ee/blocks/window/conv.py +323 -0
  15. tensor_ee/blocks/window/linear.py +129 -0
  16. tensor_ee/blocks/window/merge.py +67 -0
  17. tensor_ee/blocks/window/normalization.py +197 -0
  18. tensor_ee/blocks/window/patch.py +362 -0
  19. tensor_ee/blocks/window/pooling.py +275 -0
  20. tensor_ee/blocks/window/resample.py +286 -0
  21. tensor_ee/blocks/window/segformer.py +718 -0
  22. tensor_ee/blocks/window/swin.py +602 -0
  23. tensor_ee/blocks/window/swin_upernet_config.py +361 -0
  24. tensor_ee/blocks/window/upernet.py +1016 -0
  25. tensor_ee/comparison.py +213 -0
  26. tensor_ee/compatibility.py +180 -0
  27. tensor_ee/compiler.py +356 -0
  28. tensor_ee/converter/__init__.py +72 -0
  29. tensor_ee/converter/build.py +120 -0
  30. tensor_ee/converter/convert.py +388 -0
  31. tensor_ee/converter/export.py +846 -0
  32. tensor_ee/converter/graph.py +417 -0
  33. tensor_ee/converter/operations.py +790 -0
  34. tensor_ee/converter/plan.py +675 -0
  35. tensor_ee/converter/recognizers.py +1273 -0
  36. tensor_ee/converter/request.py +146 -0
  37. tensor_ee/converter/run.py +950 -0
  38. tensor_ee/core/__init__.py +8 -0
  39. tensor_ee/core/errors.py +25 -0
  40. tensor_ee/core/grid.py +323 -0
  41. tensor_ee/core/metadata.py +398 -0
  42. tensor_ee/core/ops.py +336 -0
  43. tensor_ee/core/precision.py +133 -0
  44. tensor_ee/core/tensor.py +129 -0
  45. tensor_ee/examples/__init__.py +1 -0
  46. tensor_ee/examples/nonwater.py +251 -0
  47. tensor_ee/layouts/__init__.py +17 -0
  48. tensor_ee/layouts/boundaries.py +103 -0
  49. tensor_ee/layouts/raster.py +117 -0
  50. tensor_ee/layouts/shift.py +226 -0
  51. tensor_ee/layouts/window.py +201 -0
  52. tensor_ee/models/__init__.py +16 -0
  53. tensor_ee/models/fixtures.py +119 -0
  54. tensor_ee/models/segformer_b0/__init__.py +9 -0
  55. tensor_ee/models/segformer_b0/configuration.py +209 -0
  56. tensor_ee/models/segformer_b0/decoder.py +130 -0
  57. tensor_ee/models/segformer_b0/encoder.py +90 -0
  58. tensor_ee/models/swin_t224/__init__.py +30 -0
  59. tensor_ee/models/swin_t224/configuration.py +103 -0
  60. tensor_ee/models/swin_t224/decoder.py +53 -0
  61. tensor_ee/models/swin_t224/encoder.py +50 -0
  62. tensor_ee/onnx_import.py +431 -0
  63. tensor_ee/outputs.py +16 -0
  64. tensor_ee/primitives.py +110 -0
  65. tensor_ee/receptive_field.py +257 -0
  66. tensor_ee/resnet_models.py +185 -0
  67. tensor_ee/segwater_inference.py +235 -0
  68. tensor_ee/segwater_preprocessing.py +182 -0
  69. tensor_ee/segwater_pytorch.py +249 -0
  70. tensor_ee/semantics.py +389 -0
  71. tensor_ee/smp.py +155 -0
  72. tensor_ee/swin.py +197 -0
  73. tensor_ee/swin_workflow.py +470 -0
  74. tensor_ee/tiling.py +287 -0
  75. tensor_ee/weight_assets.py +371 -0
  76. tensor_ee/weight_mapping.py +51 -0
  77. tensor_ee/weights/__init__.py +14 -0
  78. tensor_ee/weights/assets.py +160 -0
  79. tensor_ee/weights/constants.py +156 -0
  80. tensor_ee-0.1.0.dist-info/METADATA +172 -0
  81. tensor_ee-0.1.0.dist-info/RECORD +84 -0
  82. tensor_ee-0.1.0.dist-info/WHEEL +5 -0
  83. tensor_ee-0.1.0.dist-info/licenses/LICENSE +21 -0
  84. tensor_ee-0.1.0.dist-info/top_level.txt +1 -0
tensor_ee/tiling.py ADDED
@@ -0,0 +1,287 @@
1
+ """TensorEE inference on aligned context windows with disjoint output cores.
2
+
3
+ The source is one *finite* global tensor. Each tile is compiled on its larger
4
+ context window, with the window affine translated from the same global grid.
5
+ Context is clamped at the true source boundary, so every intermediate tensor
6
+ has the same finite-padding boundary there as a full-image forward pass. Only
7
+ artificial interior context edges are protected by the caller-supplied halo.
8
+
9
+ The halo must cover the model's exact receptive support. Alignment must cover
10
+ the deepest striding/pooling lattice. These requirements cannot be inferred
11
+ from a random fixture's numerically small distant effects.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Sequence
18
+
19
+ import ee
20
+ from torch import nn
21
+
22
+ from tensor_ee.compiler import (CompiledModel, Grid, Raster, UnsupportedOperation,
23
+ WeightProvider, compile_model)
24
+ from tensor_ee.receptive_field import ReceptiveFieldReport, analyze_receptive_field
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PixelWindow:
29
+ """A half-open rectangle in integer coordinates of the global input grid."""
30
+
31
+ x: int
32
+ y: int
33
+ width: int
34
+ height: int
35
+
36
+ def __post_init__(self) -> None:
37
+ if any(type(value) is not int for value in (self.x, self.y, self.width, self.height)):
38
+ raise ValueError("PixelWindow coordinates and dimensions must be integers")
39
+ if self.x < 0 or self.y < 0 or self.width <= 0 or self.height <= 0:
40
+ raise ValueError("PixelWindow requires nonnegative origins and positive dimensions")
41
+
42
+ @property
43
+ def right(self) -> int:
44
+ return self.x + self.width
45
+
46
+ @property
47
+ def bottom(self) -> int:
48
+ return self.y + self.height
49
+
50
+ @property
51
+ def pixels(self) -> int:
52
+ return self.width * self.height
53
+
54
+ def grid(self, global_grid: Grid) -> Grid:
55
+ if self.right > global_grid.width or self.bottom > global_grid.height:
56
+ raise ValueError("PixelWindow is outside the global finite input")
57
+ a, b, c, d, e, f = global_grid.transform
58
+ return Grid(global_grid.crs,
59
+ (a, b, c + a * self.x, d, e, f + e * self.y),
60
+ self.width, self.height)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Tile:
65
+ """One output core and its larger finite context, in global pixel indices."""
66
+
67
+ index: int
68
+ row: int
69
+ column: int
70
+ core: PixelWindow
71
+ context: PixelWindow
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class TilePlan:
76
+ global_grid: Grid
77
+ roi: PixelWindow
78
+ core_size: tuple[int, int]
79
+ halo: tuple[int, int]
80
+ alignment: int
81
+ tiles: tuple[Tile, ...]
82
+
83
+ @property
84
+ def roi_grid(self) -> Grid:
85
+ return self.roi.grid(self.global_grid)
86
+
87
+ @property
88
+ def vertical_seams(self) -> tuple[int, ...]:
89
+ return tuple(sorted({tile.core.right for tile in self.tiles
90
+ if tile.core.right < self.roi.right}))
91
+
92
+ @property
93
+ def horizontal_seams(self) -> tuple[int, ...]:
94
+ return tuple(sorted({tile.core.bottom for tile in self.tiles
95
+ if tile.core.bottom < self.roi.bottom}))
96
+
97
+ def seam_pixel_count(self, width: int = 1) -> int:
98
+ """Count pixels within ``width`` pixels on each side of internal seams."""
99
+ _positive_integer(width, "seam width")
100
+
101
+ def stripe_pixels(seams: tuple[int, ...], start: int, end: int) -> int:
102
+ intervals = sorted((max(start, seam - width), min(end, seam + width))
103
+ for seam in seams)
104
+ total, previous_end = 0, start
105
+ for left, right in intervals:
106
+ total += max(0, right - max(left, previous_end))
107
+ previous_end = max(previous_end, right)
108
+ return total
109
+
110
+ x_pixels = stripe_pixels(self.vertical_seams, self.roi.x, self.roi.right)
111
+ y_pixels = stripe_pixels(self.horizontal_seams, self.roi.y, self.roi.bottom)
112
+ return (x_pixels * self.roi.height + y_pixels * self.roi.width
113
+ - x_pixels * y_pixels)
114
+
115
+ def metadata(self) -> dict:
116
+ def rectangle(window: PixelWindow) -> dict:
117
+ return {"x": window.x, "y": window.y,
118
+ "width": window.width, "height": window.height}
119
+
120
+ return {
121
+ "global_dimensions": [self.global_grid.width, self.global_grid.height],
122
+ "global_transform": list(self.global_grid.transform),
123
+ "roi": rectangle(self.roi), "core_size": list(self.core_size),
124
+ "halo": list(self.halo), "alignment": self.alignment,
125
+ "tile_count": len(self.tiles), "core_pixel_count": self.roi.pixels,
126
+ "context_pixel_count_sum": sum(tile.context.pixels for tile in self.tiles),
127
+ "vertical_seams": list(self.vertical_seams),
128
+ "horizontal_seams": list(self.horizontal_seams),
129
+ "tiles": [{"index": tile.index, "row": tile.row, "column": tile.column,
130
+ "core": rectangle(tile.core), "context": rectangle(tile.context),
131
+ "context_transform": list(tile.context.grid(self.global_grid).transform)}
132
+ for tile in self.tiles],
133
+ }
134
+
135
+
136
+ def _positive_integer(value: int, name: str) -> None:
137
+ if type(value) is not int or value <= 0:
138
+ raise ValueError(f"{name} must be a positive integer")
139
+
140
+
141
+ def _pair(value: int | tuple[int, int], name: str, *, allow_zero: bool = False) -> tuple[int, int]:
142
+ try:
143
+ values = (value, value) if type(value) is int else tuple(value)
144
+ except TypeError as error:
145
+ raise ValueError(f"{name} must be an integer or an integer pair") from error
146
+ if len(values) != 2 or any(type(item) is not int or item < (0 if allow_zero else 1)
147
+ for item in values):
148
+ qualifier = "nonnegative" if allow_zero else "positive"
149
+ raise ValueError(f"{name} must contain two {qualifier} integers")
150
+ return values
151
+
152
+
153
+ def plan_tiles(global_grid: Grid, core_size: int | tuple[int, int],
154
+ halo: int | tuple[int, int], *, alignment: int,
155
+ roi: PixelWindow | None = None) -> TilePlan:
156
+ """Partition a finite aligned ROI; clamp contexts to real global boundaries.
157
+
158
+ Core sizes are (width, height), halo is (horizontal, vertical). All origins,
159
+ dimensions, and halo widths must be multiples of ``alignment``. A final
160
+ core may be shorter than ``core_size`` while remaining aligned. The caller
161
+ must supply a halo at least as large as the model's exact required support.
162
+ """
163
+ _positive_integer(alignment, "alignment")
164
+ core = _pair(core_size, "core_size")
165
+ margin = _pair(halo, "halo", allow_zero=True)
166
+ region = roi or PixelWindow(0, 0, global_grid.width, global_grid.height)
167
+ region.grid(global_grid) # Validate actual finite domain before planning.
168
+ aligned = {"global width": global_grid.width, "global height": global_grid.height,
169
+ "ROI x": region.x, "ROI y": region.y, "ROI width": region.width,
170
+ "ROI height": region.height, "core width": core[0], "core height": core[1],
171
+ "horizontal halo": margin[0], "vertical halo": margin[1]}
172
+ for name, value in aligned.items():
173
+ if value % alignment:
174
+ raise ValueError(f"{name} must be a multiple of alignment {alignment}")
175
+ tiles = []
176
+ for row, y in enumerate(range(region.y, region.bottom, core[1])):
177
+ for column, x in enumerate(range(region.x, region.right, core[0])):
178
+ output = PixelWindow(x, y, min(core[0], region.right - x),
179
+ min(core[1], region.bottom - y))
180
+ left, top = max(0, x - margin[0]), max(0, y - margin[1])
181
+ right = min(global_grid.width, output.right + margin[0])
182
+ bottom = min(global_grid.height, output.bottom + margin[1])
183
+ context = PixelWindow(left, top, right - left, bottom - top)
184
+ tiles.append(Tile(len(tiles), row, column, output, context))
185
+ return TilePlan(global_grid, region, core, margin, alignment, tuple(tiles))
186
+
187
+
188
+ def seam_mask(plan: TilePlan, width: int = 1) -> ee.Image:
189
+ """Mask an exact union of bands around internal core boundaries."""
190
+ _positive_integer(width, "seam width")
191
+ coordinates = ee.Image.pixelCoordinates(plan.global_grid.projection())
192
+ x, y = coordinates.select("x"), coordinates.select("y")
193
+ mask = ee.Image.constant(0).reproject(plan.global_grid.projection())
194
+ for seam in plan.vertical_seams:
195
+ mask = mask.Or(x.gte(seam - width).And(x.lt(seam + width)))
196
+ for seam in plan.horizontal_seams:
197
+ mask = mask.Or(y.gte(seam - width).And(y.lt(seam + width)))
198
+ return mask.And(plan.roi_grid.mask()).rename("seam")
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class CompiledTiledModel(Raster):
203
+ plan: TilePlan | None = None
204
+ tiles: tuple[CompiledModel, ...] = ()
205
+ nodes: dict[str, Raster] = field(default_factory=dict)
206
+ coverage: ee.Image | None = None
207
+ planned_coverage: ee.Image | None = None
208
+ receptive_field: ReceptiveFieldReport | None = None
209
+
210
+
211
+ def compile_tiled_model(model: nn.Module, image: ee.Image,
212
+ input_bands: Sequence[str], plan: TilePlan, *,
213
+ weight_provider: WeightProvider | None = None,
214
+ output_nodes: Sequence[str] = ()) -> CompiledTiledModel:
215
+ """Compile every context in EE, retain disjoint cores, and mosaic in EE.
216
+
217
+ One shared weight provider serves all tiles, so an asset provider can reuse
218
+ its arrays. Selected diagnostic output nodes must have input resolution.
219
+ The returned grid is the *global* grid; its image is masked to the ROI.
220
+ Every compiled intermediate uses float32 and clipped finite footprints.
221
+ """
222
+ if not plan.tiles:
223
+ raise ValueError("TilePlan must contain at least one core")
224
+ support = analyze_receptive_field(model)
225
+ if plan.alignment % support.alignment:
226
+ raise ValueError(f"Tile alignment must be a multiple of model alignment {support.alignment}")
227
+ for tile in plan.tiles:
228
+ context, core = tile.context, tile.core
229
+ for value in (core.x, core.y, core.width, core.height,
230
+ context.x, context.y, context.width, context.height):
231
+ if value % support.alignment:
232
+ raise ValueError(f"Tile origins and dimensions require model alignment {support.alignment}")
233
+ artificial_margins = []
234
+ if context.x > 0:
235
+ artificial_margins.append(core.x - context.x)
236
+ if context.y > 0:
237
+ artificial_margins.append(core.y - context.y)
238
+ if context.right < plan.global_grid.width:
239
+ artificial_margins.append(context.right - core.right)
240
+ if context.bottom < plan.global_grid.height:
241
+ artificial_margins.append(context.bottom - core.bottom)
242
+ if any(margin < support.safe_halo for margin in artificial_margins):
243
+ raise ValueError(f"Artificial context boundaries require halo at least {support.safe_halo}")
244
+ compiled_tiles = tuple(compile_model(
245
+ model, image, input_bands, tile.context.grid(plan.global_grid),
246
+ weight_provider=weight_provider, include_unfused_nodes=False,
247
+ execution_dtype="float32", clip_footprints=True)
248
+ for tile in plan.tiles)
249
+
250
+ def core_images(rasters: Sequence[Raster]) -> list[ee.Image]:
251
+ bands = rasters[0].bands
252
+ pieces = []
253
+ for tile, raster in zip(plan.tiles, rasters, strict=True):
254
+ if raster.grid != tile.context.grid(plan.global_grid):
255
+ raise UnsupportedOperation("Tiled output must preserve the input spatial grid")
256
+ if raster.bands != bands:
257
+ raise UnsupportedOperation("All tiled outputs must have identical bands")
258
+ core_grid = tile.core.grid(plan.global_grid)
259
+ pieces.append(raster.image.select(list(bands)).clip(core_grid.geometry())
260
+ .updateMask(core_grid.mask()))
261
+ return pieces
262
+
263
+ def mosaic(rasters: Sequence[Raster]) -> Raster:
264
+ result = (ee.ImageCollection.fromImages(core_images(rasters)).mosaic().rename(list(rasters[0].bands))
265
+ .setDefaultProjection(plan.global_grid.projection())
266
+ .updateMask(plan.roi_grid.mask()).toFloat())
267
+ return Raster(result, rasters[0].bands, plan.global_grid)
268
+
269
+ output = mosaic(compiled_tiles)
270
+ nodes = {}
271
+ for name in output_nodes:
272
+ if any(name not in tile.nodes for tile in compiled_tiles):
273
+ raise ValueError(f"Requested tiled output node {name!r} is absent")
274
+ nodes[name] = mosaic([tile.nodes[name] for tile in compiled_tiles])
275
+ masks = [ee.Image.constant(1).rename("tile_coverage")
276
+ .reproject(plan.global_grid.projection())
277
+ .updateMask(tile.core.grid(plan.global_grid).mask()) for tile in plan.tiles]
278
+ planned_coverage = (ee.ImageCollection.fromImages(masks).sum().rename("planned_tile_coverage")
279
+ .setDefaultProjection(plan.global_grid.projection())
280
+ .updateMask(plan.roi_grid.mask()))
281
+ actual_masks = [piece.mask().reduce(ee.Reducer.min()).unmask(0, sameFootprint=False)
282
+ .rename("tile_coverage").reproject(plan.global_grid.projection())
283
+ for piece in core_images(compiled_tiles)]
284
+ coverage = (ee.ImageCollection.fromImages(actual_masks).sum().rename("tile_coverage")
285
+ .setDefaultProjection(plan.global_grid.projection()).updateMask(plan.roi_grid.mask()))
286
+ return CompiledTiledModel(output.image, output.bands, output.grid,
287
+ plan, compiled_tiles, nodes, coverage, planned_coverage, support)
@@ -0,0 +1,371 @@
1
+ """Store arbitrary frozen convolution tensors in Earth Engine table assets.
2
+
3
+ Each feature has a harmless point geometry and properties ``layer``, ``row``,
4
+ ``weights_json`` (one JSON string of a numeric chunk), and, when present, ``bias``. EE table exports
5
+ reject null geometries and list-valued properties. Chunks are sorted by an explicit integer index;
6
+ no inference weight is downloaded or embedded in an asset-backed graph.
7
+
8
+ Uploads are independently measured, bounded request graphs. Nine significant
9
+ figures preserve every finite float32 value on restoration through
10
+ ``ee.Array.float()``. Float32 is the maximum supported model precision.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import json
16
+ from pathlib import Path
17
+ import time
18
+ from typing import Any, Callable, Iterator, Mapping, Sequence
19
+
20
+ import ee
21
+ import numpy as np
22
+ import torch
23
+ from torch import nn
24
+ from torch.fx import Node, symbolic_trace
25
+
26
+ from .compiler import UnsupportedOperation, _ROOT_MODULES
27
+ from .semantics import semantic_call, diagnostic_convolution
28
+ # The asset format and its provider moved to the library; these names are
29
+ # re-exports of the same objects (one FeatureCollectionWeightProvider class).
30
+ from .weights.assets import ( # noqa: F401
31
+ ConvolutionWeights, FeatureCollectionWeightProvider, convolution_weights, feature_records)
32
+
33
+
34
+ def effective_convolutions(model: nn.Module) -> list[ConvolutionWeights]:
35
+ """Collect exactly the final graph's conv tensors, including BN folding.
36
+
37
+ Names follow FX node keys, as ``compile_model`` does. A convolution with a
38
+ sole BatchNorm consumer is replaced by its folded convolution, keyed by the
39
+ BN node. Original conv diagnostics are deliberately absent from the asset.
40
+ """
41
+ if any(module.training for module in model.modules()):
42
+ raise ValueError("Set model.eval() before collecting inference weights")
43
+ if isinstance(model, _ROOT_MODULES):
44
+ model = nn.Sequential(model)
45
+ traced = symbolic_trace(model)
46
+ result = []
47
+ for node in traced.graph.nodes:
48
+ if node.op in ("placeholder", "output"):
49
+ continue
50
+ try:
51
+ call = semantic_call(node, traced)
52
+ except UnsupportedOperation as error:
53
+ raise UnsupportedOperation(f"At FX node {node.name} ({node.target}): {error}") from error
54
+ if call.kind == "conv" and not diagnostic_convolution(node, traced):
55
+ result.append(convolution_weights(node.name, call.module, str(node.target)))
56
+ elif call.kind == "batchnorm":
57
+ source = str(call.options["predecessor"].target) + "+" + str(node.target)
58
+ result.append(convolution_weights(node.name, call.module, source))
59
+ if not result:
60
+ raise ValueError("Model contains no deployable convolutions")
61
+ return result
62
+
63
+
64
+ def float32_wire_values(values: np.ndarray) -> list[float]:
65
+ """Short decimal values which round back to the exact source float32s."""
66
+ return [float(format(float(value), ".9g")) for value in values]
67
+
68
+
69
+ def collection_from_records(records: Sequence[Mapping[str, Any]]) -> ee.FeatureCollection:
70
+ return ee.FeatureCollection([ee.Feature(dict(record)) for record in records])
71
+
72
+
73
+ def _split_measured(records: list[dict[str, Any]], max_bytes: int) -> Iterator[tuple[list[dict[str, Any]], int]]:
74
+ actual = len(ee.serializer.toJSON(collection_from_records(records)).encode("utf-8"))
75
+ if actual <= max_bytes:
76
+ yield records, actual
77
+ elif len(records) == 1:
78
+ raise ValueError(f"One row requires {actual} serialized bytes, exceeding {max_bytes}")
79
+ else:
80
+ center = len(records) // 2
81
+ yield from _split_measured(records[:center], max_bytes)
82
+ yield from _split_measured(records[center:], max_bytes)
83
+
84
+
85
+ def upload_chunks(parameters: Sequence[ConvolutionWeights], max_bytes: int = 8_000_000
86
+ ) -> Iterator[tuple[list[dict[str, Any]], int]]:
87
+ """Pack rows conservatively, then verify each actual EE graph byte count."""
88
+ if not 100_000 <= max_bytes <= 8_000_000:
89
+ raise ValueError("max_bytes must be between 100,000 and 8,000,000")
90
+ records = []
91
+ rough_bytes = 0
92
+ # A literal FC is converted into API feature expressions. Reserving 10%
93
+ # and 32 KB covers that overhead; the exact serializer check is decisive.
94
+ target = int(max_bytes * .9) - 32_000
95
+ for record in feature_records(parameters):
96
+ size = len(json.dumps(record, separators=(",", ":")).encode()) + 512
97
+ if records and rough_bytes + size > target:
98
+ yield from _split_measured(records, max_bytes)
99
+ records, rough_bytes = [], 0
100
+ records.append(record)
101
+ rough_bytes += size
102
+ if records:
103
+ yield from _split_measured(records, max_bytes)
104
+
105
+
106
+ def _write_manifest(path: Path, manifest: dict[str, Any]) -> None:
107
+ path.parent.mkdir(parents=True, exist_ok=True)
108
+ temporary = path.with_suffix(path.suffix + ".tmp")
109
+ temporary.write_text(json.dumps(manifest, indent=2, allow_nan=False) + "\n")
110
+ temporary.replace(path)
111
+
112
+
113
+ class _RecordedTask:
114
+ """A task an earlier run started, known only by the ID its manifest recorded: polled
115
+ with ``ee.data.getTaskStatus``."""
116
+
117
+ def __init__(self, task_id: str):
118
+ self.id = task_id
119
+
120
+ def status(self) -> dict[str, Any]:
121
+ return ee.data.getTaskStatus([self.id])[0]
122
+
123
+
124
+ def _layer_metadata(manifest: Mapping[str, Any]) -> dict[str, Any]:
125
+ return {key: {k: v for k, v in meta.items() if k != "assets"} for key, meta in manifest["layers"].items()}
126
+
127
+
128
+ def _asset_exists(asset_id: str) -> bool:
129
+ try:
130
+ ee.data.getAsset(asset_id)
131
+ except ee.EEException:
132
+ return False
133
+ return True
134
+
135
+
136
+ def _resume(fresh: dict[str, Any], manifest_path: Path, iterator) -> tuple[dict[str, Any], list, list]:
137
+ """The manifest an interrupted ``export_weights`` left, reconciled for these parameters:
138
+ (manifest, tasks to poll, shards to dispatch again). Refused when it belongs to other
139
+ parameters or another chunking, or when a shard FAILED or was CANCELLED."""
140
+ manifest = json.loads(Path(manifest_path).read_text())
141
+ for name in ("format", "asset_namespace", "max_collection_payload_bytes", "parameter_count"):
142
+ if manifest.get(name) != fresh[name]:
143
+ raise ValueError(f"the manifest's {name} differs: it is not an upload of these parameters")
144
+ if _layer_metadata(manifest) != _layer_metadata(fresh):
145
+ raise ValueError("the manifest's layers differ: it is not an upload of these parameters")
146
+ if manifest.get("status") not in ("uploading", "ready"):
147
+ raise ValueError(f"the upload is {manifest.get('status')!r}: a failed shard is not resumed")
148
+ polling, redispatch = [], []
149
+ for shard in manifest["shards"]:
150
+ try:
151
+ records, byte_count = next(iterator)
152
+ except StopIteration:
153
+ raise ValueError("the manifest records more shards than these parameters make") from None
154
+ layers = sorted({record["properties"]["layer"] for record in records})
155
+ expected = {"asset_id": f"{fresh['asset_namespace']}_part_{shard['index']:04d}", "rows": len(records),
156
+ "layers": layers, "collection_payload_bytes": byte_count}
157
+ if any(shard.get(k) != v for k, v in expected.items()):
158
+ raise ValueError(f"shard {shard['index']} differs from the chunking of these parameters")
159
+ state = shard.get("status")
160
+ if state == "COMPLETED":
161
+ continue
162
+ if state in ("FAILED", "CANCELLED"):
163
+ raise ValueError(f"shard {shard['asset_id']} is {state}: it is not resumed")
164
+ if shard.get("task_id"): # started: poll it to the end
165
+ polling.append((_RecordedTask(shard["task_id"]), shard))
166
+ elif _asset_exists(shard["asset_id"]): # recorded "starting", but its export finished
167
+ shard.update(status="COMPLETED", finished_unix=time.time(), resumed="asset found")
168
+ else: # never started: dispatch it again
169
+ redispatch.append((records, byte_count, shard))
170
+ return manifest, polling, redispatch
171
+
172
+
173
+ def export_weights(parameters: Sequence[ConvolutionWeights], asset_namespace: str,
174
+ manifest_path: Path, *, max_bytes: int = 8_000_000,
175
+ max_active: int = 2, poll_seconds: float = 15,
176
+ on_update: Callable[[dict[str, Any]], None] | None = None,
177
+ resume: bool = False) -> dict[str, Any]:
178
+ """Upload FC shards, waiting for completion with bounded batch concurrency.
179
+
180
+ ``asset_namespace`` is a new unique prefix under projects/.../assets. This
181
+ function never overwrites or deletes assets. It updates its local manifest
182
+ immediately after each dispatched task and completion, so interrupted runs
183
+ remain auditable. A failed shard is reported and prevents a ready manifest.
184
+
185
+ ``resume=True`` continues the upload that ``manifest_path`` records, for the same
186
+ parameters and chunking (refused otherwise): it polls the recorded tasks to their
187
+ end; a shard recorded "starting" without a task ID is complete if its asset exists
188
+ (one ``getAsset``) and dispatched again if not; the remaining shards follow from the
189
+ same deterministic chunking; then the manifest is finalized. A manifest that is
190
+ already ready is returned as it is. Without ``resume`` the manifest starts afresh.
191
+ """
192
+ if max_active not in (1, 2):
193
+ raise ValueError("Asset upload uses at most two simultaneous exports")
194
+ if not 1 <= poll_seconds <= 30:
195
+ raise ValueError("poll_seconds must be between 1 and 30")
196
+ keys = [parameter.key for parameter in parameters]
197
+ if len(keys) != len(set(keys)):
198
+ raise ValueError("Duplicate convolution keys")
199
+ manifest: dict[str, Any] = {
200
+ "format": "native_ee_featurecollection_weights_v2", "status": "uploading",
201
+ "asset_namespace": asset_namespace, "started_unix": time.time(),
202
+ "serialization": "JSON strings of numeric chunks; 9 significant decimal digits for float32; float() restoration",
203
+ "dummy_geometry": "Point(0, 0); EE table exports require non-null geometry",
204
+ "max_collection_payload_bytes": max_bytes, "max_active_tasks": max_active,
205
+ "parameter_count": sum(parameter.weights.size + (0 if parameter.bias is None else parameter.bias.size)
206
+ for parameter in parameters),
207
+ "layers": {parameter.key: {"shape": list(parameter.shape), "dtype": parameter.dtype,
208
+ "bias": parameter.bias is not None, "source": parameter.source,
209
+ "sha256": parameter.sha256, "assets": []}
210
+ for parameter in parameters}, "shards": [],
211
+ }
212
+ iterator = iter(upload_chunks(parameters, max_bytes))
213
+ active: list[tuple[Any, dict[str, Any]]] = []
214
+ pending: list[tuple[list[dict[str, Any]], int, dict[str, Any]]] = []
215
+ exhausted = False
216
+ if resume:
217
+ manifest, active, pending = _resume(manifest, Path(manifest_path), iterator)
218
+ if manifest["status"] == "ready":
219
+ return manifest
220
+
221
+ def save() -> None:
222
+ _write_manifest(manifest_path, manifest)
223
+ if on_update:
224
+ on_update(manifest)
225
+
226
+ def dispatch(records: list[dict[str, Any]], byte_count: int, shard: dict[str, Any] | None) -> None:
227
+ if shard is None: # the next shard of the chunking
228
+ index = len(manifest["shards"])
229
+ asset_id = f"{asset_namespace}_part_{index:04d}"
230
+ layers = sorted({record["properties"]["layer"] for record in records})
231
+ shard = {"index": index, "asset_id": asset_id, "rows": len(records),
232
+ "layers": layers, "collection_payload_bytes": byte_count,
233
+ "status": "starting", "started_unix": time.time()}
234
+ manifest["shards"].append(shard)
235
+ for key in layers:
236
+ manifest["layers"][key]["assets"].append(asset_id)
237
+ else: # a recorded shard that never started
238
+ shard.update(status="starting", started_unix=time.time())
239
+ save()
240
+ task = ee.batch.Export.table.toAsset(
241
+ collection_from_records(records), description=f"native_unet_weights_part_{shard['index']:04d}",
242
+ assetId=shard["asset_id"], overwrite=False)
243
+ try:
244
+ task.start()
245
+ except Exception as error:
246
+ shard.update(status="FAILED", error=f"{type(error).__name__}: {error}")
247
+ manifest.update(status="failed", finished_unix=time.time())
248
+ # Record the failure, but never let the save's callback (a polling cap, say)
249
+ # replace the start() error.
250
+ with contextlib.suppress(Exception):
251
+ save()
252
+ raise
253
+ shard.update(task_id=task.id, status="READY")
254
+ active.append((task, shard))
255
+ save()
256
+
257
+ save()
258
+ while active or pending or not exhausted:
259
+ while (pending or not exhausted) and len(active) < max_active:
260
+ if pending:
261
+ dispatch(*pending.pop(0))
262
+ continue
263
+ try:
264
+ records, byte_count = next(iterator)
265
+ except StopIteration:
266
+ exhausted = True
267
+ break
268
+ dispatch(records, byte_count, None)
269
+ any_finished = False
270
+ for task, shard in list(active):
271
+ status = task.status()
272
+ shard["task_status"] = status
273
+ shard["status"] = status["state"]
274
+ if status["state"] in ("COMPLETED", "FAILED", "CANCELLED"):
275
+ shard["finished_unix"] = time.time()
276
+ active.remove((task, shard))
277
+ any_finished = True
278
+ if status["state"] != "COMPLETED":
279
+ manifest.update(status="failed", finished_unix=time.time())
280
+ save()
281
+ raise RuntimeError(f"Weight shard {shard['asset_id']} failed: {status}")
282
+ save()
283
+ if active and not any_finished:
284
+ time.sleep(poll_seconds)
285
+ manifest.update(status="ready", finished_unix=time.time(),
286
+ collection_payload_bytes=sum(shard["collection_payload_bytes"] for shard in manifest["shards"]))
287
+ save()
288
+ return manifest
289
+
290
+
291
+ def probe_asset_roundtrip(asset_namespace: str, result_path: Path) -> dict[str, Any]:
292
+ """Live all-element test, including a standard ResNet18 length-4,608 row."""
293
+ rng = np.random.default_rng(47)
294
+ weights = rng.normal(0, .025, (3, 4608)).astype(np.float32)
295
+ bias = np.array([-.03, .0, .03], dtype=np.float32)
296
+ parameter = ConvolutionWeights("probe", weights, bias, "float32", "synthetic_probe")
297
+ manifest_path = result_path.with_name(result_path.stem + "_manifest.json")
298
+ start = time.perf_counter()
299
+ manifest = export_weights([parameter], asset_namespace, manifest_path, poll_seconds=5)
300
+ provider = FeatureCollectionWeightProvider(manifest)
301
+ actual, actual_bias = provider.arrays("probe")
302
+ expected = ee.Array(weights.astype(float).tolist())
303
+ expected_bias = ee.Array(bias.astype(float).reshape(-1, 1).tolist())
304
+ collection = ee.FeatureCollection(manifest["shards"][0]["asset_id"])
305
+ verification = ee.Dictionary({
306
+ "shape": actual.length(), "feature_count": collection.size(), "row_count": collection.filter(ee.Filter.eq("chunk", 0)).size(),
307
+ "max_weight_absolute_error": actual.subtract(expected).abs().reduce(ee.Reducer.max(), [0, 1]),
308
+ "max_bias_absolute_error": actual_bias.subtract(expected_bias).abs().reduce(ee.Reducer.max(), [0, 1]),
309
+ })
310
+ check_start = time.perf_counter()
311
+ raw = verification.getInfo()
312
+ report = {"status": "pass" if (raw["max_weight_absolute_error"] == [[0]] and raw["max_bias_absolute_error"] == [[0]]
313
+ and raw["shape"] == [3, 4608] and raw["row_count"] == 3) else "fail",
314
+ "manifest_path": str(manifest_path), "asset_ids": [shard["asset_id"] for shard in manifest["shards"]],
315
+ "row_length": 4608, "source_sha256": parameter.sha256, "verification": raw,
316
+ "verification_seconds": time.perf_counter() - check_start,
317
+ "total_seconds": time.perf_counter() - start,
318
+ "verification_request_bytes": len(ee.serializer.toJSON(verification).encode("utf-8")),
319
+ "serialization": manifest["serialization"]}
320
+ _write_manifest(result_path, report)
321
+ return report
322
+
323
+
324
+ def verify_asset_rows(parameters: Sequence[ConvolutionWeights], manifest: Mapping[str, Any] | str | Path,
325
+ result_path: Path, *, rows_per_layer: int = 3,
326
+ on_update: Callable[[dict[str, Any]], None] | None = None) -> dict[str, Any]:
327
+ """Check deterministic rows of every server-reconstructed matrix exactly.
328
+
329
+ This is a storage and ordering check, separate from model output parity.
330
+ Only scalar errors, shapes, and timing are returned. The exact source
331
+ fixtures are embedded for selected rows, keeping each query comfortably
332
+ below the request payload cap. It does not claim a full-asset checksum.
333
+ """
334
+ if rows_per_layer < 1:
335
+ raise ValueError("rows_per_layer must be positive")
336
+ provider = FeatureCollectionWeightProvider(manifest)
337
+ report: dict[str, Any] = {"status": "running", "started_unix": time.time(),
338
+ "comparison": "deterministic sampled rows from every layer; native server reconstruction",
339
+ "layers": []}
340
+ _write_manifest(result_path, report)
341
+ for parameter in parameters:
342
+ metadata = provider.manifest["layers"][parameter.key]
343
+ if parameter.sha256 != metadata["sha256"]:
344
+ raise ValueError(f"Source fingerprint mismatch for {parameter.key}")
345
+ weights, bias = provider.arrays(parameter.key)
346
+ indices = sorted(set(int(value) for value in np.linspace(0, parameter.shape[0] - 1,
347
+ min(rows_per_layer, parameter.shape[0]))))
348
+ row_checks = {}
349
+ for index in indices:
350
+ expected = ee.Array(parameter.weights[index:index + 1].astype(float).tolist())
351
+ difference = weights.slice(0, index, index + 1).subtract(expected).abs()
352
+ row_checks[f"weight_error_row_{index}"] = difference.reduce(ee.Reducer.max(), [0, 1]).get([0, 0])
353
+ if bias is not None:
354
+ row_checks[f"bias_error_row_{index}"] = bias.get([index, 0]).subtract(float(parameter.bias[index])).abs()
355
+ row_checks["shape"] = weights.length()
356
+ request = ee.Dictionary(row_checks)
357
+ started = time.perf_counter()
358
+ raw = request.getInfo()
359
+ errors = [value for key, value in raw.items() if key != "shape"]
360
+ entry = {"key": parameter.key, "rows": indices, "shape": list(parameter.shape),
361
+ "status": "pass" if raw["shape"] == list(parameter.shape) and all(value == 0 for value in errors) else "fail",
362
+ "max_absolute_error": max(errors), "evaluation_seconds": time.perf_counter() - started,
363
+ "request_bytes": len(ee.serializer.toJSON(request).encode()), "server_metrics": raw}
364
+ report["layers"].append(entry)
365
+ _write_manifest(result_path, report)
366
+ if on_update:
367
+ on_update(entry)
368
+ report.update(status="pass" if all(entry["status"] == "pass" for entry in report["layers"]) else "fail",
369
+ finished_unix=time.time())
370
+ _write_manifest(result_path, report)
371
+ return report