meshprobe 0.2.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 (51) hide show
  1. meshprobe/__init__.py +8 -0
  2. meshprobe/artifacts.py +333 -0
  3. meshprobe/blender/__init__.py +1 -0
  4. meshprobe/blender/curated_builder.py +172 -0
  5. meshprobe/blender/worker.py +2046 -0
  6. meshprobe/camera.py +225 -0
  7. meshprobe/cli.py +856 -0
  8. meshprobe/client.py +432 -0
  9. meshprobe/contact_sheet.py +83 -0
  10. meshprobe/controller.py +1076 -0
  11. meshprobe/daemon.py +128 -0
  12. meshprobe/evals/__init__.py +5 -0
  13. meshprobe/evals/curated.py +330 -0
  14. meshprobe/evals/curated_build.py +206 -0
  15. meshprobe/evals/curated_tasks.py +533 -0
  16. meshprobe/evals/ergonomics.py +1197 -0
  17. meshprobe/evals/factory.py +472 -0
  18. meshprobe/evals/generators.py +1040 -0
  19. meshprobe/evals/geometry.py +514 -0
  20. meshprobe/evals/harness/__init__.py +19 -0
  21. meshprobe/evals/harness/adapters.py +631 -0
  22. meshprobe/evals/harness/attempts.py +41 -0
  23. meshprobe/evals/harness/broker.py +544 -0
  24. meshprobe/evals/harness/replay.py +111 -0
  25. meshprobe/evals/harness/runner.py +208 -0
  26. meshprobe/evals/harness/sandbox.py +631 -0
  27. meshprobe/evals/harness/suite.py +246 -0
  28. meshprobe/evals/harness/windows_sandbox.py +760 -0
  29. meshprobe/evals/leakage.py +74 -0
  30. meshprobe/evals/migration.py +278 -0
  31. meshprobe/evals/oracles.py +777 -0
  32. meshprobe/evals/reference_agent.py +649 -0
  33. meshprobe/evals/reports.py +245 -0
  34. meshprobe/evals/schemas.py +369 -0
  35. meshprobe/evals/tiers.py +257 -0
  36. meshprobe/evals/variants.py +97 -0
  37. meshprobe/identity.py +12 -0
  38. meshprobe/mcp_server.py +65 -0
  39. meshprobe/models.py +625 -0
  40. meshprobe/protocol.py +152 -0
  41. meshprobe/py.typed +1 -0
  42. meshprobe/selectors.py +97 -0
  43. meshprobe/service.py +110 -0
  44. meshprobe/session.py +106 -0
  45. meshprobe/sources.py +263 -0
  46. meshprobe/workspace.py +682 -0
  47. meshprobe-0.2.0.dist-info/METADATA +248 -0
  48. meshprobe-0.2.0.dist-info/RECORD +51 -0
  49. meshprobe-0.2.0.dist-info/WHEEL +4 -0
  50. meshprobe-0.2.0.dist-info/entry_points.txt +3 -0
  51. meshprobe-0.2.0.dist-info/licenses/LICENSE +202 -0
