openmapstack 0.3.0__py3-none-any.whl → 0.4.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.
openmapstack/__init__.py CHANGED
@@ -3,4 +3,4 @@
3
3
  from .validation import Check, ValidationResult, validate_project
4
4
 
5
5
  __all__ = ["Check", "ValidationResult", "validate_project"]
6
- __version__ = "0.3.0"
6
+ __version__ = "0.4.0"
openmapstack/api.py CHANGED
@@ -263,6 +263,8 @@ def api_info() -> dict[str, Any]:
263
263
  "package_version": __version__,
264
264
  "check_api_version": CHECK_API_VERSION,
265
265
  "project_schema": PROJECT_SCHEMA,
266
+ "skill_snapshot_schemas": ["openmapstack-skill-snapshot/v1", "openmapstack-skill-snapshot/v2"],
267
+ "benchmark_arm_schemas": ["openmapstack-benchmark-arm/v1", "openmapstack-benchmark-arm/v2"],
266
268
  "result_schemas": {
267
269
  "check": CHECK_RESULT_SCHEMA,
268
270
  "verify": VERIFY_RESULT_SCHEMA,
@@ -48,9 +48,9 @@ def _geometry_column(con, rel: str, preferred: str | None = None) -> str | None:
48
48
  except Exception: # noqa: BLE001
49
49
  return preferred
50
50
  by_name = {str(name): str(type_name).upper() for name, type_name, *_ in columns}
51
- if preferred and preferred in by_name:
52
- return preferred
53
- typed = [name for name, type_name in by_name.items() if type_name == "GEOMETRY"]
51
+ if preferred:
52
+ return preferred if preferred in by_name else None
53
+ typed = [name for name, type_name in by_name.items() if type_name == "GEOMETRY" or type_name.startswith("GEOMETRY(")]
54
54
  if len(typed) == 1:
55
55
  return typed[0]
56
56
  candidates = typed or list(by_name)
@@ -60,6 +60,37 @@ def _geometry_column(con, rel: str, preferred: str | None = None) -> str | None:
60
60
  return typed[0] if typed else preferred
61
61
 
62
62
 
63
+ def _geometry_expression(con, rel: str, preferred: str | None = None) -> str | None:
64
+ """Resolve typed geometry or WKB without assigning an invented CRS.
65
+
66
+ Spatial reads GeoParquet metadata into GEOMETRY (including CRS-qualified
67
+ types). Bare WKB Parquet remains BLOB: it can be checked for validity, but
68
+ ST_CRS on its decoded value correctly reports missing metadata.
69
+ """
70
+ column = _geometry_column(con, rel, preferred)
71
+ if column is None:
72
+ return None
73
+ columns = con.execute(f"SELECT * FROM {rel} LIMIT 0").description or []
74
+ types = {str(name): str(kind).upper() for name, kind, *_ in columns}
75
+ identifier = '"' + column.replace('"', '""') + '"'
76
+ if types.get(column) == "BLOB":
77
+ return f"ST_GeomFromWKB({identifier})"
78
+ if types.get(column, "").startswith("GEOMETRY"):
79
+ return identifier
80
+ return None
81
+
82
+
83
+ def _invalid_geometry_count(con, rel: str, preferred: str | None = None) -> int | None:
84
+ expression = _geometry_expression(con, rel, preferred)
85
+ if expression is None:
86
+ return None
87
+ # Missing geometries cannot silently count as valid via SQL NULL semantics.
88
+ return con.execute(
89
+ f"SELECT COUNT(*) FROM {rel} "
90
+ f"WHERE {expression} IS NULL OR NOT ST_IsValid({expression})"
91
+ ).fetchone()[0]
92
+
93
+
63
94
  def row_count(workspace: Path, path: str, equals: int | None = None, at_least: int | None = None,
64
95
  at_most: int | None = None, project_dir: str = ".") -> AssertionResult:
65
96
  target = project_root(workspace, project_dir) / path
@@ -92,12 +123,10 @@ def geometry_all_valid(workspace: Path, path: str, project_dir: str = ".") -> As
92
123
  return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
93
124
  try:
94
125
  rel = _read(con, target)
95
- column = _geometry_column(con, rel)
96
- if column is None:
97
- return not_testable(f"{path} has no geometry column", code="geometry_column_missing")
98
- total, invalid = con.execute(
99
- f'SELECT COUNT(*), SUM(CASE WHEN NOT ST_IsValid("{column}") THEN 1 ELSE 0 END) FROM {rel}'
100
- ).fetchone()
126
+ invalid = _invalid_geometry_count(con, rel)
127
+ if invalid is None:
128
+ return failed(f"{path} has no geometry column", code="geometry_column_missing")
129
+ total = con.execute(f"SELECT COUNT(*) FROM {rel}").fetchone()[0]
101
130
  except Exception as exc: # noqa: BLE001
102
131
  return not_testable(f"could not validate geometry in {path}: {exc}", code="read_error")
103
132
  invalid = invalid or 0
@@ -299,10 +328,14 @@ def dataset_crs_is(
299
328
  return not_testable("duckdb spatial not available in this environment", code="duckdb_unavailable")
300
329
  try:
301
330
  rel = _read(con, target)
302
- column = _geometry_column(con, rel, geometry_field)
303
- if column is None:
304
- return not_testable(f"{path} has no geometry column", code="geometry_column_missing")
305
- rows = con.execute(f'SELECT DISTINCT ST_CRS("{column}") FROM {rel}').fetchall()
331
+ expression = _geometry_expression(con, rel, geometry_field)
332
+ if expression is None:
333
+ return failed(f"{path} has no geometry column", code="geometry_column_missing")
334
+ # The NULL row preserves a typed column's CRS even for an empty dataset.
335
+ rows = con.execute(
336
+ f"SELECT DISTINCT ST_CRS(geometry_value) FROM "
337
+ f"(SELECT {expression} AS geometry_value FROM {rel} UNION ALL SELECT NULL)"
338
+ ).fetchall()
306
339
  except Exception as exc: # noqa: BLE001
307
340
  return not_testable(f"could not inspect CRS metadata in {path}: {exc}", code="read_error")
308
341
  actual = sorted({str(row[0]).upper() for row in rows if row and row[0]})
@@ -316,3 +349,36 @@ def dataset_crs_is(
316
349
  expected=expected.upper(),
317
350
  )
318
351
  return passed(f"{path} actual CRS metadata is {expected.upper()}", actual=actual)
352
+
353
+
354
+ # GeoParquet always stores longitude/latitude and defaults WGS 84 to
355
+ # OGC:CRS84, so a declared EPSG:4326 is written as either identifier.
356
+ _EQUIVALENT_CRS = ({"EPSG:4326", "OGC:CRS84"},)
357
+
358
+
359
+ def dataset_crs_matches_storage_crs(
360
+ workspace: Path,
361
+ path: str,
362
+ geometry_field: str | None = None,
363
+ project_dir: str = ".",
364
+ ) -> AssertionResult:
365
+ """Require a dataset's real CRS metadata to match `processing.storage_crs`.
366
+
367
+ Checks that the project writes what it declares, without prescribing
368
+ which storage CRS it should choose.
369
+ """
370
+ from . import get_in, load_project_yaml
371
+
372
+ proj = load_project_yaml(workspace, project_dir)
373
+ if proj is None:
374
+ return failed("project.yaml missing", code="manifest_missing")
375
+ declared = get_in(proj, "processing.storage_crs")
376
+ if not isinstance(declared, str) or not declared.strip():
377
+ return failed("processing.storage_crs is not declared", code="storage_crs_missing")
378
+ declared = declared.strip().upper()
379
+ for group in _EQUIVALENT_CRS:
380
+ if declared in group:
381
+ results = [dataset_crs_is(workspace, path, crs, geometry_field, project_dir) for crs in sorted(group)]
382
+ match = next((result for result in results if result.status == "passed"), None)
383
+ return match or results[0]
384
+ return dataset_crs_is(workspace, path, declared, geometry_field, project_dir)
@@ -9,13 +9,15 @@ validation isn't available.
9
9
  from __future__ import annotations
10
10
 
11
11
  import html
12
+ import json
12
13
  import re
14
+ import sqlite3
13
15
  import tempfile
14
16
  import zipfile
15
17
  from pathlib import Path
16
18
  from typing import Any
17
19
 
18
- from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root
20
+ from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root, warning
19
21
 
20
22
 
21
23
  def _extract_qgs_xml(qgz_path: Path) -> str | None:
@@ -31,9 +33,13 @@ def _extract_qgs_xml(qgz_path: Path) -> str | None:
31
33
 
32
34
 
33
35
  def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
34
- """The .qgz opens as a zip containing a .qgs, every <datasource> referencing
35
- a relative file path resolves on disk, and GeoPackage datasources declare
36
- a layername= (otherwise GDAL silently loads a non-spatial attribute table)."""
36
+ """The .qgz opens as a zip containing a .qgs, every local <datasource> is a
37
+ relative path that resolves on disk, and GeoPackage datasources declare
38
+ a layername= (otherwise GDAL silently loads a non-spatial attribute table).
39
+
40
+ An absolute path fails even when it exists: a QGIS-authored project once
41
+ shipped the author's own paths and passed because they resolved locally.
42
+ """
37
43
  qgz_path = project_root(workspace, project_dir) / path
38
44
  if not qgz_path.exists():
39
45
  return failed(f"{path} does not exist", code="file_missing")
@@ -57,6 +63,10 @@ def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str =
57
63
  if ds.startswith(("http", "type=xyz", "contextualWMSLegend", "crs=")) or "url=" in ds:
58
64
  continue
59
65
  raw_path = ds.split("|", 1)[0]
66
+ if Path(raw_path).is_absolute() or re.match(r"^[A-Za-z]:[\\/]", raw_path):
67
+ # Resolves on the machine that wrote it and nowhere else.
68
+ errors.append(f"datasource uses an absolute path: {raw_path}")
69
+ continue
60
70
  if raw_path.lower().endswith(".gpkg") and "layername=" not in ds:
61
71
  errors.append(f"GeoPackage datasource missing layername=: {ds}")
62
72
  resolved = (root / raw_path).resolve() if raw_path.startswith("./") or not raw_path.startswith("/") else Path(raw_path)
@@ -68,6 +78,143 @@ def static_valid(workspace: Path, path: str = "project.qgz", project_dir: str =
68
78
  return passed(f"{path} static-valid: {len(datasources)} datasource(s), all files resolve")
69
79
 
70
80
 
81
+ #: File formats whose GDAL drivers are built into every QGIS build. Anything
82
+ #: else may need an optional driver: Debian/Ubuntu QGIS packages ship without
83
+ #: Parquet and Arrow (checked on QGIS 3.40.15, GDAL 3.12.2), and formats such
84
+ #: as Personal Geodatabase depend on extra libraries.
85
+ PORTABLE_SUFFIXES = frozenset({".gpkg", ".geojson", ".json", ".fgb", ".shp", ".tif", ".tiff"})
86
+ OPTIONAL_DRIVERS = {".parquet": "Parquet", ".geoparquet": "Parquet", ".arrow": "Arrow", ".feather": "Arrow", ".arrows": "Arrow"}
87
+
88
+
89
+ def _is_provider_datasource(datasource: str) -> bool:
90
+ """Remote services and provider connection strings are not local files."""
91
+ location = datasource.split("|", 1)[0]
92
+ return datasource.startswith(("http", "type=xyz", "contextualWMSLegend", "crs=")) or "=" in location
93
+
94
+
95
+ def datasources_portable(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
96
+ """Every local file datasource uses a format every QGIS build reads.
97
+
98
+ Portable formats are an allowlist: GeoPackage, GeoJSON, FlatGeobuf,
99
+ Shapefile and GeoTIFF. A live trial pointed QGIS at GeoParquet; a stock
100
+ Ubuntu QGIS opened the layer invalid and drew nothing while every static
101
+ check passed. Remote and provider datasources are out of scope. A warning,
102
+ not a failure: builds with the extra driver do open other formats.
103
+ """
104
+ xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
105
+ if error == "file_missing":
106
+ return failed(f"{path} does not exist", code="file_missing")
107
+ if error == "not_a_zip":
108
+ return failed(f"{path} is not a valid zip archive", code="not_a_zip")
109
+ if xml is None:
110
+ return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
111
+ unportable: dict[str, str] = {}
112
+ for datasource in re.findall(r"<datasource>(.*?)</datasource>", xml, re.DOTALL):
113
+ datasource = datasource.strip()
114
+ if not datasource or _is_provider_datasource(datasource):
115
+ continue
116
+ suffix = Path(datasource.split("|", 1)[0]).suffix.lower()
117
+ if suffix not in PORTABLE_SUFFIXES:
118
+ unportable[datasource] = OPTIONAL_DRIVERS.get(suffix, f"{suffix or 'no extension'} is outside the portable set")
119
+ if unportable:
120
+ return warning(
121
+ "layers use formats many QGIS builds cannot open, so they load invalid there: "
122
+ + ", ".join(f"{source} ({reason})" for source, reason in unportable.items())
123
+ + "; point QGIS at a GeoPackage, GeoJSON or FlatGeobuf copy",
124
+ code="datasource_format_not_portable",
125
+ datasources=unportable,
126
+ )
127
+ return passed(f"{path}: every local file datasource uses a format every QGIS build reads")
128
+
129
+
130
+ def _data_crs(root: Path, datasource: str) -> str | None:
131
+ """The CRS a GeoJSON or GeoPackage layer's data is actually in, or ``None``.
132
+
133
+ GeoJSON without a ``crs`` member is WGS84 by RFC 7946, which is how GDAL
134
+ reads it. GeoPackage records the SRS per geometry table.
135
+ """
136
+ location, *options = datasource.split("|")
137
+ path = root / location
138
+ if not path.is_file():
139
+ return None
140
+ suffix = path.suffix.lower()
141
+ if suffix in {".geojson", ".json"}:
142
+ try:
143
+ crs = json.loads(path.read_text(encoding="utf-8")).get("crs")
144
+ except (OSError, ValueError, AttributeError):
145
+ return None
146
+ if not crs:
147
+ return "EPSG:4326"
148
+ name = str((crs.get("properties") or {}).get("name", "")) if isinstance(crs, dict) else ""
149
+ if "CRS84" in name.upper():
150
+ return "EPSG:4326"
151
+ match = re.search(r"EPSG:+(\d+)", name, re.IGNORECASE)
152
+ return f"EPSG:{match.group(1)}" if match else None
153
+ if suffix == ".gpkg":
154
+ layer = next((option.split("=", 1)[1] for option in options if option.startswith("layername=")), None)
155
+ try:
156
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
157
+ try:
158
+ rows = connection.execute(
159
+ "SELECT g.table_name, s.organization, s.organization_coordsys_id FROM gpkg_geometry_columns g"
160
+ " JOIN gpkg_spatial_ref_sys s ON g.srs_id = s.srs_id"
161
+ ).fetchall()
162
+ finally:
163
+ connection.close()
164
+ except sqlite3.Error:
165
+ return None
166
+ matches = [row for row in rows if layer is None or row[0] == layer]
167
+ if len(matches) != 1 or str(matches[0][1]).upper() != "EPSG":
168
+ return None
169
+ return f"EPSG:{matches[0][2]}"
170
+ return None
171
+
172
+
173
+ def layer_crs_matches_data(workspace: Path, path: str = "project.qgz", project_dir: str = ".") -> AssertionResult:
174
+ """Each GeoJSON or GeoPackage layer's declared CRS is the CRS of its data.
175
+
176
+ QGIS trusts a hand-written ``<srs>``. A live trial declared EPSG:3301 on
177
+ WGS84 GeoJSON, so QGIS placed the roads a few metres from the grid's
178
+ origin and the map drew nothing where the analysis was, while every
179
+ static check passed. Layers whose data CRS cannot be read here are
180
+ skipped; missing files are reported by ``qgis.static_valid``.
181
+ """
182
+ xml, _qgz_path, error = _qgs_xml(workspace, path, project_dir)
183
+ if error == "file_missing":
184
+ return failed(f"{path} does not exist", code="file_missing")
185
+ if error == "not_a_zip":
186
+ return failed(f"{path} is not a valid zip archive", code="not_a_zip")
187
+ if xml is None:
188
+ return failed(f"{path} does not contain a .qgs document", code="no_qgs_document")
189
+ root = project_root(workspace, project_dir)
190
+ compared: dict[str, dict[str, str]] = {}
191
+ mismatched: list[str] = []
192
+ for layer_xml in re.findall(r"<maplayer[ >].*?</maplayer>", xml, re.DOTALL):
193
+ datasource = html.unescape((re.search(r"<datasource>(.*?)</datasource>", layer_xml, re.DOTALL) or [None, ""])[1]).strip()
194
+ authid = re.search(r"<authid>(.*?)</authid>", layer_xml, re.DOTALL)
195
+ if not datasource or authid is None or _is_provider_datasource(datasource):
196
+ continue
197
+ actual = _data_crs(root, datasource)
198
+ if actual is None:
199
+ continue
200
+ name_match = re.search(r"<layername>(.*?)</layername>", layer_xml, re.DOTALL)
201
+ name = name_match.group(1).strip() if name_match else datasource
202
+ declared = authid.group(1).strip()
203
+ compared[name] = {"declared": declared, "data": actual}
204
+ if declared.upper() != actual.upper():
205
+ mismatched.append(name)
206
+ if mismatched:
207
+ return failed(
208
+ "layers declare a CRS their data is not in, so QGIS draws them in the wrong place: "
209
+ + ", ".join(f"{name} (declared {compared[name]['declared']}, data {compared[name]['data']})" for name in mismatched),
210
+ code="layer_crs_mismatch",
211
+ layers={name: compared[name] for name in mismatched},
212
+ )
213
+ if not compared:
214
+ return not_testable("no local GeoJSON or GeoPackage layer to compare", code="no_comparable_layers")
215
+ return passed(f"all {len(compared)} comparable layers declare the CRS their data is in", layers=compared)
216
+
217
+
71
218
  _QGIS_APPLICATION: Any = None
72
219
 
73
220
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  import json
7
7
  import shlex
8
+ import sqlite3
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
@@ -171,12 +172,68 @@ def _parquet_snapshot(path: Path, ignored_fields: set[str]) -> dict[str, Any]:
171
172
  return {"schema": schema, "rows": sorted(normalized_rows, key=_stable_json)}
172
173
 
173
174
 
175
+ def _geopackage_snapshot(path: Path, ignored_fields: set[str]) -> Any:
176
+ """Schema, core metadata and rows of a GeoPackage, without its write time.
177
+
178
+ ``gpkg_contents.last_change`` records when the file was written, so a
179
+ byte hash reports every rebuild as changed even when no feature differs.
180
+ Everything a consumer interprets is kept: column definitions, geometry
181
+ columns and the SRS definitions they reference. Geometry blobs are
182
+ compared as stored: one writer encodes one geometry identically.
183
+ """
184
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
185
+
186
+ def metadata(query: str) -> list[list[Any]]:
187
+ try:
188
+ return [list(row) for row in connection.execute(query).fetchall()]
189
+ except sqlite3.OperationalError: # optional table absent
190
+ return []
191
+
192
+ try:
193
+ contents = connection.execute(
194
+ "SELECT table_name, data_type, srs_id, min_x, min_y, max_x, max_y FROM gpkg_contents ORDER BY table_name"
195
+ ).fetchall()
196
+ geometry_columns = metadata("SELECT * FROM gpkg_geometry_columns ORDER BY table_name")
197
+ spatial_reference_systems = metadata(
198
+ "SELECT srs_name, srs_id, organization, organization_coordsys_id, definition"
199
+ " FROM gpkg_spatial_ref_sys ORDER BY srs_id"
200
+ )
201
+ schemas = {}
202
+ tables = {}
203
+ for table_name, *_ in contents:
204
+ schemas[table_name] = [
205
+ list(column[1:]) for column in connection.execute(f"PRAGMA table_info({_sql_identifier(table_name)})")
206
+ ]
207
+ cursor = connection.execute(f"SELECT * FROM {_sql_identifier(table_name)}")
208
+ names = [column[0] for column in cursor.description]
209
+ rows = [
210
+ {
211
+ name: value.hex() if isinstance(value, bytes) else _normalize_json(value, ignored_fields)
212
+ for name, value in zip(names, row, strict=True)
213
+ if name not in ignored_fields
214
+ }
215
+ for row in cursor.fetchall()
216
+ ]
217
+ tables[table_name] = sorted(rows, key=_stable_json)
218
+ finally:
219
+ connection.close()
220
+ return {
221
+ "contents": [list(row) for row in contents],
222
+ "geometry_columns": geometry_columns,
223
+ "spatial_reference_systems": spatial_reference_systems,
224
+ "schemas": schemas,
225
+ "tables": tables,
226
+ }
227
+
228
+
174
229
  def _semantic_snapshot(path: Path, ignored_fields: set[str]) -> Any:
175
230
  suffix = path.suffix.lower()
176
231
  if suffix in {".json", ".geojson"}:
177
232
  return _normalize_json(json.loads(path.read_text(encoding="utf-8")), ignored_fields)
178
233
  if suffix in {".parquet", ".geoparquet"}:
179
234
  return _parquet_snapshot(path, ignored_fields)
235
+ if suffix == ".gpkg":
236
+ return _geopackage_snapshot(path, ignored_fields)
180
237
  return {"sha256": hashlib.sha256(path.read_bytes()).hexdigest()}
181
238
 
182
239
 
@@ -176,6 +176,51 @@ def run_record_matches(
176
176
  return passed(f"report run_id {run_id!r} matches a real run record with consistent hashes")
177
177
 
178
178
 
179
+ def sample_run_not_promoted(
180
+ workspace: Path, project_dir: str = ".", runs_dir: str = "runs"
181
+ ) -> AssertionResult:
182
+ """A sampled run is never the canonical run of record.
183
+
184
+ Sampling clips or thins the inputs, so its outputs are evidence that the
185
+ pipeline executes, never evidence of the answer. `runs.latest` is what
186
+ `verify`, the clean-rerun protocol, and every expectation attestation bind
187
+ to, so a sampled record reaching it would launder a smoke test into a
188
+ result. A sampled record must also state what it *realized*, not only what
189
+ was requested.
190
+ """
191
+ from openmapstack.sampling import run_mode, run_record_errors
192
+
193
+ proj = load_project_yaml(workspace, project_dir)
194
+ if proj is None:
195
+ return failed("project.yaml missing", code="manifest_missing")
196
+ root = project_root(workspace, project_dir)
197
+ directory = root / runs_dir
198
+ if not directory.is_dir():
199
+ return not_testable(f"no {runs_dir}/ directory", code="runs_dir_missing")
200
+
201
+ latest_id = str(get_in(proj, "runs.latest.id", "") or "")
202
+ sampled: list[str] = []
203
+ problems: list[str] = []
204
+ for record_path in sorted(directory.glob("*.json")):
205
+ record = load_json(record_path)
206
+ if not isinstance(record, dict):
207
+ continue
208
+ problems.extend(f"{record_path.name}: {problem}" for problem in run_record_errors(record))
209
+ if run_mode(record) == "sampled":
210
+ sampled.append(record_path.stem)
211
+
212
+ if latest_id and latest_id in sampled:
213
+ return failed(
214
+ f"runs.latest is sampled run {latest_id!r}; a sampled run cannot be the canonical run",
215
+ code="sampled_run_promoted",
216
+ )
217
+ if problems:
218
+ return failed(f"invalid run mode/sample declaration: {problems}", code="sample_record_invalid")
219
+ if not sampled:
220
+ return passed("no sampled run records; runs.latest is canonical")
221
+ return passed(f"{len(sampled)} sampled run record(s) present and none is runs.latest")
222
+
223
+
179
224
  def no_prose_only_validation(
180
225
  workspace: Path, check_id: str, project_dir: str = ".", report_path: str = "validation/latest-report.json"
181
226
  ) -> AssertionResult:
@@ -208,7 +253,7 @@ def report_evidence_recomputes(
208
253
  and optional id field. This avoids accepting internally consistent prose
209
254
  or invented counters as proof that a GIS check actually ran.
210
255
  """
211
- from .geodata import _connect, _read
256
+ from .geodata import _connect, _invalid_geometry_count, _read
212
257
 
213
258
  report = load_json(project_root(workspace, project_dir) / report_path)
214
259
  if report is None:
@@ -246,9 +291,11 @@ def report_evidence_recomputes(
246
291
  if metric == "row_count":
247
292
  actual = con.execute(f"SELECT COUNT(*) FROM {relation}").fetchone()[0]
248
293
  elif metric == "invalid_geometry_count":
249
- actual = con.execute(
250
- f"SELECT COUNT(*) FROM {relation} WHERE NOT ST_IsValid(geom)"
251
- ).fetchone()[0]
294
+ actual = _invalid_geometry_count(con, relation, declaration.get("geometry_field"))
295
+ if actual is None:
296
+ return failed(
297
+ f"{relative} has no matching geometry column", code="geometry_column_missing"
298
+ )
252
299
  elif metric in {"duplicate_count", "null_count"}:
253
300
  field = declaration.get("field")
254
301
  if not isinstance(field, str) or not field: