Skip to content

Changelog

All notable changes to GeoCase are documented here.

The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

Corpus changes: name what changed

Any change to a case's geometry, CRS, dtype, nodata value, id, or risk types is listed by case id and by what changed — never as a summary.

This convention exists because of a measured failure. The rc1 → rc3 fix to polygon_*_baseline (four different geometries shipping under one family name) was correct, and it silently broke a downstream test: the consumer had a canary pinning the old behaviour, it went red on upgrade, and there was no signal as to why. An entry reading "fixed baseline consistency" costs the reader an afternoon; one naming the case and the geometry costs them five seconds.

A user hitting a break searches for the case id or the error text, so both belong in the entry verbatim.

[Unreleased]

Added — benchmark edge batteries, raster and predicate tasks (Plan 46 Phase 3.3, #31)

Benchmark only; no catalog case changed and no prompt.md changed. Append-only.

  • sample_at (1 → 4 edges): bottom_up_sentinel, non_square_sentinel, nan_nodata_keeps_minus_9999.
  • zonal_mean (1 → 5): bottom_up_transform, non_square_pixels, all_nodata_window, nan_beside_sentinel.
  • tag_points (1 → 4): point_on_outer_boundary, point_on_corner_vertex, point_on_hole_boundary.
  • label_point (2 → 4): multipolygon_gap, thin_l_band.
  • fix_geometry (1 → 4): bowtie_southern_offset, bowtie_tiny_coordinates, bowtie_unequal_lobes.

Added — benchmark edge batteries, antimeridian tasks (Plan 46 Phase 3, #31)

Benchmark only; no catalog case changed and no prompt.md changed. Checks are append-only: existing check names keep their meaning.

  • area_m2, buffer_m, position_at, split_antimeridian each go from one edge check to five. New edge checks: area_m2 antimeridian_westward, pole_adjacent_crossing, equator_crossing, southern_hemisphere; buffer_m dateline_westward, pole_adjacent, equator_crossing_line, southern_hemisphere; position_at dateline_westward, pole_adjacent, equator_crossing, southern_hemisphere; split_antimeridian westward_ring, pole_adjacent, equator_crossing, southern_hemisphere. The new area_m2 and split_antimeridian inputs differ (only the original box is shared).
  • tests/benchmark/test_results_pin.py regrades a committed module over the check names recorded in its graded.json only; checks added later are not drift.

Added — vector differential round (Plan 42 Phases 3–4, #27)

  • geocase.differential.default_compare now compares geometry-dtype frame columns with compare_geometries, so a frame divergence reports geometry state differs: NULL vs EMPTY (or NaN-coordinate vs present, or POINT EMPTY vs POLYGON EMPTY) instead of two reprs. No case changed.
  • examples/test_differential_vector.py: pyogrio vs. raw OGR + GEOS over the vector corpus. Result: 104 agree, 0 diverged, 0 errored — no finding.

Added — a reusable differential-round instrument (Plan 51, #46)

geocase.differential gains run_round and render_report, so a new differential round (PROJ, GEOS, or your own) no longer hand-rolls the same write-up the pyogrio and GDAL rounds each wrote from scratch:

  • run_round(...) is compare_cases plus summarize, bundled into a new RoundReport.
  • render_report(report) renders it as Markdown: an environment table, the outcome counts, and a section per non-agree outcome with each result's detail and, for a known result, its upstream_url.
  • Both are exported from geocase.differential.__all__, alongside the existing compare_cases/summarize. Not part of the import geocase v1.0 surface, same footing as the rest of the submodule.

Added — one-click release pipeline (Plan 50)

Tooling only; nothing in the package changes.

  • prepare-release.yml: one input (the version) opens the release PR, using the new scripts/prepare_release.py.
  • release.yml: a merged release PR tags the version, uploads to TestPyPI without approval, smoke-tests the TestPyPI package in clean runners (scripts/smoke_release.py, core and [array]), waits for approval before PyPI, then smoke-tests PyPI, creates the GitHub release from scripts/changelog_section.py, and closes the release issue.

[1.1.0] — 2026-09-27

Added — the array extra for geocase.raster (#44)

geocase.raster imports numpy, but numpy is not a core dependency, so on a plain pip install geocase without numpy, import geocase.raster failed with ModuleNotFoundError: No module named 'numpy'. It now raises ImportError: geocase.raster needs numpy ... pip install "geocase[array]".

  • New extra array = ["numpy>=1.24,<3"]; all includes it, and write now lists numpy explicitly.
  • Core stays pydantic, pyyaml, geofacts: plain geocase still never installs or changes numpy.
  • The docs no longer call geocase.raster "dependency-free".

Changed — benchmark prompts (Plan 46 Phase 0)

Two task prompts were edited, so their prompt_sha256 moved. Each task now carries prompt_version: 2 in its task.yaml, the superseded text is archived as prompt.v1.md beside prompt.md, and every committed run's recorded hash still reproduces from the version that run was sent. A run made under v1 is not comparable with one made under v2 for these two tasks; the prompt-hash test reports the split rather than failing on it. Listed by task and by what changed:

  • project_line — the stated tolerance moved from 1 km to 25 km. The grader had always enforced 25 km (LIMIT_M), so a submission 20 km off violated the prompt's contract and scored PASS. The prompt moved rather than the oracle: the trap is densify-before-reproject, whose failure is >500 km, and a 1 km bar would additionally have measured how many waypoints a model chose. The oracle's behaviour is unchanged; no committed grading moves.
  • utm_epsg_for — the contract now names the zone-assignment standard: the grid zone "as assigned by the Military Grid Reference System, whose zone numbering includes the published grid exceptions". The old prompt asked for the CRS "appropriate for that location", under which pyproj.query_utm_crs_info's answers (32632 for 10.5E 78N, 32631 for 4.5E 60N — EPSG's areas of use do not encode the 33X/32V exceptions) were a defensible reading scored SILENT. The grader's expected codes are unchanged and now cited (DMA TM 8358.1, ch. 3) in a SOURCES dict, which every hand-typed grader constant must carry.

Added — benchmark

  • python -m geocase.benchmark report — task x model matrix, per-trap-category trapped rates with Wilson intervals, reproducible-silent tasks at k>=3, and --coverage. Unpublishable runs are excluded from every rate and named.
  • classify_trial in geocase.benchmark.taxonomy: trapped (controls pass, edge silently wrong) vs broken (a control did not pass), derived at report time from the stored checks. No record schema changes.
  • TRAP_TO_RISK: the trap_category -> catalog risk_types mapping; the coverage test pins that transform, dtype and precision have no geo task.

Plan 44. Eight cases added, no case removed, no existing geometry / CRS / dtype / nodata value / id changed. Every existing case keeps every risk type it had — the changes below are additions only, so nothing that selected before stops selecting.

Added — corpus

The numeric-boundary family, under src/geocase/data/core/vector/special/precision/. Seven single-point GeoJSON cases, single-variable, isolating where a text formatter stops representing the double it was given. precision_loss_geojson_roundtrip found this class by accident — one of its three points happens to sit at 1e-14 — and the family exists so finding it again is not an accident:

  • numeric_boundary_1e14 — the hit. GDAL d6fd56f52d writes this coordinate as 0 through both the WKT and the GeoJSON writers, silently, at relative error 1.0. Not a precision floor: 1e-14 is fourteen decimal places, inside the writer's default of fifteen.
  • numeric_boundary_1e13 — the upper bracket. A brute force over 300 000 coordinates puts the zeroing class at exactly |v| in [1e-14, 1e-13), so this value must survive. A consumer that loses it has a wider bug.
  • numeric_boundary_1e15 — the lower bracket, separating the writer bug from "the value fell off the end of 15 decimal places". Losing 1e-15 is documented behaviour; losing 1e-14 is not.
  • numeric_boundary_trailing_zeros — 0.000000010000001, which loses its trailing 1 through intelliround (ogr/ogrutils.cpp:161-169): the routine tests s[len-3] through s[len-9] for zeros, then drops the last 8 characters including the untested s[len-2]. Same mechanism as the 1e-14 finding, at a magnitude nobody would call small.
  • numeric_boundary_trailing_nines — the other pattern intelliround special-cases, where rounding carries across every digit. Its y carries across an integer boundary, so a consumer rounding to 15 places returns 51.0 for a point that was never at 51.
  • numeric_boundary_15_significant_digits — control. Inside the writer's 15-place default, so it must survive unchanged.
  • numeric_boundary_17_significant_digits — control. 17 digits is what IEEE 754 doubles need in the worst case, two more than the writer emits, so this value is expected to change on a text round-trip. It exists so a consumer can tell that documented limit apart from the 1e-14 class, which is a defect.

And one raster:

  • optical_dateline_west_small — an RGB GeoTIFF straddling the antimeridian at -180, mirroring optical_dateline_small about 180. Identical size, dtype, band count, CRS and pixel size, so a behavioural difference between the pair is attributable to the direction of the crossing and to nothing else.

Added — risk types

Five terms, all additive. Existing spellings are untouched and no alias was needed.

  • precision/formatter_boundary, precision/significant_digits, precision/denormal_magnitude — the numeric axis the corpus had no way to name.
  • failure_mode/consumer, failure_mode/reference_implementation — a new family recording whose failure mode a case is. Round 6 pointed the corpus at GDAL itself and the georeferencing-convention cases all passed; that is the correct result, because those cases are failure modes for consumers of GDAL, not for GDAL. The corpus previously could not express the difference.

risk_types is a pinned selector surface, so shipping this family commits the two terms and the axis they cut. That was decided deliberately (Plan 44, U26): the family is additive — no existing term changes meaning and no existing query changes its result set — and if a later round shows the split wants a third value, adding one is additive again.

Changed — corpus

risk_types gained one or more failure_mode/* terms on the cases below. Additions only — no term was removed, renamed or retired, so every existing selector returns what it did before. Listed by id because a new term changes what list_cases(risk_types_any=...) returns for the added term:

  • failure_mode/consumer — dem_nan_nodata_small, rotated_two_islands, geotiff_int8_small, landcover_ambiguous_zero_small, water_mask_small, bottom_up_dem_small, pixel_is_area_dem_small, pixel_is_point_dem_small.
  • failure_mode/reference_implementation — optical_dateline_small, precision_loss_geojson_roundtrip. The cases added in this release carry the term from the start and are listed here too, so that the set this selector returns can be read off one list: optical_dateline_west_small, numeric_boundary_1e13, numeric_boundary_1e14, numeric_boundary_1e15, numeric_boundary_15_significant_digits, numeric_boundary_17_significant_digits, numeric_boundary_trailing_nines, numeric_boundary_trailing_zeros.

Complete as shipped: failure_mode/consumer selects 8 cases and failure_mode/reference_implementation selects 10.

optical_dateline_small and precision_loss_geojson_roundtrip additionally gained a known_divergences record apiece, so a repeat differential run against GDAL reports known rather than diverged. This changes differential output only; it does not affect selection.

Added — CI

  • A floor job installs GeoCase on Python 3.11 with every core dependency at its declared minimum (pydantic==2.0, pyyaml==6.0, geofacts==0.1.2, pytest==7.0, pinned in ci/floor-constraints.txt) and no extras, then runs tests/ without tests/benchmark. A unit test keeps the pins equal to the >= bounds in pyproject.toml. Tests that need an extra now carry @pytest.mark.requires(...) and skip without it. No dependency bound changed.

[1.0.0] — 2026-09-05

The first stable release. Everything below landed after the 2026-08-02 feature freeze (recorded as 1.0.0-freeze at the bottom of this file) and shipped through the release candidates 1.0.0rc1, 1.0.0rc2 and 1.0.0rc3, which are on PyPI. If you are upgrading from an rc, the entries below are cumulative across all three: read Changed — corpus first, since that is the section that can change what a selector returns.

Changed — corpus

  • risk_types consolidated to a canonical vocabulary (124 terms → 104) — plan 40 phase 3, executing plan 27 §1.2–1.3. Terms are now family/specific where a family has more than one member, and every term is gated: scripts/validate_catalog.py rejects a spelling outside src/geocase/catalog/risk_types.py. Before this, only four terms were checked against anything, so the rest were indistinguishable from typos — which is how the corpus reached 124 terms over 163 cases with 78 singletons, and how docs/adding-a-case.md came to teach two worked-example terms (topology_breakage, attribute_encoding) that existed in no case.

Nothing silently stops selecting. Every merged spelling is recorded in RISK_TYPE_ALIASES and resolved at selection time, so list_cases(risk_types_any=["coordinate_order"]) returns what it always did. A bare family prefix now also selects — risk_types_any=["crs"] matches every crs/* term.

Two terms were retired rather than renamed, and these are the ones that change what a selector returns:

  • none (9 cases) — the absence of a risk type, spelled wrong. Those nine cases now declare risk_types: []. Anything selecting on "none" returns nothing. Affected: simple_valid_point, simple_valid_linestring, simple_valid_multipoint, simple_valid_multilinestring, simple_valid_polygon, simple_valid_multipolygon, dense_ring_polygon_4k, dense_ring_polygon_4k_gpkg, fractal_coastline_polygon.
  • format_comparison (60 cases) — a corpus-construction label rather than a failure mode, covering 37% of the catalog. Moved to tags, so select it with tags_any=["format_comparison"] instead of risk_types_any=[...]. No case lost the label.

Two merges the brief proposed were not made, because the corpus's own tests proved the distinction load-bearing: lat_lon_swap stays separate from crs/axis_order (the GML baselines declare an authority axis order the bytes genuinely honour; out_of_bounds_coordinates is a swap caught only because latitude 100 is out of range), and crs_mishandled stays separate from crs/mismatch (a mismatch is a property of a pair, which is why crs_mismatch_overlay_pair exists).

  • bottom_up_dem_small, rotated_bottom_up_small gained transform/bottom_up; pixel_is_area_dem_small, pixel_is_point_dem_small gained transform/pixel_anchor. All four previously declared only nodata_ignored, so the two conventions that produced the most severe findings of validation rounds 1 and 2 were unsearchable by the index built to find them. No bytes changed.

Added — corpus

  • Three single-variable controls (163 → 166) — plan 40 phase 4, under raster/single_variable/: rotated_only_square (a 30° rotated affine, no nodata), nodata_only_dem_small (two interior -9999 sentinels, north-up), and bottom_up_only_square (a positive-e affine alone). rotated_two_islands bundles rotation with sparse islands with footprint generation, so a failure there needs an argument about which caused it. These remove the argument: a defect reproducing on the control and its bundled counterpart is localised with no further work.

  • expected_pixel_world_pairs — plan 41 phase 3.3. [row, col, x, y] quadruples in the case's own CRS, on all five rotated rasters and rotated_only_square. Round 4's only irreducible finding required hand-rolling the inverse affine to see where a pixel landed; the fixture now ships that answer. expected_bounds is only the axis-aligned envelope on a rotated raster and says nothing about individual pixels, which is exactly what was wrong.

  • A known_divergences record on optical_dateline_small naming the consequence: the footprint reaches longitude 180.22, so floor-based tile indexing requests a tile at 180 and raises. The geometry was always in the file and was not enough — round 4 found the crash only after hand-rolling the tile arithmetic.

Added

  • geocase.risk_types() — the reverse index, {term: [case ids]}. The forward direction has always existed via list_cases(risk_types_any=...); a reporter built this half by hand with an ad-hoc Counter over all 163 cases. Additive.
  • list_cases(risk_types_all=...) — the missing half of the pair. tags has had both _any and _all since v1.0 while risk_types had only _any. Additive.
  • CaseMetadata.case_id — a read-only alias for .id, plus a directed AttributeError for near-misses. The package spells one concept two ways (KnownDivergence.case_id against CaseMetadata.id), and a reporter's first call was c.case_id, which raised a bare pydantic error naming nothing useful. The underlying inconsistency is a v1.1 naming item, not a v1.0 change.

Changed — docs site

No library behaviour changes here. Three surfaces were unpublished from the docs site ahead of the v1.0 release. All of them stay in the repository and remain readable on GitHub — exclude_docs removes them from the build, so they are no longer reachable by URL either.

  • docs/benchmark/ — the LLM-benchmark subsystem. It is explicitly not part of the v1.0 compatibility promise, and it is not mature enough to present as a product surface beside a 1.0 release. Removed from the nav and from the build: nav removal alone would have left the page live at its URL. The README.md pointer stays and is now marked experimental and unpublished.
  • docs/design/ — four documents specifying a case-recommendation service, an API spec, and a database schema for a backend that does not exist. The pages do say "proposed", but published beside a v1.0 release they read as shipping features. The three published pages that linked into them (contributing/workflow.md, contributing/structure-and-planning.md, index.md) now link by GitHub URL, per the rule that an unpublished doc is never linked relatively.
  • docs/design/presentation-brief.md — a brief handed to a design tool describing how to pitch GeoCase. It was already off-nav but was still being built and served, and it links to a validation branch that will break when the branch is deleted.

Also scrubbed: absolute local paths of the form /Users/<name>/projects/… in plans 37, 38, and 39, which named a developer's home directory and pointed at private repositories no reader can open. Now written as ~/projects/….

Added

  • crs_mismatch_overlay_pair (153 → 154) — plan 36 phase 2, executing plan 27 §1.1. crs_mismatch sat in the README's opening prose and in the catalog's pre-committed search vocabulary while no case declared it: the nearest candidates (rasterize_match_wgs84_polygon, web_mercator_baseline) are single-layer, and a CRS mismatch is a relationship between two inputs that one file cannot express. The new case is the catalog's first two-layer case — one footprint in southeastern Norway written twice, as a WGS84 primary and a sidecar holding UTM 33N metres while declaring EPSG:4326. Both files are individually well-formed and parse without warning; the defect exists only in the pair. Reprojecting the sidecar by its true EPSG lands it on the reference to within 0.0004 m, while trusting its declaration puts it 3359 km away, silently — an overlay or spatial join returns empty, which reads as "no features intersect" rather than as an error.

Ships as one case with a sidecar rather than two cross-referencing cases: a relationship split across two independently-selectable ids can be selected apart, and a selector returning half a relationship is a footgun. files.sidecars already supported this, so no model change was needed. _check_crs_mismatch() in catalog/content.py backs the risk type against the bytes — the only content check that reads a sidecar as well as a primary — and fails if the sidecar is ever rewritten with honest degrees.

  • Three large curated vector cases (150 → 153) — plan 28 phase 3. Every vector case in the catalog held at most 4 features, and 74 held exactly one, which made probes for skip_features, max_features, Arrow batch chunking and paged reads execute without being able to fail: with one feature every batch boundary is the same boundary and every partial read is the full read. Each new case holds ~10,000 features with one defect placed past the boundary, under vector/special/scale/: invalid_geometry_at_scale_gpkg (a self-intersecting bowtie at index 9,999, invisible to any prefix read), null_after_batch_boundary_gpkg (the first NULL after 10,000 non-NULL integers — pyogrio returns int64 from a partial read and float64 from the full read of the same column), and mixed_timezone_after_batch_gpkg (a UTC offset that changes only at the last row — datetime64[ms, UTC+01:00] partial against datetime64[ms, UTC] full). Generated by _large_specs() in scripts/generate_vector_fixtures.py and covered by its --check gate, with a fingerprint that compares the defect rather than 10,000 WKB blobs.

mixed_timezone_after_batch_gpkg is deliberately GeoPackage-non-conformant: requirement 15 wants DATETIME in UTC with a literal Z, and GDAL warns on read. That is the case's subject rather than an oversight — a conformant all-Z file was measured and shows no dtype instability at all — and it is declared via params.gpkg_datetime_conformant: false and the spec_nonconformance risk type.

Size impact: the bundled payload goes 2.1 MB → 5.1 MB and the wheel 456 KB → 1.25 MB, against verify_dist.py's unchanged 2 MB ceiling. Roughly one more trio of this size fits; the one after that belongs in a remote manifest.

  • Seven cases closing gaps found by an external review (143 → 150). Three raster transform-convention cases: bottom_up_dem_small (a positive-e, south-up affine — every other raster in the catalog is built with from_origin, which always emits a negative e, so code assuming north-up passed the whole corpus), plus pixel_is_area_dem_small / pixel_is_point_dem_small, a differential pair sharing a transform and an array and differing only in AREA_OR_POINT. Two NetCDF cases: ndvi_packed_netcdf (int16 packed by scale_factor, cross-linked with the GeoTIFF ndvi_scaled_int16_small as a same-failure-mode pair across containers) and cf_time_ordering_netcdf (CF time units on non-conventional (longitude, latitude, time) dimensions). Two vector cases: polygon_z_wkb / polygon_z_gpkg, the first fixtures in the catalog with Z coordinates, the GPKG half also carrying an id of 9007199254740993 (2^53 + 1) for readers that route integers through a double.

  • scripts/generate_netcdf_fixtures.py. NetCDF fixtures are now regenerable and gated like the raster and vector families. --check compares semantics rather than bytes, because HDF5 stamps its library version into every file.

  • NetCDF content checking. check_case_content no longer returns [] for the category; check_netcdf_content verifies declared dimensions (in order), variables, fill values, packing and time units against the real file.

  • Two AssertionHints fields, both additive and None-defaulted: expected_transform_signs and expected_pixel_anchor, with a new PixelAnchor literal. Public assertions assert_transform_signs, assert_pixel_anchor and assert_scale_factor.

  • AssertionHints.required_drivers (plan 28 phase 2.1) — additive, defaults to []. Declares the OGR driver an external consumer (pyogrio, fiona, ogr2ogr) needs before a case will open for them. It says nothing about GeoCase, which reads every vector case without OGR. Populated on the 20 vector cases a stock GDAL build cannot open: the 7 Parquet/Feather/Arrow/GeoArrow cases name Parquet / Arrow (the optional libgdal-arrow-parquet plugin), and the 13 WKB/WKT bare-geometry cases declare the new NO_OGR_DRIVER sentinel — the empty string, deliberately falsy — because no driver exists for them at any build configuration. An external validation run had logged all 20 as spurious failures. loader_hint cannot express this: it marks all 113 vector cases geopandas.

  • loader_hint filter (plan 28 phase 2.2) on list_cases(), select_cases(), matches_selection() and SuiteSelection. Additive and keyword-only.

  • AssertionHints.expected_error_kind (plan 28 phase 2.4) — additive, defaults to None, with a new ExpectedErrorKind literal in geocase.catalog.models: unparseable_geometry | unsupported_format | missing_driver | invalid_crs | invalid_topology. A harness could previously assert only that a case failed, never how, so "failed for the curated reason", "failed because a driver is missing" and "failed because the consumer has a new bug" were indistinguishable — an external validation run had to separate all 20 of its failures by hand. A vocabulary rather than exception classes, since the class belongs to the consumer (the same unclosed ring is GEOSException from shapely, DataSourceError from pyogrio, ValueError from pandas). Gated in both directions: AssertionHints rejects the field on a case that is not expect_loadable: false, and the content gate opens the file and reports a finding when the observed failure does not match the declared kind, via the new geocase.catalog.content.classify_error. Declared on unclosed_ring_polygon, the corpus's one expect_loadable: false case.

  • CaseMetadata.known_divergences (plan 28 phase 2.5) — additive, defaults to [], holding KnownDivergence(consumer, version_range, description, upstream_url) records. A divergence investigated once stays investigated: without it the next person running a differential harness re-investigates from scratch and — the expensive part — cannot tell a newly introduced consumer bug on a case from the one already understood. A record, not an assertion: nothing in the content gate can verify it, because whether it still reproduces depends on the reader the user has installed. Seeded on empty_geometry_gpkg with the pyogrio Arrow / GPKG spatial-filter finding traced into GDAL's GetArrowStream. Rendered as its own "Known consumer divergences" section on the case's catalog page.

  • geocase.differential (plan 28 phase 2.6) — read every case two ways, compare, report the disagreements. compare_case, compare_cases, summarize, default_compare and the DifferentialResult dataclass. This is the mode with external evidence behind it: both defects an independent validation run found (a pyogrio read_dataframe crash, since patched upstream, and the GDAL GetArrowStream divergence) came from comparing a consumer against itself, not against any assertion GeoCase declares — so neither path needs to be an oracle.

Four outcomes, kept distinguishable on purpose: agree (including both paths failing identically, so a curated-failure case is agreement rather than a finding), diverged, known (matched against known_divergences for the consumer named in consumer=), and errored (exactly one path raised — a crash and a wrong answer need different triage). Selection keywords are forwarded verbatim to list_cases. default_compare understands GeoDataFrames row-count-first and treats None, NaN, NaT and NA as the same missing value; it stops there, and compare= takes your own. Not in geocase.__all__ — a submodule import, the precedent geocase.raster and geocase.assertions set. Documented at docs/differential-testing.md with a runnable examples/test_differential_pyogrio.py.

  • The expect_loadable × expect_valid_geometry matrix is documented in docs/adding-a-case.md, with the assertion each cell implies — the documentation answer to a report finding that the two fields read as one axis and are not. unclosed_ring_polygon raises before a geometry exists; self_intersecting_polygon returns an object whose .is_valid is False. Both declare expect_valid_geometry: false, and a harness that writes assert not geom.is_valid for both fails on the first for the wrong reason.

Fixed

  • pip install "geocase[all]" could break a working geospatial environment (plan 40 phase 1). Installed into a venv created with --system-site-packages over GDAL 3.6.2 / geopandas 0.12.2 / scipy 1.10.1, it resolved numpy 2.4.6 — incompatible with that scipy — shadowed the system geopandas and pandas, and left pandas unimportable:
ImportError: C extension: None not built

Every optional dependency is now bounded at the next major (geopandas>=0.14,<2, shapely>=2.0,<3, pyarrow>=14.0,<23, rasterio>=1.3,<2, xarray>=2023.1,<2027, netCDF4>=1.6,<2, and likewise for the bench, dev and docs groups), so a future major cannot be pulled in silently.

Bounds alone do not prevent this, and the fix that matters is knowing which install to run. There are two supported shapes, now documented in README.md and docs/getting-started.md: pip install "geocase[all]" for a greenfield environment, and plain pip install geocase when you already have a geo stack. The plain install is enough to enumerate, select and resolve every case — verified against a clean interpreter with numpy, rasterio, geopandas and xarray all absent — because case.primary_path is an ordinary filesystem path and gdal.Open reads it directly. The extras are the convenience loaders, not how a case is read.

Changed

  • list_cases(format="vector") now raises a redirecting ValueError instead of a raw pydantic ValidationError reciting all 17 FormatType literals (plan 28 phase 2.3). Passing any of the four Category values — "vector", "raster", "netcdf", "satellite" — to format gets a message pointing at category= instead. The format parameter keeps its name; renaming it would break the v1.0 keyword surface to fix a message. Both before and after are exceptions, so the only code affected is code catching ValidationError specifically.

  • (data) latlon_sample.nc was replaced. It was the only fixture in the repository that could not be regenerated: its temperature values were unseeded random floats committed in a single "Analyse structure" commit, and 400 seed and distribution combinations failed to recover them. It is now emitted from a deterministic ramp.

Shape (5, 8), float64, _FillValue = -9999.0 and both fill positions are preserved exactly, so every assertion the case declares still holds. Only the temperature values differ. If you pinned an earlier release and asserted against specific temperatures, those assertions will fail.

The case also dropped three declarations it could not demonstrate: the coordinate_order and dimension_mismatch risk types (the data is conventional (latitude, longitude) rectilinear, which exercises neither) and expect_crs/expected_epsg (the file has no grid_mapping and no crs variable). The dimension-ordering risk now lives on cf_time_ordering_netcdf, which demonstrates it.

  • expected_scale_factor is now enforced. It had been declared in the model, the schema and three raster case.yamls since the raster action plan, and read by nothing. Those three cases are checked against their real band scales for the first time.

  • The six *_gml_baseline cases declare axis_order. Their bytes were always latitude-first — urn:ogc:def:crs:EPSG::4326 forces the authority's declared axis order — and no case.yaml said so. Bytes are unchanged; a new content check verifies the claim against them. Their notes.md files also had their geometry corrected: all six still quoted pre-relocation coordinates.

  • BREAKING (data): the <geometry>_<format>_baseline fixtures now hold the geometry they always claimed to. 53 of the 60 shipped different coordinates from the GeoJSON canonical named in their own params.canonical_source_case_id. Nothing in src/ or tests/ ever dereferenced that link, so the divergence was structurally invisible — and anyone who trusted the naming and diffed, say, KML against Shapefile got a "cross-format difference" that was purely a fixture accident. Two independent evaluations hit exactly that.

Every baseline payload changed. If you assert baseline coordinates downstream, use the table below to update them. Old values are shown normalized, so a row may differ from your file's literal ring order or vertex start.

Family Formats Old geometry New (canonical) geometry
point CSV_WKT, GPKG, SQLite, Shapefile, WKB, WKT POINT (10 52) POINT (12.5 55.7)
point GML POINT (10.5 50.5) POINT (12.5 55.7)
point Arrow, Feather, FlatGeobuf, KML (already correct) POINT (12.5 55.7)
linestring GML, GPKG, KML, SQLite, Shapefile, WKB, WKT LINESTRING (0 0, 1 1, 2 0) LINESTRING (10 50, 10.5 50.3, 11 50.1)
linestring FlatGeobuf, GeoArrow LINESTRING (12 55, 12.5 55.4, 13 55.8) LINESTRING (10 50, 10.5 50.3, 11 50.1)
linestring CSV_WKT (already correct) LINESTRING (10 50, 10.5 50.3, 11 50.1)
polygon FlatGeobuf, GML, KML, Parquet, WKB, WKT POLYGON ((12 55, 12 56, 13 56, 13 55, 12 55)) POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50))
polygon CSV_WKT, GPKG POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0)) POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50))
polygon SQLite, Shapefile (already correct) POLYGON ((10 50, 11 50, 11 51, 10 51, 10 50))
multipoint all but Feather MULTIPOINT ((0 0), (1 1), (2 2)) MULTIPOINT ((10 50), (10.2 50.1), (10.4 50.2))
multipoint Feather MULTIPOINT ((12 55), (12.2 55.1), (12.4 55.2)) MULTIPOINT ((10 50), (10.2 50.1), (10.4 50.2))
multilinestring all but Parquet MULTILINESTRING ((0 0, 1 1), (2 2, 3 3)) MULTILINESTRING ((10 50, 10.5 50.2, 11 50.1), (10.2 49.8, 10.8 49.9, 11.1 50))
multilinestring Parquet MULTILINESTRING ((12 55, 12.4 55.2, 12.8 55.4), (12.1 54.8, 12.6 55, 13 55.3)) (as above)
multipolygon all MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)), ((2 2, 3 2, 3 3, 2 3, 2 2))) MULTIPOLYGON (((10 50, 10.5 50, 10.5 50.5, 10 50.5, 10 50)), ((11 50, 11.5 50, 11.5 50.5, 11 50.5, 11 50)))

Note the multilinestring row in particular: the old fixtures had two-vertex parts where the canonical has three, so no coordinate tolerance would ever have hidden the difference.

The six simple_valid_* GeoJSON canonicals are unchanged, including simple_valid_polygon.params.expected_bounds.

  • BREAKING (data): every baseline now carries exactly id (int64, always 1) and name (str, always the case id). Previously the schemas varied case by case — polygon_geopackage_baseline had id, name, area_sqkm while polygon_shapefile_baseline had only name — so a consumer diffing two members could not tell which column differences were the format and which were fixture accident. Columns removed: value, area_sqkm, length_km, poly_count, segments, and segment_co (itself a silent, undocumented DBF 10-character truncation of segment_count). Format-idiomatic schemas remain covered, deliberately and better, by the special/encoding/* cases.

Three formats cannot honour the schema and are documented exceptions: KML reads name back as Name and synthesizes ~10 columns of its own, GML injects gml_id, and WKT/WKB have no attribute slot at all (VectorCase.load() synthesizes name).

  • point_gml_baseline now declares params.canonical_source_case_id. It carried the cross_format_canonical tag while declaring an unrelated canonical_location: {lon, lat} literal that nothing read — the entire 59-declared vs 60-tagged gap. The literal is removed.

  • Regenerating the KML baselines drops the hand-added <Style> block that polygon_kml_baseline carried. This is intentional, not an oversight: KML styling is format_limited_kml_case's job, and a style element inside a family whose purpose is to hold everything but the format constant is one more uncontrolled variable.

  • CI: the catalog job installs .[raster,vector] rather than .[raster]. The fixture generator now needs shapely, geopandas and pyarrow.

  • (data) The footprint_edge_cases footprint sidecars are renamed, and three of them now hold different geometry. Each <case>_footprint.geojson recorded one of two incompatible things under one name: for hole_center_nodata and all_valid_rectangular it was ground truth derived from the raster's NoData mask, and for the other three it was a recording of the GDAL footprint utility's own simplified/hull output — inflated 1.98× / 1.88× / 2.80× over the real valid-pixel mask, with rotated_two_islands and nonsquare_diagonal_sparse collapsing genuinely disjoint regions into a single polygon.

The two meanings are now separate files, named for what they are:

Old New Meaning
<case>_footprint.geojson <case>_footprint_truth.geojson Mask-exact ground truth, regenerated from the same array as the raster. What params.expected_footprint points at.
— <case>_footprint_gdal_hull.geojson The previously committed GDAL output, kept as a recorded-behaviour baseline. Pointed at by the new params.recorded_gdal_footprint.

all_valid_rectangular has no _gdal_hull file: every pixel is valid, so the hull is the mask. No case id, files.primary, fixture, marker or __all__ entry changed; if you read these sidecars by filename, update the path and expect real geometry.

  • (data) params.min_rect_ratio re-derived from the truth geometry for all five footprint edge cases. The old values (0.74 / 0.93 / 0.76 / 0.98) had been fitted to the near-rectangular hulls and so asserted almost nothing; the real shapes score 0.375 / 0.500 / 0.275 / 0.889.

  • The content gate checks footprint shape, not just hole count. geocase.catalog.content._check_footprint now also compares part count and area against a freshly re-derived mask. A declared footprint that merges disjoint regions, or that is a hull rather than a mask, fails scripts/validate_case_content.py. This is stricter than before and may turn cases in external manifests red.

  • landcover_small's behavioral_goal now names landcover_ambiguous_zero_small instead of referring to a case that did not exist.

Added

  • landcover_ambiguous_zero_small — a new raster case where 0 is simultaneously a valid land-cover class and the declared NoData sentinel. A consumer masking data == nodata silently deletes a legitimate class covering 25% of the scene; one ignoring NoData treats sentinel pixels as data. Nothing in the file distinguishes the two, and that indistinguishability is the case. It is the sibling of landcover_small — the same scene with the ambiguity removed — so the pair isolates the collision from every other property. Declares risk_types: [ambiguous_zero, nodata_ignored, category_misread]. The bundled catalog is now 136 cases.

  • shapefile_ring_orientation — a new special/encoding/ case preserving the pre-convergence polygon_shapefile_baseline bytes: the same square as simple_valid_polygon but with a clockwise exterior ring. The Shapefile specification mandates CW exteriors where RFC 7946 mandates CCW, and OGR rewrites orientation on write, so a GeoJSON → Shapefile round trip silently reverses it. Code that reads is_ccw to tell an exterior from a hole breaks here.

It exists as its own case because the cross-format comparison must be winding-insensitive — the Shapefile members of any family can never match a CCW canonical — which makes this artifact unassertable inside a baseline family.

  • Three gates, so this class of defect cannot return silently.
  • scripts/validate_catalog.py now checks that the cross_format_canonical tag and params.canonical_source_case_id are biconditional, that the id resolves, that the target is GeoJSON, that geometry types match, and that a canonical is not itself tagged. No geospatial dependencies, so it runs in the GDAL-only catalog job.
  • tests/unit/test_cross_format_canonical.py loads every tagged case through VectorCase.load() and asserts geometry (via shapely.normalize, tolerance 1e-9), geometry type, CRS (via pyproj.CRS, since the columnar formats return PROJJSON rather than the string "EPSG:4326"), the name value, and the column schema. Cases are auto-discovered, so future baselines are gated automatically.
  • scripts/generate_vector_fixtures.py now generates all 60 baselines rather than only the five SpatiaLite ones, deriving each geometry from its declared canonical, and --check verifies every one.

Fixed

  • geometry.xsd is now declared in files.sidecars for the six GML cases. It was hashed by generate_checksums.py but undeclared, and validate_catalog.py only checks declared files — so a missing GML schema would have shipped unnoticed.

  • docs/dataset-catalog.md geography was wrong. Thirty-six baselines sat at or beside (0, 0) — colliding with the null_island_point sentinel's entire reason for existing — while the page claimed they were in Central Europe. Convergence makes the claim true and leaves only seven deliberate cases near the origin. The bundled-payload figure is also corrected from 4.2 MB to its actual 2.1 MB. The two numbers came from different measurements: du -sh src/geocase/data reports 4.2 MB because 572 mostly-tiny files each round up to a 4 KB block, while the payload's real byte sum is 2.1 MB (2.4 MB including case.yaml, notes.md, and checksums.sha256). The wheel carries 2.3 MB uncompressed. 2.1 MB is the figure quoted everywhere; du output is not.

  • The bundled case count is 135, not 134. The 1.0.0 entry's "134 bundled cases" was correct at that release; the catalog has grown by one since. README.md, docs/index.md and recipe/meta.yaml's build-time assertion said 134 and now say 135, and scripts/validate_catalog.py gates all three against len(get_registry()) so the number cannot drift again.

[1.0.0-freeze] — 2026-08-02

Not a published version. This section records the v1.0 feature freeze: the feature set was finalised on 2026-08-02, and that date is what the heading records. Nothing was uploaded under this heading. What actually shipped as 1.0.0 is the section above, which folds this freeze together with everything the release candidates added; this entry is kept because it is where the compatibility promise and the removals were first written down.

Correction (2026-09-05): this heading previously read ## [1.0.0] — dated 2026-08-02, **not yet released** and stated that no GeoCase version had ever been uploaded to PyPI or TestPyPI. That was true when written and is no longer: 1.0.0rc1, 1.0.0rc2 and 1.0.0rc3 were published to PyPI during August 2026, and 1.0.0 followed on 2026-09-05. Renamed to 1.0.0-freeze so that exactly one section in this file describes the released 1.0.0. Version 0.1.0 was never uploaded.

An earlier correction (2026-08-23) fixed this entry's original claim that 1.0.0 was "the first release published to PyPI"; see Plan 25 for the release process that had not yet been run at that point.

The compatibility promise

v1.0 makes a stability commitment on two surfaces only:

  1. The pytest workflow — the geocase_case / geocase fixtures and the geocase_case, geocase_suite, and geocase_select markers.
  2. The public API — the names exported from import geocase, pinned against a literal in tests/unit/test_public_api.py. This surface was 27 names at the freeze and is 29 as released, after Plan 31 added SpatialExtent and Category. That is an additive change: no name was removed or renamed, so the promise holds.

Everything else — module layout, internal helpers, the shape of geocase.catalog — is internal and may change in a minor release. The promise is deliberately narrow because those two surfaces are the genuinely mature ones; a wider claim would make the 1.0 label dishonest.

Breaking changes

  • The command-line interface and its [project.scripts] entry point have been removed. The declared geocase console script was broken in every install — it pointed at a module that raised ImportError — so no working usage can depend on it. There is no CLI in v1.0. Use the Python API or the pytest plugin.

Added

  • Public API (import geocase): a pinned surface — 27 names at this freeze, 29 as released — covering case discovery, loading, and inspection — list_cases, get_case, load_case, show_case, list_suites, get_suite, __version__, the case classes, the metadata models and enums, and RemoteCaseUnavailableError.
  • docs/dataset-catalog.md: what the catalog contains, why each format was chosen, the geodetic rationale for every coordinate cluster, and an honest list of coverage gaps.
  • Generated catalog pages under docs/_generated/catalog/: an index plus one page per case, per risk type, and per format, carrying schema.org/Dataset JSON-LD.
  • Manifest support: extended-manifests/*.yaml parse and resolve, remote case ids are discoverable through the registry and show_case, and GEOCASE_MANIFESTS selects which manifests are loaded.
  • CI quality gates: ruff check and ruff format --check, mypy, a Python 3.11/3.14 test matrix, and non-blocking coverage reporting.
  • Catalog integrity gates: orphaned case metadata, manifest validity (shadowed ids, cross-manifest duplicates, malformed digests, dangling bundled_analog references), size_class versus real on-disk payload, generated-page drift, and case ids named in hand-written docs.

Changed

  • Bundled data shrank from 36 MB to 2.1 MB. Five SpatiaLite fixtures declared size_class: tiny were 6.7 MB each; size_class now has enforced byte thresholds.
  • cases/raster.py loads through loaders/rasterio_loader.py, making it the single raster load path.
  • requires-python is >=3.11, with classifiers for 3.11 through 3.14 — the versions actually tested rather than the ones plausibly supported.
  • Development status classifier: 3 - Alpha → 5 - Production/Stable.
  • project.urls now point at the canonical repository host. (This entry originally said "GitLab"; the project has always lived on GitHub at https://github.com/farzinashouri/geocase, and the URLs point there.)
  • The case schema's format enum went from 7 values to 17, and its assertions block from 6 documented fields to all 16. Both are now pinned to the models by a test.

Fixed

  • The raster coverage matrix reported 25 of 30 cases: the generator's glob missed the five footprint_edge_cases files. Because the artifact was gated by git diff --exit-code, CI was actively enforcing the wrong number.
  • Case pages linked risk hubs at ../../risk/, one level too high, producing 187 broken links. mkdocs build --strict now passes.
  • materialize_case raised an internal No case root found for manifest cases instead of an actionable error; both error paths are now asserted in tests.
  • The registry singleton ignored GEOCASE_MANIFESTS changes after first use; resolved manifest paths are part of the cache key.

Removed

  • The cli/ package and its entry point (see Breaking changes).
  • catalog/validators.py — an empty stub nothing imported.
  • raster/affine_transform_quirk/ — an empty case directory that shipped in the wheel while appearing in no index. The rotated/skewed-transform coverage it implied is a genuine gap, now tracked on the v1.1 list rather than implied by a placeholder.

Deferred to v1.1

Stated as decisions, not omissions:

  • Remote dataset transport. Declared remote cases are discoverable and raise clear errors, but nothing downloads, caches, or unpacks. Both manifests are entirely placeholder — every sha256 is replace_me, every base_uri is example.org. The gate for reopening this is concrete: at least one real published archive with a real sha256.
  • Rotated/skewed affine transforms and non-square pixels, and southern-hemisphere UTM coverage — see the coverage gaps in the dataset catalog.

Known numbers

134 bundled cases (103 vector, 30 raster, 1 NetCDF) across 16 formats, 2.1 MB of bundled data, 780 passing tests, 54% line coverage.