meshprobe/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Read-only 3D model inspection primitives."""
2
+
3
+ from meshprobe.controller import BlenderController
4
+ from meshprobe.models import SceneManifest
5
+ from meshprobe.session import InspectionSession
6
+
7
+ __all__ = ["BlenderController", "InspectionSession", "SceneManifest"]
8
+ __version__ = "0.2.0"
meshprobe/artifacts.py ADDED
@@ -0,0 +1,333 @@
1
+ """Deterministic, atomic storage for derived MeshProbe artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import uuid
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import dataclass
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import cast
14
+
15
+ from meshprobe.sources import sha256_file
16
+
17
+ type JsonScalar = str | int | float | bool | None
18
+ type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
19
+
20
+ _MANIFEST_NAME = "manifest.json"
21
+ _PAYLOAD_DIRECTORY = "payload"
22
+ _SCHEMA_VERSION = 1
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ArtifactOutput:
27
+ """One immutable file in a cache entry."""
28
+
29
+ relative_path: str
30
+ sha256: str
31
+ bytes: int
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ArtifactCacheEntry:
36
+ """A validated, completely published cache entry."""
37
+
38
+ key: str
39
+ source_sha256: str
40
+ importer_version: str
41
+ normalization_parameters: dict[str, JsonValue]
42
+ output_sha256: str
43
+ outputs: tuple[ArtifactOutput, ...]
44
+ path: Path
45
+
46
+ def output_path(self, relative_path: str) -> Path:
47
+ """Return a declared output path and reject undeclared names."""
48
+
49
+ declared = {output.relative_path for output in self.outputs}
50
+ normalized = _normalized_relative_path(relative_path)
51
+ if normalized not in declared:
52
+ raise KeyError(f"cache entry has no output named {normalized!r}")
53
+ return self.path / _PAYLOAD_DIRECTORY / Path(*PurePosixPath(normalized).parts)
54
+
55
+
56
+ class ArtifactCache:
57
+ """Content-addressed cache whose incomplete writes are never visible."""
58
+
59
+ def __init__(self, root: Path) -> None:
60
+ self.root = root.expanduser().resolve()
61
+ self._entries = self.root / "entries"
62
+ self._staging = self.root / ".staging"
63
+ self._entries.mkdir(parents=True, exist_ok=True)
64
+ self._staging.mkdir(parents=True, exist_ok=True)
65
+
66
+ @staticmethod
67
+ def key_for(
68
+ source_sha256: str,
69
+ importer_version: str,
70
+ normalization_parameters: Mapping[str, JsonValue],
71
+ ) -> str:
72
+ """Derive an entry key from every input that can change normalization."""
73
+
74
+ normalized = _normalized_parameters(normalization_parameters)
75
+ _validate_sha256(source_sha256, "source_sha256")
76
+ _validate_importer_version(importer_version)
77
+ material = {
78
+ "schema_version": _SCHEMA_VERSION,
79
+ "source_sha256": source_sha256,
80
+ "importer_version": importer_version,
81
+ "normalization_parameters": normalized,
82
+ }
83
+ return hashlib.sha256(_canonical_json(material)).hexdigest()
84
+
85
+ def lookup(
86
+ self,
87
+ source_sha256: str,
88
+ importer_version: str,
89
+ normalization_parameters: Mapping[str, JsonValue],
90
+ ) -> ArtifactCacheEntry | None:
91
+ """Return a verified entry or ``None``; staging data is never consulted."""
92
+
93
+ key = self.key_for(source_sha256, importer_version, normalization_parameters)
94
+ entry_path = self._entry_path(key)
95
+ if not entry_path.exists():
96
+ return None
97
+ entry = self._validate_entry(entry_path)
98
+ if entry.key != key:
99
+ raise ValueError(f"cache manifest key mismatch at {entry_path}")
100
+ return entry
101
+
102
+ def publish(
103
+ self,
104
+ source_sha256: str,
105
+ importer_version: str,
106
+ normalization_parameters: Mapping[str, JsonValue],
107
+ write_outputs: Callable[[Path], None],
108
+ ) -> ArtifactCacheEntry:
109
+ """Create and atomically publish one cache entry.
110
+
111
+ ``write_outputs`` receives an empty payload directory on the cache volume.
112
+ It may create files and directories but not symbolic links. If another
113
+ process publishes the same key first, its validated entry wins.
114
+ """
115
+
116
+ parameters = _normalized_parameters(normalization_parameters)
117
+ key = self.key_for(source_sha256, importer_version, parameters)
118
+ existing = self.lookup(source_sha256, importer_version, parameters)
119
+ if existing is not None:
120
+ return existing
121
+
122
+ staging_path = self._staging / f"{key}-{uuid.uuid4().hex}"
123
+ payload_path = staging_path / _PAYLOAD_DIRECTORY
124
+ payload_path.mkdir(parents=True)
125
+ try:
126
+ write_outputs(payload_path)
127
+ outputs = _inventory_payload(payload_path)
128
+ if not outputs:
129
+ raise ValueError("cache publication produced no output files")
130
+ output_sha256 = _outputs_digest(outputs)
131
+ manifest = {
132
+ "schema_version": _SCHEMA_VERSION,
133
+ "key": key,
134
+ "source_sha256": source_sha256,
135
+ "importer_version": importer_version,
136
+ "normalization_parameters": parameters,
137
+ "output_sha256": output_sha256,
138
+ "outputs": [
139
+ {
140
+ "relative_path": output.relative_path,
141
+ "sha256": output.sha256,
142
+ "bytes": output.bytes,
143
+ }
144
+ for output in outputs
145
+ ],
146
+ }
147
+ (staging_path / _MANIFEST_NAME).write_bytes(_canonical_json(manifest) + b"\n")
148
+ self._validate_entry(staging_path)
149
+
150
+ destination = self._entry_path(key)
151
+ destination.parent.mkdir(parents=True, exist_ok=True)
152
+ try:
153
+ os.replace(staging_path, destination)
154
+ except OSError as error:
155
+ if not destination.exists():
156
+ raise
157
+ winner = self._validate_entry(destination)
158
+ if winner.key != key:
159
+ raise ValueError(
160
+ f"concurrent cache publication produced wrong key {winner.key}"
161
+ ) from error
162
+ return self._validate_entry(destination)
163
+ finally:
164
+ shutil.rmtree(staging_path, ignore_errors=True)
165
+
166
+ def evict(self, key: str) -> bool:
167
+ """Remove one complete entry and return whether it existed."""
168
+
169
+ _validate_sha256(key, "cache key")
170
+ entry_path = self._entry_path(key)
171
+ if not entry_path.exists():
172
+ return False
173
+ shutil.rmtree(entry_path)
174
+ return True
175
+
176
+ def cleanup_staging(self) -> int:
177
+ """Remove abandoned unpublished directories and return their count."""
178
+
179
+ removed = 0
180
+ for candidate in self._staging.iterdir():
181
+ if candidate.is_dir() and not candidate.is_symlink():
182
+ shutil.rmtree(candidate)
183
+ else:
184
+ candidate.unlink()
185
+ removed += 1
186
+ return removed
187
+
188
+ def _entry_path(self, key: str) -> Path:
189
+ _validate_sha256(key, "cache key")
190
+ return self._entries / key[:2] / key
191
+
192
+ def _validate_entry(self, entry_path: Path) -> ArtifactCacheEntry:
193
+ manifest_path = entry_path / _MANIFEST_NAME
194
+ payload_path = entry_path / _PAYLOAD_DIRECTORY
195
+ try:
196
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
197
+ except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError) as error:
198
+ raise ValueError(f"invalid cache manifest at {manifest_path}: {error}") from error
199
+ if not isinstance(raw, dict):
200
+ raise ValueError(f"cache manifest must be an object at {manifest_path}")
201
+ if raw.get("schema_version") != _SCHEMA_VERSION:
202
+ raise ValueError(f"unsupported cache manifest schema at {manifest_path}")
203
+
204
+ key = _required_string(raw, "key")
205
+ source_sha256 = _required_string(raw, "source_sha256")
206
+ importer_version = _required_string(raw, "importer_version")
207
+ output_sha256 = _required_string(raw, "output_sha256")
208
+ _validate_sha256(key, "cache manifest key")
209
+ _validate_sha256(source_sha256, "cache manifest source_sha256")
210
+ _validate_sha256(output_sha256, "cache manifest output_sha256")
211
+ _validate_importer_version(importer_version)
212
+
213
+ parameters_raw = raw.get("normalization_parameters")
214
+ if not isinstance(parameters_raw, dict):
215
+ raise ValueError("cache manifest normalization_parameters must be an object")
216
+ parameters = _normalized_parameters(cast(dict[str, JsonValue], parameters_raw))
217
+
218
+ outputs_raw = raw.get("outputs")
219
+ if not isinstance(outputs_raw, list) or not outputs_raw:
220
+ raise ValueError("cache manifest outputs must be a non-empty array")
221
+ outputs: list[ArtifactOutput] = []
222
+ for item in outputs_raw:
223
+ if not isinstance(item, dict):
224
+ raise ValueError("cache manifest output must be an object")
225
+ relative_path = _normalized_relative_path(_required_string(item, "relative_path"))
226
+ sha256 = _required_string(item, "sha256")
227
+ size = item.get("bytes")
228
+ _validate_sha256(sha256, "cache output sha256")
229
+ if not isinstance(size, int) or isinstance(size, bool) or size < 0:
230
+ raise ValueError("cache output bytes must be a non-negative integer")
231
+ outputs.append(ArtifactOutput(relative_path, sha256, size))
232
+ if len({output.relative_path for output in outputs}) != len(outputs):
233
+ raise ValueError("cache manifest output paths must be unique")
234
+ declared = tuple(sorted(outputs, key=lambda output: output.relative_path))
235
+ actual = _inventory_payload(payload_path)
236
+ if actual != declared:
237
+ raise ValueError(f"cache payload does not match manifest at {entry_path}")
238
+ if _outputs_digest(declared) != output_sha256:
239
+ raise ValueError(f"cache output digest mismatch at {entry_path}")
240
+ expected_key = self.key_for(source_sha256, importer_version, parameters)
241
+ if expected_key != key:
242
+ raise ValueError(f"cache input digest mismatch at {entry_path}")
243
+
244
+ return ArtifactCacheEntry(
245
+ key=key,
246
+ source_sha256=source_sha256,
247
+ importer_version=importer_version,
248
+ normalization_parameters=parameters,
249
+ output_sha256=output_sha256,
250
+ outputs=declared,
251
+ path=entry_path,
252
+ )
253
+
254
+
255
+ def _inventory_payload(payload_path: Path) -> tuple[ArtifactOutput, ...]:
256
+ if not payload_path.is_dir() or payload_path.is_symlink():
257
+ raise ValueError("cache payload must be a real directory")
258
+ outputs: list[ArtifactOutput] = []
259
+ for path in sorted(payload_path.rglob("*")):
260
+ if path.is_symlink():
261
+ raise ValueError(f"cache payload cannot contain symbolic links: {path}")
262
+ if path.is_dir():
263
+ continue
264
+ if not path.is_file():
265
+ raise ValueError(f"cache payload contains unsupported entry: {path}")
266
+ relative_path = path.relative_to(payload_path).as_posix()
267
+ outputs.append(
268
+ ArtifactOutput(
269
+ relative_path=_normalized_relative_path(relative_path),
270
+ sha256=sha256_file(path),
271
+ bytes=path.stat().st_size,
272
+ )
273
+ )
274
+ return tuple(outputs)
275
+
276
+
277
+ def _outputs_digest(outputs: tuple[ArtifactOutput, ...]) -> str:
278
+ records = [
279
+ {
280
+ "relative_path": output.relative_path,
281
+ "sha256": output.sha256,
282
+ "bytes": output.bytes,
283
+ }
284
+ for output in outputs
285
+ ]
286
+ return hashlib.sha256(_canonical_json(records)).hexdigest()
287
+
288
+
289
+ def _normalized_parameters(parameters: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
290
+ if any(not isinstance(key, str) for key in parameters):
291
+ raise ValueError("normalization parameter keys must be strings")
292
+ try:
293
+ encoded = _canonical_json(dict(parameters))
294
+ normalized = json.loads(encoded)
295
+ except (TypeError, ValueError) as error:
296
+ raise ValueError(f"normalization parameters must be finite JSON values: {error}") from error
297
+ if not isinstance(normalized, dict):
298
+ raise ValueError("normalization parameters must be an object")
299
+ return cast(dict[str, JsonValue], normalized)
300
+
301
+
302
+ def _canonical_json(value: object) -> bytes:
303
+ return json.dumps(
304
+ value,
305
+ sort_keys=True,
306
+ separators=(",", ":"),
307
+ ensure_ascii=False,
308
+ allow_nan=False,
309
+ ).encode("utf-8")
310
+
311
+
312
+ def _normalized_relative_path(relative_path: str) -> str:
313
+ path = PurePosixPath(relative_path.replace("\\", "/"))
314
+ if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
315
+ raise ValueError(f"cache output path must be a normalized relative path: {relative_path!r}")
316
+ return path.as_posix()
317
+
318
+
319
+ def _required_string(value: Mapping[str, object], key: str) -> str:
320
+ item = value.get(key)
321
+ if not isinstance(item, str) or not item:
322
+ raise ValueError(f"cache manifest {key} must be a non-empty string")
323
+ return item
324
+
325
+
326
+ def _validate_sha256(value: str, label: str) -> None:
327
+ if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
328
+ raise ValueError(f"{label} must be a lowercase SHA-256 digest")
329
+
330
+
331
+ def _validate_importer_version(importer_version: str) -> None:
332
+ if not importer_version or len(importer_version) > 256:
333
+ raise ValueError("importer_version must contain between 1 and 256 characters")
@@ -0,0 +1 @@
1
+ """Blender-side worker implementation."""
@@ -0,0 +1,172 @@
1
+ """Factory-clean Blender batch builder for curated evaluation assemblies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import bpy # type: ignore[import-not-found]
13
+ from mathutils import Vector # type: ignore[import-not-found]
14
+
15
+
16
+ def arguments() -> tuple[Path, Path]:
17
+ args = sys.argv[sys.argv.index("--") + 1 :]
18
+ if len(args) != 2:
19
+ raise ValueError("curated builder expects jobs.json and checkpoint.json")
20
+ return Path(args[0]).resolve(strict=True), Path(args[1]).resolve()
21
+
22
+
23
+ def clear_scene() -> None:
24
+ bpy.ops.object.select_all(action="SELECT")
25
+ bpy.ops.object.delete(use_global=False)
26
+ for collection in list(bpy.data.collections):
27
+ if collection.users == 0:
28
+ bpy.data.collections.remove(collection)
29
+
30
+
31
+ def imported_objects(path: Path) -> list[bpy.types.Object]:
32
+ before = set(bpy.data.objects)
33
+ result = bpy.ops.import_scene.gltf(filepath=str(path))
34
+ if "FINISHED" not in result:
35
+ raise RuntimeError(f"curated source import failed: {path}")
36
+ return [obj for obj in bpy.data.objects if obj not in before]
37
+
38
+
39
+ def parent_preserving_world(obj: bpy.types.Object, parent: bpy.types.Object) -> None:
40
+ matrix = obj.matrix_world.copy()
41
+ obj.parent = parent
42
+ obj.matrix_world = matrix
43
+
44
+
45
+ def mesh_span(objects: list[bpy.types.Object]) -> float:
46
+ points = [
47
+ obj.matrix_world @ Vector(corner)
48
+ for obj in objects
49
+ if obj.type == "MESH"
50
+ for corner in obj.bound_box
51
+ ]
52
+ if not points:
53
+ raise ValueError("curated source contains no mesh objects")
54
+ minimum = Vector(min(point[axis] for point in points) for axis in range(3))
55
+ maximum = Vector(max(point[axis] for point in points) for axis in range(3))
56
+ return float(max((maximum - minimum).length, 1e-6))
57
+
58
+
59
+ def add_camera_and_light(variant: str) -> None:
60
+ camera_data = bpy.data.cameras.new("inspection_camera")
61
+ camera = bpy.data.objects.new("inspection_camera", camera_data)
62
+ bpy.context.scene.collection.objects.link(camera)
63
+ camera.location = (5.5, -8.0, 4.5)
64
+ camera.rotation_euler = (math.radians(67), 0, math.radians(34))
65
+ camera_data.lens = 24 if variant == "start_state" else 50
66
+ bpy.context.scene.camera = camera
67
+ light_data = bpy.data.lights.new("inspection_key", "AREA")
68
+ light_data.energy = 1_000 if variant == "start_state" else 700
69
+ light_data.shape = "DISK"
70
+ light_data.size = 5
71
+ light = bpy.data.objects.new("inspection_key", light_data)
72
+ bpy.context.scene.collection.objects.link(light)
73
+ light.location = (-4, -5, 6) if variant == "start_state" else (4, -5, 6)
74
+
75
+
76
+ def build(job: dict[str, Any]) -> dict[str, object]:
77
+ clear_scene()
78
+ variant = str(job["variant"])
79
+ assembly_root = bpy.data.objects.new("assembly", None)
80
+ bpy.context.scene.collection.objects.link(assembly_root)
81
+ placements = list(job["placements"])
82
+ if variant == "reorder_hierarchy":
83
+ placements.reverse()
84
+ roles: dict[str, list[str]] = {}
85
+ for placement_index, placement in enumerate(placements):
86
+ role = str(placement["role"])
87
+ group = bpy.data.objects.new(f"group_{placement_index + 1:02d}", None)
88
+ bpy.context.scene.collection.objects.link(group)
89
+ group.parent = assembly_root
90
+ objects = imported_objects(Path(str(placement["path"])).resolve(strict=True))
91
+ top_level = [obj for obj in objects if obj.parent not in objects]
92
+ for obj in top_level:
93
+ parent_preserving_world(obj, group)
94
+ scale = 1 / mesh_span(objects)
95
+ group.scale = (scale, scale, scale)
96
+ group.location = tuple(float(value) / 1_000 for value in placement["position_mm"])
97
+ role_names: list[str] = []
98
+ meshes = sorted((obj for obj in objects if obj.type == "MESH"), key=lambda obj: obj.name)
99
+ for mesh_index, obj in enumerate(meshes, start=1):
100
+ suffix = f"part_{mesh_index:02d}" if variant == "rename" else obj.name
101
+ obj.name = f"{role}__{suffix}"
102
+ role_names.append(obj.name)
103
+ roles[role] = role_names
104
+
105
+ if variant == "rigid_transform":
106
+ assembly_root.location = (1.7, -0.8, 0.6)
107
+ assembly_root.rotation_euler[2] = math.radians(37)
108
+ if variant == "rescale":
109
+ assembly_root.scale = (0.1, 0.1, 0.1)
110
+ if variant == "mirror":
111
+ assembly_root.scale.x = -1
112
+ if variant == "material":
113
+ material = bpy.data.materials.new("curated_diagnostic_blue")
114
+ material.diffuse_color = (0.08, 0.25, 0.7, 1)
115
+ for obj in bpy.context.scene.objects:
116
+ if obj.type == "MESH":
117
+ obj.data.materials.clear()
118
+ obj.data.materials.append(material)
119
+ if variant == "extra_occluder":
120
+ bpy.ops.mesh.primitive_cube_add(location=(0.2, -0.55, 0.25), scale=(0.45, 0.12, 0.45))
121
+ occluder = bpy.context.active_object
122
+ occluder.name = "distractor__irrelevant_panel"
123
+ occluder.parent = assembly_root
124
+ roles["distractor"] = [occluder.name]
125
+ add_camera_and_light(variant)
126
+ output = Path(str(job["output"])).resolve()
127
+ output.parent.mkdir(parents=True, exist_ok=True)
128
+ result = bpy.ops.export_scene.gltf(
129
+ filepath=str(output),
130
+ export_format="GLB",
131
+ export_cameras=True,
132
+ export_lights=True,
133
+ )
134
+ if "FINISHED" not in result or not output.is_file():
135
+ raise RuntimeError(f"curated assembly export failed: {output}")
136
+ return {"roles": roles, "variant": variant, "model_file": output.name}
137
+
138
+
139
+ def atomic_json(path: Path, payload: object) -> None:
140
+ pending = path.with_name(f".{path.name}.pending")
141
+ pending.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
142
+ os.replace(pending, path)
143
+
144
+
145
+ def main() -> None:
146
+ jobs_path, checkpoint_path = arguments()
147
+ payload = json.loads(jobs_path.read_text(encoding="utf-8"))
148
+ build_id = str(payload["build_id"])
149
+ jobs = list(payload["jobs"])
150
+ checkpoint: dict[str, Any] = {"build_id": build_id, "completed": []}
151
+ if checkpoint_path.is_file():
152
+ candidate = json.loads(checkpoint_path.read_text(encoding="utf-8"))
153
+ if candidate.get("build_id") == build_id:
154
+ checkpoint = candidate
155
+ completed = set(checkpoint.get("completed", []))
156
+ for job in jobs:
157
+ key = str(job["key"])
158
+ output = Path(str(job["output"]))
159
+ metadata = Path(str(job["metadata"]))
160
+ if key in completed and output.is_file() and metadata.is_file():
161
+ continue
162
+ result = build(job)
163
+ atomic_json(metadata, result)
164
+ completed.add(key)
165
+ atomic_json(
166
+ checkpoint_path,
167
+ {"build_id": build_id, "completed": sorted(completed)},
168
+ )
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()