openmapstack 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.
@@ -0,0 +1,340 @@
1
+ """Clean-rerun and reproducibility assertions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import shlex
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .spatial import connect_spatial
12
+
13
+ from . import failed, load_json, load_project_yaml, not_testable, passed, project_root
14
+
15
+ CLEAN_RERUN_EVIDENCE = ".openmapstack-clean-rerun.json"
16
+ DEFAULT_IGNORED_FIELDS = {
17
+ "completed_at",
18
+ "created_at",
19
+ "generated_at",
20
+ "inputs_hash",
21
+ "outputs_hash",
22
+ "run_id",
23
+ "started_at",
24
+ "timestamp",
25
+ "updated_at",
26
+ }
27
+
28
+
29
+ def _stable_json(value: Any) -> str:
30
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
31
+
32
+
33
+ def _normalize_number(value: Any) -> Any:
34
+ if isinstance(value, float):
35
+ if value == 0:
36
+ return 0.0
37
+ return round(value, 12)
38
+ return value
39
+
40
+
41
+ def _canonical_sequence(values: list[Any], *, reversible: bool) -> list[Any]:
42
+ normalized = [_normalize_json(item, set()) for item in values]
43
+ candidates = [normalized]
44
+ if reversible:
45
+ candidates.append(list(reversed(normalized)))
46
+ return min(candidates, key=_stable_json)
47
+
48
+
49
+ def _canonical_ring(coordinates: list[Any]) -> list[Any]:
50
+ if not coordinates:
51
+ return []
52
+ points = [_normalize_json(point, set()) for point in coordinates]
53
+ if len(points) > 1 and points[0] == points[-1]:
54
+ points = points[:-1]
55
+ if not points:
56
+ return []
57
+ rotations: list[list[Any]] = []
58
+ for sequence in (points, list(reversed(points))):
59
+ rotations.extend(sequence[index:] + sequence[:index] for index in range(len(sequence)))
60
+ canonical = min(rotations, key=_stable_json)
61
+ return canonical + [canonical[0]]
62
+
63
+
64
+ def _normalize_geometry(geometry: Any, ignored_fields: set[str]) -> Any:
65
+ if not isinstance(geometry, dict):
66
+ return _normalize_json(geometry, ignored_fields)
67
+ geometry_type = geometry.get("type")
68
+ coordinates = geometry.get("coordinates")
69
+ result = {
70
+ key: _normalize_json(value, ignored_fields)
71
+ for key, value in geometry.items()
72
+ if key not in ignored_fields and key not in {"coordinates", "geometries"}
73
+ }
74
+ if geometry_type == "Point":
75
+ result["coordinates"] = _normalize_json(coordinates, ignored_fields)
76
+ elif geometry_type == "MultiPoint" and isinstance(coordinates, list):
77
+ result["coordinates"] = sorted(
78
+ (_normalize_json(point, ignored_fields) for point in coordinates), key=_stable_json
79
+ )
80
+ elif geometry_type == "LineString" and isinstance(coordinates, list):
81
+ result["coordinates"] = _canonical_sequence(coordinates, reversible=True)
82
+ elif geometry_type == "MultiLineString" and isinstance(coordinates, list):
83
+ result["coordinates"] = sorted(
84
+ (_canonical_sequence(line, reversible=True) for line in coordinates), key=_stable_json
85
+ )
86
+ elif geometry_type == "Polygon" and isinstance(coordinates, list):
87
+ rings = [_canonical_ring(ring) for ring in coordinates]
88
+ result["coordinates"] = rings[:1] + sorted(rings[1:], key=_stable_json)
89
+ elif geometry_type == "MultiPolygon" and isinstance(coordinates, list):
90
+ polygons = []
91
+ for polygon in coordinates:
92
+ rings = [_canonical_ring(ring) for ring in polygon]
93
+ polygons.append(rings[:1] + sorted(rings[1:], key=_stable_json))
94
+ result["coordinates"] = sorted(polygons, key=_stable_json)
95
+ elif geometry_type == "GeometryCollection":
96
+ result["geometries"] = sorted(
97
+ (_normalize_geometry(item, ignored_fields) for item in geometry.get("geometries", [])),
98
+ key=_stable_json,
99
+ )
100
+ else:
101
+ result["coordinates"] = _normalize_json(coordinates, ignored_fields)
102
+ return result
103
+
104
+
105
+ def _normalize_json(value: Any, ignored_fields: set[str]) -> Any:
106
+ if isinstance(value, dict):
107
+ value_type = value.get("type")
108
+ if value_type in {
109
+ "Point",
110
+ "MultiPoint",
111
+ "LineString",
112
+ "MultiLineString",
113
+ "Polygon",
114
+ "MultiPolygon",
115
+ "GeometryCollection",
116
+ }:
117
+ return _normalize_geometry(value, ignored_fields)
118
+ result = {
119
+ key: _normalize_json(item, ignored_fields)
120
+ for key, item in value.items()
121
+ if key not in ignored_fields
122
+ }
123
+ if value_type == "FeatureCollection" and isinstance(result.get("features"), list):
124
+ result["features"] = sorted(result["features"], key=_stable_json)
125
+ return result
126
+ if isinstance(value, list):
127
+ return [_normalize_json(item, ignored_fields) for item in value]
128
+ return _normalize_number(value)
129
+
130
+
131
+ def _sql_identifier(value: str) -> str:
132
+ return '"' + value.replace('"', '""') + '"'
133
+
134
+
135
+ def _parquet_snapshot(path: Path, ignored_fields: set[str]) -> dict[str, Any]:
136
+ escaped_path = path.as_posix().replace("'", "''")
137
+ connection = connect_spatial()
138
+ if connection is None:
139
+ raise RuntimeError("DuckDB Spatial is not preinstalled")
140
+ try:
141
+ columns = connection.execute(
142
+ f"DESCRIBE SELECT * FROM read_parquet('{escaped_path}')"
143
+ ).fetchall()
144
+ selected: list[str] = []
145
+ schema: list[dict[str, str]] = []
146
+ names: list[str] = []
147
+ for name, type_name, *_ in columns:
148
+ names.append(name)
149
+ schema.append({"name": name, "type": type_name})
150
+ identifier = _sql_identifier(name)
151
+ if str(type_name).upper().startswith("GEOMETRY"):
152
+ selected.append(f"ST_AsGeoJSON({identifier})")
153
+ else:
154
+ selected.append(identifier)
155
+ rows = connection.execute(
156
+ f"SELECT {', '.join(selected)} FROM read_parquet('{escaped_path}')"
157
+ ).fetchall()
158
+ finally:
159
+ connection.close()
160
+
161
+ normalized_rows = []
162
+ for row in rows:
163
+ item: dict[str, Any] = {}
164
+ for name, value, column in zip(names, row, schema, strict=True):
165
+ if column["type"].upper().startswith("GEOMETRY") and isinstance(value, str):
166
+ value = _normalize_geometry(json.loads(value), ignored_fields)
167
+ else:
168
+ value = _normalize_json(value, ignored_fields)
169
+ item[name] = value
170
+ normalized_rows.append(item)
171
+ return {"schema": schema, "rows": sorted(normalized_rows, key=_stable_json)}
172
+
173
+
174
+ def _semantic_snapshot(path: Path, ignored_fields: set[str]) -> Any:
175
+ suffix = path.suffix.lower()
176
+ if suffix in {".json", ".geojson"}:
177
+ return _normalize_json(json.loads(path.read_text(encoding="utf-8")), ignored_fields)
178
+ if suffix in {".parquet", ".geoparquet"}:
179
+ return _parquet_snapshot(path, ignored_fields)
180
+ return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
181
+
182
+
183
+ def clean_execution_succeeded(workspace: Path, rerun_workspace: str) -> Any:
184
+ """Require the runner-owned clean rerun and post-run validation to succeed."""
185
+ evidence_path = Path(rerun_workspace) / CLEAN_RERUN_EVIDENCE
186
+ evidence = load_json(evidence_path)
187
+ if evidence is None:
188
+ return not_testable(f"clean-rerun evidence is missing: {evidence_path}", code="evidence_missing")
189
+ if evidence.get("status") != "passed":
190
+ return failed(
191
+ f"clean rerun failed during {evidence.get('stage')}: {evidence.get('error', 'unknown error')}",
192
+ rerun_stage=evidence.get("stage"),
193
+ code="clean_rerun_failed",
194
+ )
195
+ return passed(
196
+ "canonical entrypoint succeeded in a clean workspace and artifacts revalidated",
197
+ preserved_paths=evidence.get("preserved_paths", []),
198
+ )
199
+
200
+
201
+ def outputs_semantically_equal(
202
+ workspace: Path,
203
+ rerun_workspace: str,
204
+ paths: list[str],
205
+ project_dir: str = ".",
206
+ ignored_fields: list[str] | None = None,
207
+ ) -> Any:
208
+ """Compare outputs after normalizing row, feature, and geometry representation."""
209
+ root = project_root(workspace, project_dir)
210
+ rerun_root = Path(rerun_workspace)
211
+ if not rerun_root.exists():
212
+ return not_testable(f"rerun workspace {rerun_workspace} does not exist", code="rerun_workspace_missing")
213
+ ignored = set(ignored_fields or [])
214
+ missing: list[str] = []
215
+ mismatches: list[str] = []
216
+ errors: dict[str, str] = {}
217
+ for relative in paths:
218
+ original = root / relative
219
+ rerun = rerun_root / relative
220
+ if not original.is_file() or not rerun.is_file():
221
+ missing.append(relative)
222
+ continue
223
+ try:
224
+ if _semantic_snapshot(original, ignored) != _semantic_snapshot(rerun, ignored):
225
+ mismatches.append(relative)
226
+ except Exception as exc: # noqa: BLE001
227
+ errors[relative] = f"{type(exc).__name__}: {exc}"
228
+ if missing:
229
+ return failed(f"outputs missing in one of the two runs: {missing}", missing=missing, code="output_missing")
230
+ if errors:
231
+ return not_testable("could not normalize one or more outputs", errors=errors, code="normalize_error")
232
+ if mismatches:
233
+ return failed(f"semantic outputs changed across clean rerun: {mismatches}", mismatches=mismatches, code="output_semantically_changed")
234
+ return passed(f"all {len(paths)} outputs are semantically equal across the clean rerun")
235
+
236
+
237
+ def outputs_hash_stable(
238
+ workspace: Path, rerun_workspace: str, paths: list[str], project_dir: str = "."
239
+ ) -> Any:
240
+ """Backward-compatible byte comparison; prefer outputs_semantically_equal."""
241
+ root = project_root(workspace, project_dir)
242
+ rerun_root = Path(rerun_workspace)
243
+ missing: list[str] = []
244
+ mismatches: list[str] = []
245
+ for relative in paths:
246
+ original = root / relative
247
+ rerun = rerun_root / relative
248
+ if not original.is_file() or not rerun.is_file():
249
+ missing.append(relative)
250
+ elif original.read_bytes() != rerun.read_bytes():
251
+ mismatches.append(relative)
252
+ if missing:
253
+ return not_testable(f"outputs missing in one of the two runs: {missing}", code="output_missing")
254
+ if mismatches:
255
+ return failed(f"outputs not hash-stable across clean rerun: {mismatches}", code="output_hash_changed")
256
+ return passed(f"all {len(paths)} declared outputs byte-identical across independent reruns")
257
+
258
+
259
+ def validation_report_reproducible(
260
+ workspace: Path,
261
+ rerun_workspace: str,
262
+ project_dir: str = ".",
263
+ report_path: str = "validation/latest-report.json",
264
+ ignored_fields: list[str] | None = None,
265
+ ) -> Any:
266
+ """Compare complete validation evidence, excluding nondeterministic metadata."""
267
+ root = project_root(workspace, project_dir)
268
+ report_a = load_json(root / report_path)
269
+ report_b = load_json(Path(rerun_workspace) / report_path)
270
+ if report_a is None or report_b is None:
271
+ return not_testable("validation report missing in one of the two runs", code="report_missing")
272
+
273
+ explicit_ignored = set(ignored_fields or [])
274
+ normalized_a = _normalize_json(
275
+ {key: value for key, value in report_a.items() if key not in DEFAULT_IGNORED_FIELDS},
276
+ explicit_ignored,
277
+ )
278
+ normalized_b = _normalize_json(
279
+ {key: value for key, value in report_b.items() if key not in DEFAULT_IGNORED_FIELDS},
280
+ explicit_ignored,
281
+ )
282
+ for normalized in (normalized_a, normalized_b):
283
+ if isinstance(normalized, dict) and isinstance(normalized.get("checks"), list):
284
+ normalized["checks"] = sorted(normalized["checks"], key=_stable_json)
285
+ if normalized_a != normalized_b:
286
+ return failed(
287
+ "validation evidence changed across rerun "
288
+ f"(status {report_a.get('status')!r} vs {report_b.get('status')!r})",
289
+ code="validation_evidence_changed",
290
+ )
291
+ return passed(f"validation evidence reproduces with status {report_a.get('status')!r}")
292
+
293
+
294
+ def no_chat_dependency(workspace: Path, project_dir: str = ".") -> Any:
295
+ """Require declared canonical dependencies to avoid transcript-like state."""
296
+ root = project_root(workspace, project_dir)
297
+ project = load_project_yaml(workspace, project_dir)
298
+ implementation = (
299
+ ((project or {}).get("runtime") or {}).get("implementation") or {}
300
+ if isinstance(project, dict)
301
+ else {}
302
+ )
303
+ paths: list[str] = []
304
+ pipeline = implementation.get("pipeline") if isinstance(implementation, dict) else None
305
+ if isinstance(pipeline, str):
306
+ paths.append(pipeline)
307
+ command = implementation.get("command") if isinstance(implementation, dict) else None
308
+ if isinstance(command, str):
309
+ try:
310
+ command = shlex.split(command)
311
+ except ValueError as exc:
312
+ return failed(f"canonical command cannot be parsed: {exc}", code="command_unparseable")
313
+ if isinstance(command, list):
314
+ for token in command:
315
+ if not isinstance(token, str) or token.startswith("-"):
316
+ continue
317
+ candidate = root / token
318
+ try:
319
+ candidate.resolve().relative_to(root.resolve())
320
+ except ValueError:
321
+ continue
322
+ if candidate.is_file():
323
+ paths.append(token)
324
+ dependencies = implementation.get("dependencies", []) if isinstance(implementation, dict) else []
325
+ if isinstance(dependencies, list):
326
+ paths.extend(item for item in dependencies if isinstance(item, str))
327
+ if not paths:
328
+ return not_testable("canonical pipeline/dependencies are not declared", code="dependencies_undeclared")
329
+
330
+ forbidden = ["chat_history", "conversation.json", "transcript.txt", "chat_log"]
331
+ hits: list[str] = []
332
+ for relative in paths:
333
+ path = root / relative
334
+ if not path.is_file():
335
+ continue
336
+ text = path.read_text(encoding="utf-8", errors="ignore")
337
+ hits.extend(f"{relative}:{token}" for token in forbidden if token in text)
338
+ if hits:
339
+ return failed(f"canonical project dependencies reference conversation state: {hits}", code="chat_dependency_found")
340
+ return passed("canonical project dependencies contain no chat/transcript references")
@@ -0,0 +1,65 @@
1
+ """Controlled DuckDB Spatial loading for deterministic evals.
2
+
3
+ Grading code never downloads extensions. A preparation step may explicitly
4
+ install Spatial into a pinned directory, after which tests and evals only use
5
+ ``LOAD spatial``. This separation lets CI prove the suite inside a container
6
+ whose network is disabled at runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ EXTENSION_DIR_ENV = "OPENMAPSTACK_SPATIAL_EXTENSION_DIR"
16
+
17
+
18
+ def _connection_config() -> dict[str, str]:
19
+ configured = os.environ.get(EXTENSION_DIR_ENV)
20
+ if not configured:
21
+ return {}
22
+ extension_dir = Path(configured).expanduser().resolve()
23
+ extension_dir.mkdir(parents=True, exist_ok=True)
24
+ return {"extension_directory": str(extension_dir)}
25
+
26
+
27
+ def connect_spatial(*, install: bool = False) -> Any | None:
28
+ """Return a Spatial-enabled DuckDB connection, or ``None`` if unavailable.
29
+
30
+ ``install=False`` is intentional and is used by every assertion and
31
+ generated pipeline. Only ``evals/prepare_spatial.py`` passes
32
+ ``install=True`` while image/dependency preparation still has network.
33
+ """
34
+ try:
35
+ import duckdb
36
+ except ImportError:
37
+ return None
38
+
39
+ connection = duckdb.connect(config=_connection_config())
40
+ try:
41
+ connection.execute("LOAD spatial")
42
+ return connection
43
+ except Exception:
44
+ if not install:
45
+ connection.close()
46
+ return None
47
+
48
+ try:
49
+ connection.execute("INSTALL spatial")
50
+ connection.execute("LOAD spatial")
51
+ return connection
52
+ except Exception:
53
+ connection.close()
54
+ return None
55
+
56
+
57
+ def require_spatial() -> Any:
58
+ connection = connect_spatial()
59
+ if connection is None:
60
+ directory = os.environ.get(EXTENSION_DIR_ENV, "DuckDB's default extension directory")
61
+ raise RuntimeError(
62
+ "DuckDB Spatial is not preinstalled in "
63
+ f"{directory}; run evals/prepare_spatial.py before disabling network access"
64
+ )
65
+ return connection