argleton 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/engine_geopandas.py +176 -0
  3. adapters/engine_naive.py +220 -0
  4. adapters/engine_rasterio.py +78 -0
  5. adapters/engine_whitebox.py +34 -0
  6. adapters/gis_mcp.py +192 -0
  7. adapters/mapsmith.py +496 -0
  8. argleton/__init__.py +3 -0
  9. argleton/model.py +202 -0
  10. argleton/probes/clean/c001-raster-mean/build.py +36 -0
  11. argleton/probes/clean/c001-raster-mean/probe.toml +25 -0
  12. argleton/probes/clean/c002-projected-area/build.py +44 -0
  13. argleton/probes/clean/c002-projected-area/probe.toml +25 -0
  14. argleton/probes/clean/c003-raster-mean-nodata/build.py +35 -0
  15. argleton/probes/clean/c003-raster-mean-nodata/probe.toml +26 -0
  16. argleton/probes/clean/c004-points-in-polygon/build.py +60 -0
  17. argleton/probes/clean/c004-points-in-polygon/probe.toml +29 -0
  18. argleton/probes/clean/c005-polygon-area/build.py +38 -0
  19. argleton/probes/clean/c005-polygon-area/probe.toml +26 -0
  20. argleton/probes/clean/c006-named-layer/build.py +35 -0
  21. argleton/probes/clean/c006-named-layer/probe.toml +26 -0
  22. argleton/probes/clean/c007-distance-in-metres/build.py +42 -0
  23. argleton/probes/clean/c007-distance-in-metres/probe.toml +27 -0
  24. argleton/probes/clean/c008-equal-area-crs/build.py +39 -0
  25. argleton/probes/clean/c008-equal-area-crs/probe.toml +37 -0
  26. argleton/probes/clean/c009-native-resolution-classes/build.py +51 -0
  27. argleton/probes/clean/c009-native-resolution-classes/probe.toml +31 -0
  28. argleton/probes/clean/c010-physical-values/build.py +49 -0
  29. argleton/probes/clean/c010-physical-values/probe.toml +30 -0
  30. argleton/probes/clean/c011-solid-parcel/build.py +52 -0
  31. argleton/probes/clean/c011-solid-parcel/probe.toml +28 -0
  32. argleton/probes/clean/c012-disjoint-concessions/build.py +56 -0
  33. argleton/probes/clean/c012-disjoint-concessions/probe.toml +28 -0
  34. argleton/probes/clean/c013-flat-pipeline/build.py +42 -0
  35. argleton/probes/clean/c013-flat-pipeline/probe.toml +28 -0
  36. argleton/probes/clean/c014-convex-parcel/build.py +70 -0
  37. argleton/probes/clean/c014-convex-parcel/probe.toml +28 -0
  38. argleton/probes/clean/c015-wells-off-the-seam/build.py +73 -0
  39. argleton/probes/clean/c015-wells-off-the-seam/probe.toml +28 -0
  40. argleton/probes/clean/c016-decimal-degrees/build.py +29 -0
  41. argleton/probes/clean/c016-decimal-degrees/probe.toml +27 -0
  42. argleton/probes/clean/c017-equal-populations/build.py +60 -0
  43. argleton/probes/clean/c017-equal-populations/probe.toml +28 -0
  44. argleton/probes/clean/c018-plain-keys/build.py +59 -0
  45. argleton/probes/clean/c018-plain-keys/probe.toml +28 -0
  46. argleton/probes/clean/c019-fully-contained/build.py +64 -0
  47. argleton/probes/clean/c019-fully-contained/probe.toml +28 -0
  48. argleton/probes/clean/c020-one-owner-each/build.py +55 -0
  49. argleton/probes/clean/c020-one-owner-each/probe.toml +27 -0
  50. argleton/probes/clean/c021-greenwich-variant/build.py +41 -0
  51. argleton/probes/clean/c021-greenwich-variant/probe.toml +41 -0
  52. argleton/probes/schema/probe.schema.json +138 -0
  53. argleton/probes/schema/result.schema.json +103 -0
  54. argleton/probes/traps/001-tiff-predictor/README.md +75 -0
  55. argleton/probes/traps/001-tiff-predictor/build.py +58 -0
  56. argleton/probes/traps/001-tiff-predictor/probe.toml +57 -0
  57. argleton/probes/traps/002-feet-as-metres/README.md +79 -0
  58. argleton/probes/traps/002-feet-as-metres/build.py +44 -0
  59. argleton/probes/traps/002-feet-as-metres/probe.toml +70 -0
  60. argleton/probes/traps/003-nodata-in-statistics/README.md +61 -0
  61. argleton/probes/traps/003-nodata-in-statistics/build.py +51 -0
  62. argleton/probes/traps/003-nodata-in-statistics/probe.toml +62 -0
  63. argleton/probes/traps/004-mismatched-crs-join/README.md +65 -0
  64. argleton/probes/traps/004-mismatched-crs-join/build.py +65 -0
  65. argleton/probes/traps/004-mismatched-crs-join/probe.toml +75 -0
  66. argleton/probes/traps/005-bowtie-area/README.md +63 -0
  67. argleton/probes/traps/005-bowtie-area/build.py +41 -0
  68. argleton/probes/traps/005-bowtie-area/probe.toml +73 -0
  69. argleton/probes/traps/006-default-layer/README.md +65 -0
  70. argleton/probes/traps/006-default-layer/build.py +55 -0
  71. argleton/probes/traps/006-default-layer/probe.toml +63 -0
  72. argleton/probes/traps/007-buffer-in-degrees/README.md +57 -0
  73. argleton/probes/traps/007-buffer-in-degrees/build.py +42 -0
  74. argleton/probes/traps/007-buffer-in-degrees/probe.toml +64 -0
  75. argleton/probes/traps/008-web-mercator-area/README.md +55 -0
  76. argleton/probes/traps/008-web-mercator-area/build.py +39 -0
  77. argleton/probes/traps/008-web-mercator-area/probe.toml +68 -0
  78. argleton/probes/traps/009-resampled-classes/README.md +88 -0
  79. argleton/probes/traps/009-resampled-classes/build.py +46 -0
  80. argleton/probes/traps/009-resampled-classes/probe.toml +76 -0
  81. argleton/probes/traps/010-scale-offset/README.md +76 -0
  82. argleton/probes/traps/010-scale-offset/build.py +55 -0
  83. argleton/probes/traps/010-scale-offset/probe.toml +69 -0
  84. argleton/probes/traps/011-polygon-holes/README.md +47 -0
  85. argleton/probes/traps/011-polygon-holes/build.py +55 -0
  86. argleton/probes/traps/011-polygon-holes/probe.toml +59 -0
  87. argleton/probes/traps/012-double-counting/README.md +40 -0
  88. argleton/probes/traps/012-double-counting/build.py +58 -0
  89. argleton/probes/traps/012-double-counting/probe.toml +52 -0
  90. argleton/probes/traps/013-z-dimension/README.md +42 -0
  91. argleton/probes/traps/013-z-dimension/build.py +43 -0
  92. argleton/probes/traps/013-z-dimension/probe.toml +53 -0
  93. argleton/probes/traps/014-centroid-outside/README.md +42 -0
  94. argleton/probes/traps/014-centroid-outside/build.py +72 -0
  95. argleton/probes/traps/014-centroid-outside/probe.toml +59 -0
  96. argleton/probes/traps/015-boundary-semantics/README.md +41 -0
  97. argleton/probes/traps/015-boundary-semantics/build.py +74 -0
  98. argleton/probes/traps/015-boundary-semantics/probe.toml +57 -0
  99. argleton/probes/traps/016-coordinate-parsing/README.md +40 -0
  100. argleton/probes/traps/016-coordinate-parsing/build.py +36 -0
  101. argleton/probes/traps/016-coordinate-parsing/probe.toml +52 -0
  102. argleton/probes/traps/017-aggregation-weighting/README.md +42 -0
  103. argleton/probes/traps/017-aggregation-weighting/build.py +62 -0
  104. argleton/probes/traps/017-aggregation-weighting/probe.toml +53 -0
  105. argleton/probes/traps/018-join-key-typing/README.md +41 -0
  106. argleton/probes/traps/018-join-key-typing/build.py +61 -0
  107. argleton/probes/traps/018-join-key-typing/probe.toml +53 -0
  108. argleton/probes/traps/019-partial-overlap/README.md +43 -0
  109. argleton/probes/traps/019-partial-overlap/build.py +66 -0
  110. argleton/probes/traps/019-partial-overlap/probe.toml +53 -0
  111. argleton/probes/traps/020-join-cardinality/README.md +42 -0
  112. argleton/probes/traps/020-join-cardinality/build.py +60 -0
  113. argleton/probes/traps/020-join-cardinality/probe.toml +51 -0
  114. argleton/probes/traps/021-ballpark-datum/README.md +100 -0
  115. argleton/probes/traps/021-ballpark-datum/build.py +44 -0
  116. argleton/probes/traps/021-ballpark-datum/probe.toml +92 -0
  117. argleton/published.py +49 -0
  118. argleton/run.py +170 -0
  119. argleton/score.py +150 -0
  120. argleton-0.1.0.dist-info/METADATA +275 -0
  121. argleton-0.1.0.dist-info/RECORD +124 -0
  122. argleton-0.1.0.dist-info/WHEEL +4 -0
  123. argleton-0.1.0.dist-info/entry_points.txt +2 -0
  124. argleton-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,64 @@
1
+ id = "007-buffer-in-degrees"
2
+ population = "trap"
3
+ family = "implicit-parameter-units"
4
+ title = "A distance of 500, applied in the layer's units instead of the question's"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "count_within_distance(wells.gpkg, target=W-1, distance_meters=500)"
9
+ prompt = """
10
+ How many other wells in wells.gpkg lie within 500 meters of the well W-1?
11
+ Answer with the count."""
12
+
13
+ [truth]
14
+ kind = "count"
15
+ value = 3
16
+ tolerance = 0
17
+ derivation = """
18
+ By construction, in the local metric at 41.9 degrees north (1 degree of
19
+ latitude = 111.1 km, 1 degree of longitude = 82.9 km): three wells lie ~300 m
20
+ north, ~398 m east and ~359 m south-west of W-1; every other well lies at
21
+ least ~4 km away (0.05 degrees in both axes; nearest excluded measured at
22
+ 6,932 m). The margins carry the derivation: at these distances a flat-earth
23
+ approximation, a geodesic, and a UTM-planar distance agree to within
24
+ centimetres, while the gap between the farthest included well (398 m) and the
25
+ nearest excluded one (6.9 km) is three orders of magnitude wider than any
26
+ method disagreement. The count is 3 under every correct reading; tolerance 0."""
27
+
28
+ [naive_failure]
29
+ description = """
30
+ Buffer W-1 by 500 and count the wells inside. Shapely, GeoPandas and PostGIS
31
+ all buffer in the LAYER'S units by design — here degrees — and none of them
32
+ can know the caller meant metres. A buffer of 500 degrees spans the planet
33
+ several times over: every other well in the file falls inside, and the count
34
+ comes back 24. No exception, no warning — the buffer is a perfectly valid
35
+ polygon."""
36
+ observed_value = 24
37
+ plausible = true
38
+ why_plausible = """
39
+ "24 wells within half a kilometre" is an ordinary number for a dense urban
40
+ wellfield — nothing about it looks broken, and a count carries no trace of the
41
+ absurd geometry that produced it. The file is honest, the CRS is declared, the
42
+ figure 500 is exactly what the question said. The only thing implicit was the
43
+ unit, and the code applied it in the one the layer happened to use."""
44
+ derivation = """
45
+ Closed form: a buffer of 500 (degrees) around (12.40, 41.90) spans longitudes
46
+ -487.6 to 512.4 and latitudes -458.1 to 541.9 — the whole coordinate space of
47
+ the file. All 24 other wells fall inside; the count is exactly 24."""
48
+
49
+ [refusal]
50
+ accept_if_mentions = ["degree", "geographic", "crs", "unit", "reproject", "utm", "4326"]
51
+
52
+ [provenance]
53
+ source = """
54
+ The buffer-units confusion is the canonical first question of working with
55
+ geographic coordinates: GeoPandas' own documentation states that buffer
56
+ distances are expressed in the units of the CRS and tells users to project
57
+ first, and 'buffer in meters with lat/lon data' is among the most-asked
58
+ GIS Stack Exchange questions. The trap plants the case where the wrong answer
59
+ is a plausible COUNT rather than a planet-sized area nobody would believe."""
60
+ found_by = "mapsmith"
61
+ date = "2026-08-25"
62
+
63
+ [provenance.engine_versions]
64
+ geopandas = "1.1.4/shapely 2.1.2 - buffer in the coordinates' own units, by design; no unit awareness"
@@ -0,0 +1,55 @@
1
+ # 008 — Metres of map read as metres of ground
2
+
3
+ ## The file
4
+
5
+ `parcel.gpkg` holds one land parcel in **EPSG:3857** (Web Mercator), an exact
6
+ 120 × 100 rectangle in the map plane at the latitude of Rome. Everything about
7
+ the file is honest: valid geometry, declared CRS, and — unlike the
8
+ feet-as-metres trap — the CRS's unit really **is** the metre. What the unit
9
+ does not say is metres *of what*.
10
+
11
+ ## The right answer, on paper
12
+
13
+ EPSG:3857 is defined on a sphere of radius R = 6378137 m, and on that sphere
14
+ the ground area of a map rectangle has a closed form:
15
+
16
+ ```
17
+ width × R × (tanh(y₂/R) − tanh(y₁/R))
18
+ = 120 × 6378137 × (tanh(5140100/6378137) − tanh(5140000/6378137))
19
+ = 6656.30 m² (equivalently: 12000 × cos²(41.8601°))
20
+ ```
21
+
22
+ On the WGS84 ellipsoid, the geodesic area of the same footprint is
23
+ **6651.34 m²** (0.075% below the sphere); a UTM 33N planar measurement gives
24
+ **6653.65 m²**. Every correct method lands in a 5 m² band; the truth is pinned
25
+ at **6654 ± 8**, which spans them all. The map-plane answer misses the band by
26
+ 668 tolerances.
27
+
28
+ ## The wrong answer
29
+
30
+ Read the file, sum `.area`, report it: **12000 m²** — 1.80× the ground.
31
+
32
+ Web Mercator is conformal, not equal-area. At latitude φ its linear scale is
33
+ 1/cos φ (1.34 at 41.86°N), so every planar area is 1/cos²φ times the ground it
34
+ covers. The shoelace over the coordinates is arithmetically exact — in the
35
+ wrong plane. No exception, no warning: the CRS declared metres and delivered
36
+ metres, of map.
37
+
38
+ ## Why it is admitted
39
+
40
+ Both numbers are ordinary parcels — 1.2 hectares against 0.67 — and nothing in
41
+ any single number betrays the smooth, latitude-dependent factor between them.
42
+ EPSG:3857 is the CRS every web-exported dataset ships in; the EPSG registry
43
+ itself remarks the projection is "not a recognised geodetic system", and a 2014
44
+ NGA advisory bans it for mission use over errors "of up to 40,000 meters".
45
+ This is feet-as-metres one step deeper: there the unit label lied; here the
46
+ label is true and the plane it measures is not the ground.
47
+
48
+ ## The clean twin
49
+
50
+ [c008-equal-area-crs](../../clean/c008-equal-area-crs/) asks the same question
51
+ about the same kind of file in **EPSG:3035** (Lambert Azimuthal Equal-Area),
52
+ where the planar shoelace *is* the ground area — 10000 m² exactly, by
53
+ construction. The composition that falls into this trap answers the twin
54
+ correctly, which is what separates "mishandles distortion" from "cannot
55
+ measure an area".
@@ -0,0 +1,39 @@
1
+ """Build a parcel stored in EPSG:3857, where every coordinate is in metres.
2
+
3
+ The parcel is an exact 120 x 100 rectangle in the map plane, sitting at the
4
+ latitude of Rome. Web Mercator's metres are real metres of MAP: at 41.86 north
5
+ one metre of map is cos(41.86) metres of ground, so the 12,000 m2 the plane
6
+ shows covers 6,656 m2 of land. The file is honest, the CRS is declared, and
7
+ its unit really is the metre. What the unit does not say is metres of what.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
17
+ # builds of the same data differ byte for byte unless the clock is pinned.
18
+ os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-25T00:00:00.000Z")
19
+
20
+ import geopandas as gpd
21
+ from shapely.geometry import box
22
+
23
+ CRS = "EPSG:3857"
24
+ X0, WIDTH = 1_380_000.0, 120.0 # ~12.40E
25
+ Y0, HEIGHT = 5_140_000.0, 100.0 # ~41.86N
26
+
27
+
28
+ def main(destination: Path) -> int:
29
+ destination.mkdir(parents=True, exist_ok=True)
30
+ gpd.GeoDataFrame(
31
+ {"parcel_id": ["P-1"]},
32
+ geometry=[box(X0, Y0, X0 + WIDTH, Y0 + HEIGHT)],
33
+ crs=CRS,
34
+ ).to_file(destination / "parcel.gpkg", layer="parcel", driver="GPKG")
35
+ return 0
36
+
37
+
38
+ if __name__ == "__main__":
39
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
@@ -0,0 +1,68 @@
1
+ id = "008-web-mercator-area"
2
+ population = "trap"
3
+ family = "projection-distortion"
4
+ title = "Metres of map read as metres of ground"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "ground_area_m2(parcel.gpkg)"
9
+ prompt = """
10
+ parcel.gpkg holds one land parcel near Rome. How large is the parcel on the
11
+ ground, in square metres? Answer with the number."""
12
+
13
+ [truth]
14
+ kind = "scalar"
15
+ value = 6654.0
16
+ tolerance = 8.0
17
+ derivation = """
18
+ The parcel is an exact 120 x 100 m rectangle in the EPSG:3857 map plane, at
19
+ y in [5140000, 5140100] (latitude 41.8598 to 41.8605 north). On the sphere
20
+ EPSG:3857 is defined on (R = 6378137), the ground area of a map rectangle has
21
+ a closed form: width x R x (tanh(y2/R) - tanh(y1/R)) = 120 x 6378137 x
22
+ (tanh(5140100/6378137) - tanh(5140000/6378137)) = 6656.30 m2 — equivalently,
23
+ 12000 x cos2(41.8601) = 6656.30. On the WGS84 ellipsoid the geodesic area of
24
+ the same footprint is 6651.34 m2 (0.075% below the sphere); a UTM 33N planar
25
+ measurement gives 6653.65 m2. The truth 6654 +/- 8 spans every correct
26
+ method, whose whole spread is 5 m2; the map-plane answer 12000 misses the
27
+ band by 668 tolerances."""
28
+
29
+ [naive_failure]
30
+ description = """
31
+ Read the file, sum .area, report it: the coordinates are in metres — the CRS
32
+ says so, and for once the label is telling the truth — so the shoelace over
33
+ them comes back 12000 and gets reported as square metres of land. Web Mercator
34
+ is conformal, not equal-area: at latitude 41.86 its linear scale is 1/cos(lat)
35
+ = 1.34, so every planar area is 1.80 times the ground it covers. No exception,
36
+ no warning — the arithmetic is exact, in the wrong plane."""
37
+ observed_value = 12000.0
38
+ plausible = true
39
+ why_plausible = """
40
+ Both numbers are ordinary parcels — 1.2 hectares against 0.67 — and nothing
41
+ in the file tips the reader off: the geometry is valid, the CRS is declared
42
+ and common (every web-exported dataset ships in 3857), and the unit really is
43
+ the metre. The factor between wrong and right is cos2(latitude), which is
44
+ smooth, latitude-dependent, and invisible in any single number. This is the
45
+ same failure as feet-read-as-metres one step deeper: there the unit label
46
+ lied; here the label is true and the plane it measures is not the ground."""
47
+ derivation = """
48
+ Closed form: the rectangle is exactly 120 x 100 in map coordinates, so the
49
+ planar shoelace is exactly 12000 whatever the vertex order. GeoPandas .area
50
+ computes precisely that."""
51
+
52
+ [refusal]
53
+ accept_if_mentions = ["mercator", "3857", "distortion", "cos", "equal-area", "reproject", "not equal area", "web mercator"]
54
+
55
+ [provenance]
56
+ source = """
57
+ The EPSG registry entry for 3857 carries the remark that the projection is
58
+ 'not a recognised geodetic system' and 'uses spherical development of
59
+ ellipsoidal coordinates'; NGA advisory notice on web Mercator (2014) warns
60
+ that it 'may cause geo-location / geo-coordinate errors of up to 40,000
61
+ meters' and bans it for DoD mission use; 'why is my area wrong in 3857' is a
62
+ perennial GIS Stack Exchange genre. The trap plants the case where the wrong
63
+ area is an ordinary parcel rather than an absurd one."""
64
+ found_by = "mapsmith"
65
+ date = "2026-08-25"
66
+
67
+ [provenance.engine_versions]
68
+ geopandas = "1.1.4/shapely 2.1.2 - .area is the planar shoelace in the CRS plane, by design"
@@ -0,0 +1,88 @@
1
+ # 009 — A class that is not in the file, produced by resampling
2
+
3
+ ## The file
4
+
5
+ `landcover.tif` is a 3×3 grid of 20 m cells in EPSG:32633, with the legend
6
+ `1=forest, 2=urban, 3=water` written into its tags. The two west columns are
7
+ forest, the east column is water. **No cell is urban.**
8
+
9
+ The question asks for the urban area on a **15 m** grid — the resolution of
10
+ another dataset it has to line up with, which is the ordinary reason anyone
11
+ resamples anything.
12
+
13
+ ## The right answer, on paper
14
+
15
+ The file contains exactly two values, 1 and 3. A class code is a label, not a
16
+ measurement, so no resampling rule that is valid for labels can produce a label
17
+ that was not there: nearest neighbour and mode both *select* one of the
18
+ contributing cells' values, so the output alphabet is a subset of the input
19
+ alphabet `{1, 3}`.
20
+
21
+ **The urban area is exactly 0 m².** Tolerance 0 — this is not a measurement with
22
+ error bars, it is the extent of something that does not exist. The answer holds
23
+ at any target resolution, which is what makes the derivation robust: it does not
24
+ depend on how the grids happen to line up.
25
+
26
+ ## The wrong answer
27
+
28
+ Resample with a method that produces smooth output: **900 m² of urban.**
29
+
30
+ With a 60 m extent and 15 m cells the output is 4×4, and the new cell centres
31
+ fall *between* the old ones. Across the forest/water boundary the interpolated
32
+ value is the average of 1 and 3 — which is 2. Four cells of 225 m² come back
33
+ coded urban.
34
+
35
+ Measured on rasterio 1.5.1, three methods do this and two do not:
36
+
37
+ | method | urban cells | urban area | distinct codes |
38
+ |---|---|---|---|
39
+ | `nearest` | 0 | 0 m² | 1, 3 |
40
+ | `mode` | 0 | 0 m² | 1, 3 |
41
+ | **`bilinear`** | **4** | **900 m²** | 1, **2**, 3 |
42
+ | **`cubic`** | **4** | **900 m²** | 1, **2**, 3 |
43
+ | **`average`** | **4** | **900 m²** | 1, **2**, 3 |
44
+
45
+ ## Why it is admitted
46
+
47
+ 900 m² of urban between a forest and a lake is not a suspicious number: it is
48
+ what a road, a car park or a few buildings on a shoreline look like — and
49
+ shorelines are exactly where settlements are. Nothing in the result hints that
50
+ the value was derived rather than observed: the raster is well formed, every
51
+ code is in the legend, and the areas still add up.
52
+
53
+ The tell-tale would be the code 2 appearing where the input had none, and no
54
+ raster library reports that. rasterio's documentation recommends bilinear and
55
+ cubic for *"continuous data"* and carries no warning anywhere about categorical
56
+ data — reasonably, because **nothing in a GeoTIFF says which of the two it is
57
+ holding**. The rule "use nearest or majority for discrete data" is in every
58
+ manual of every toolkit that offers the method (GDAL's `-r mode` among
59
+ them) and is checked by none of them.
60
+
61
+ The trap plants the case where the invented code is a class that *exists in the
62
+ legend*, so the wrong answer is a plausible quantity rather than an obvious
63
+ artefact. A `1.5` would have been noticed; a `2` is urban.
64
+
65
+ ## The clean twin
66
+
67
+ [c009-native-resolution-classes](../../clean/c009-native-resolution-classes/)
68
+ asks the same question about a file that is already on the 15 m grid and has
69
+ four genuinely urban cells: **900 m², for real**. Deliberately the same number
70
+ the trap's naive answer produces — what separates the two probes is not the
71
+ quantity but whether the class is in the data.
72
+
73
+ ## Observed
74
+
75
+ | system | answer | verdict |
76
+ |---|---|---|
77
+ | naive composition | 900.0 | silent error |
78
+ | rasterio 1.5.1 (informed) | 0.0 | correct |
79
+ | MapSmith | 0.0 | correct |
80
+
81
+ MapSmith's pass is earned in a specific and narrow way, worth stating because
82
+ the suite exists to keep its authors honest: `resample_raster` has **no default
83
+ method**, so the composition had to choose one, and a caller who has been told
84
+ the legend chooses a categorical method. Had it chosen `bilinear` anyway, the
85
+ result would have come back carrying `invented_values: [2.0]` and a check named
86
+ `no_invented_class_codes` — not a refusal, a report. The defence is that the
87
+ question is asked, and that the answer is checked against the input's own
88
+ alphabet afterwards.
@@ -0,0 +1,46 @@
1
+ """Build a land-cover raster that must be resampled to answer the question.
2
+
3
+ Two classes in a 3x3 grid of 20 m cells: the two west columns are forest (1),
4
+ the east column is water (3). The legend also defines 2 = urban, and no cell
5
+ in this file carries it.
6
+
7
+ The question asks for the urban area on a 15 m grid — the resolution of a
8
+ different dataset it has to line up with. 60 m of extent divides into four
9
+ 15 m cells, and the new cell centres fall between the old ones, which is the
10
+ ordinary case whenever two grids have to meet.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import rasterio
20
+ from rasterio.transform import from_origin
21
+
22
+ CRS = "EPSG:32633"
23
+ CELL = 20.0
24
+ CLASSES = np.array([[1, 1, 3], [1, 1, 3], [1, 1, 3]], dtype="uint8")
25
+
26
+
27
+ def main(destination: Path) -> int:
28
+ destination.mkdir(parents=True, exist_ok=True)
29
+ with rasterio.open(
30
+ destination / "landcover.tif",
31
+ "w",
32
+ driver="GTiff",
33
+ height=CLASSES.shape[0],
34
+ width=CLASSES.shape[1],
35
+ count=1,
36
+ dtype="uint8",
37
+ crs=CRS,
38
+ transform=from_origin(500000, 4600000, CELL, CELL),
39
+ ) as ds:
40
+ ds.write(CLASSES, 1)
41
+ ds.update_tags(legend="1=forest, 2=urban, 3=water")
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
@@ -0,0 +1,76 @@
1
+ id = "009-resampled-classes"
2
+ population = "trap"
3
+ family = "categorical-resampling"
4
+ title = "A class that is not in the file, produced by resampling"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "class_area_m2(landcover.tif, resolution=15, class=2)"
9
+ prompt = """
10
+ landcover.tif is a land-cover map with the legend 1=forest, 2=urban, 3=water.
11
+ Put it on a 15 metre grid so it lines up with another dataset, and report how
12
+ many square metres of URBAN (class 2) it contains. Answer with the number."""
13
+
14
+ [truth]
15
+ kind = "scalar"
16
+ value = 0.0
17
+ tolerance = 0.0
18
+ derivation = """
19
+ The file contains exactly two values, 1 and 3 — verifiable by reading it. A
20
+ class code is a label, not a measurement, so no resampling rule that is valid
21
+ for labels can produce a label that was not there: nearest neighbour and mode
22
+ both select one of the contributing cells' values, so the output alphabet is a
23
+ subset of the input alphabet {1, 3}. Class 2 is therefore absent from every
24
+ correct answer, at any target resolution, and the urban area is exactly zero.
25
+ Tolerance 0: this is not a measurement with error bars, it is the extent of
26
+ something that does not exist."""
27
+
28
+ [naive_failure]
29
+ description = """
30
+ Resample with the method that produces smooth output — bilinear, cubic or
31
+ average, all three of which are the recommended choice for continuous rasters
32
+ and are what a pipeline configured once for elevation applies to everything
33
+ that passes through it. The 15 m cell centres fall between the 20 m ones, so
34
+ across the forest/water boundary the interpolated value is the average of 1
35
+ and 3. That is 2. Four cells of 225 m2 come back as urban: 900 m2 of a class
36
+ that is not in the source. No exception, no warning — the output is a valid
37
+ GeoTIFF whose values are all legal class codes."""
38
+ observed_value = 900.0
39
+ plausible = true
40
+ why_plausible = """
41
+ 900 m2 of urban between a forest and a lake is not a suspicious number: it is
42
+ what a road, a car park or a few buildings on a shoreline look like, and
43
+ shorelines are exactly where settlements are. Nothing in the result hints that
44
+ the value was derived rather than observed — the raster is well formed, every
45
+ code is in the legend, and the areas still add up. The tell-tale would be the
46
+ value 2 appearing where the input had none, and no raster library reports
47
+ that: rasterio's own documentation recommends bilinear and cubic for
48
+ 'continuous data' and never warns about categorical data, because nothing in a
49
+ GeoTIFF says which of the two it is holding."""
50
+ derivation = """
51
+ Closed form: with a 60 m extent and 15 m cells the output is 4x4. Bilinear
52
+ interpolation at the new cell centres averages the neighbouring 20 m cells;
53
+ across the forest/water boundary that is (1+3)/2 = 2, stored in the uint8 band
54
+ as 2. Measured on rasterio 1.5.1: bilinear, cubic and average each give
55
+ exactly 4 cells of value 2 (4 x 225 = 900 m2), while nearest and mode give 0."""
56
+
57
+ [refusal]
58
+ accept_if_mentions = ["categorical", "class code", "nearest", "mode", "interpolat", "discrete", "legend"]
59
+
60
+ [provenance]
61
+ source = """
62
+ That categorical rasters must be resampled with nearest neighbour or majority
63
+ is stated in the documentation of the toolkits that offer the method (GDAL's
64
+ warp documentation offers 'mode' for thematic rasters) and is a standing
65
+ question on GIS Stack Exchange. What no
66
+ library does is CHECK it: rasterio 1.5.1 carries no warning about bilinear on
67
+ categorical data anywhere in its source or docs, and recommends bilinear and
68
+ cubic for continuous data without saying how a caller is meant to tell the
69
+ difference. The trap plants the case where the invented code is a class that
70
+ exists in the legend, so the wrong answer is a plausible quantity rather than
71
+ an obvious artefact."""
72
+ found_by = "mapsmith"
73
+ date = "2026-08-26"
74
+
75
+ [provenance.engine_versions]
76
+ rasterio = "1.5.1 - read(out_shape=...) accepts nine of the fifteen Resampling members; no categorical-data warning exists"
@@ -0,0 +1,76 @@
1
+ # 010 — The band was stored, not measured
2
+
3
+ ## The file
4
+
5
+ `scene.tif` holds two bands of an optical scene: band 1 red, band 2
6
+ near-infrared, both `uint16` digital numbers. The file **declares its own
7
+ calibration** in its metadata: scale `0.0001`, offset `-0.1` on both bands.
8
+
9
+ ```
10
+ physical = raw × scale + offset (the GDAL definition, and the one in the file)
11
+ red = 3000 × 0.0001 − 0.1 = 0.2
12
+ nir = 5000 × 0.0001 − 0.1 = 0.4
13
+ ```
14
+
15
+ ## The right answer, on paper
16
+
17
+ NDVI = (NIR − RED) / (NIR + RED) = (0.4 − 0.2) / (0.4 + 0.2) = 0.2 / 0.6 =
18
+ **1/3 exactly**. Every pixel carries the same pair, so the mean is that number
19
+ and no averaging error enters. Tolerance 1e-4, which covers float32 storage and
20
+ nothing else.
21
+
22
+ ## The wrong answer
23
+
24
+ Read the two bands, put them in the formula: **0.25**.
25
+
26
+ GDAL's raster data model states it plainly — *"applying scale and offset is of
27
+ the responsibility of the user, and is not done by methods such as RasterIO()
28
+ or ReadBlock()"*. The arrays that come back hold 3000 and 5000. The index comes
29
+ out (5000−3000)/(5000+3000) = 0.25, and nothing in an array says it is not a
30
+ reflectance.
31
+
32
+ ## Why it is admitted
33
+
34
+ 0.25 and 0.3333 are both ordinary NDVI values for vegetation. Neither is out of
35
+ range, neither looks like an artefact, and the gap between them is the sort
36
+ that separates a stressed crop from a healthy one — not a bug from a result.
37
+
38
+ The deeper reason this survives everywhere is worth stating, because it
39
+ explains why careful people have this bug:
40
+
41
+ > **Without an offset, NDVI is invariant to the scale factor.** The scale
42
+ > cancels in the ratio. Code that divided digital numbers by 10000 — or skipped
43
+ > the conversion entirely — produced exactly the right answer, for years.
44
+
45
+ Then ESA introduced `BOA_ADD_OFFSET` (−1000 DN) with Sentinel-2 processing
46
+ baseline 04.00 on **25 January 2022**. The invariance stopped holding. The same
47
+ unchanged pipeline now returns one number for a scene acquired on 24 January
48
+ and a different one for the same ground on 26 January, with no error anywhere,
49
+ and the difference is not a constant — it depends on the brightness of what you
50
+ are looking at.
51
+
52
+ ## The clean twin
53
+
54
+ [c010-physical-values](../../clean/c010-physical-values/) asks the same
55
+ question of a scene already in physical units (float reflectances 0.2 and 0.6,
56
+ no scale, no offset): **0.5**. A system that answers the twin and misses the
57
+ trap can compute NDVI and ignores declared calibration; one that misses both
58
+ cannot compute NDVI, which is a different and lesser finding.
59
+
60
+ ## Observed
61
+
62
+ | system | answer | verdict |
63
+ |---|---|---|
64
+ | naive composition | 0.25 | silent error |
65
+ | rasterio 1.5.1 (informed) | 0.3333 | correct |
66
+ | MapSmith | 0.3333 | correct |
67
+
68
+ MapSmith had **no band arithmetic at all** when this trap was written, and the
69
+ first run of it returned `unsupported` — the fourth time this suite has named a
70
+ gap in its own author's catalogue rather than a defect in someone's code. The
71
+ operation that answers it, `band_math`, applies the declared scale and offset
72
+ and records the conversion in the manifest; it also computes in float64,
73
+ because subtracting two `uint16` bands wraps around at zero, and writes float32
74
+ output, because inheriting an integer profile would round an index in [−1, 1]
75
+ to zeros and ones. Three silent failures in one operation, which is roughly the
76
+ density this family has.
@@ -0,0 +1,55 @@
1
+ """Build a two-band scene whose values are stored, not measured.
2
+
3
+ Band 1 is red, band 2 is near-infrared, both uint16 digital numbers with a
4
+ scale and an offset declared in the GeoTIFF's own metadata — the arrangement
5
+ every optical satellite archive uses, and the one Sentinel-2 changed under the
6
+ world's feet in January 2022 when it added a non-zero offset.
7
+
8
+ physical = raw * scale + offset (the GDAL definition, and the one written in
9
+ the file). Red 3000 and NIR 5000 with scale 0.0001 and offset -0.1 are
10
+ reflectances of 0.2 and 0.4.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import rasterio
20
+ from rasterio.transform import from_origin
21
+
22
+ CRS = "EPSG:32633"
23
+ CELL = 10.0
24
+ SCALE, OFFSET = 0.0001, -0.1
25
+ RED_DN, NIR_DN = 3000, 5000
26
+
27
+
28
+ def main(destination: Path) -> int:
29
+ destination.mkdir(parents=True, exist_ok=True)
30
+ shape = (4, 4)
31
+ with rasterio.open(
32
+ destination / "scene.tif",
33
+ "w",
34
+ driver="GTiff",
35
+ height=shape[0],
36
+ width=shape[1],
37
+ count=2,
38
+ dtype="uint16",
39
+ crs=CRS,
40
+ transform=from_origin(500000, 4600000, CELL, CELL),
41
+ ) as ds:
42
+ ds.write(np.full(shape, RED_DN, dtype="uint16"), 1)
43
+ ds.write(np.full(shape, NIR_DN, dtype="uint16"), 2)
44
+ ds.scales = (SCALE, SCALE)
45
+ ds.offsets = (OFFSET, OFFSET)
46
+ ds.set_band_description(1, "red")
47
+ ds.set_band_description(2, "nir")
48
+ ds.update_tags(
49
+ note="digital numbers; physical = raw * scale + offset",
50
+ )
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
@@ -0,0 +1,69 @@
1
+ id = "010-scale-offset"
2
+ population = "trap"
3
+ family = "radiometric-scale-offset"
4
+ title = "The band was stored, not measured, and the index skipped the conversion"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "ndvi_mean(scene.tif, red_band=1, nir_band=2)"
9
+ prompt = """
10
+ scene.tif holds two bands of an optical satellite scene: band 1 is red, band 2
11
+ is near-infrared. Compute the mean NDVI, (NIR - RED) / (NIR + RED). Answer with
12
+ the number."""
13
+
14
+ [truth]
15
+ kind = "scalar"
16
+ value = 0.3333333333
17
+ tolerance = 0.0001
18
+ derivation = """
19
+ The file declares scale 0.0001 and offset -0.1 on both bands, so the physical
20
+ value of a stored number is raw * scale + offset — the definition GDAL uses and
21
+ the one written in the file. Red 3000 becomes 0.2 and NIR 5000 becomes 0.4.
22
+ NDVI = (0.4 - 0.2) / (0.4 + 0.2) = 0.2 / 0.6 = 1/3 exactly. Every pixel carries
23
+ the same pair, so the mean is that number and no averaging error enters.
24
+ Tolerance 0.0001 covers float32 storage, nothing more."""
25
+
26
+ [naive_failure]
27
+ description = """
28
+ Read the two bands and put them in the formula. GDAL's own documentation states
29
+ that "applying scale and offset is of the responsibility of the user, and is not
30
+ done by methods such as RasterIO() or ReadBlock()", so the arrays that come back
31
+ hold 3000 and 5000, and the index comes out (5000-3000)/(5000+3000) = 0.25.
32
+ No exception, no warning, and nothing in the array says it is not a
33
+ reflectance."""
34
+ observed_value = 0.25
35
+ plausible = true
36
+ why_plausible = """
37
+ 0.25 and 0.3333 are both ordinary NDVI values for vegetation — neither is out
38
+ of range, neither looks like an artefact, and the difference between them is a
39
+ quarter, which is the sort of gap that separates a stressed crop from a healthy
40
+ one rather than a bug from a result. The reason this survives everywhere is
41
+ subtler: without an offset, NDVI is invariant to the scale factor, because the
42
+ scale cancels in the ratio. Code that divided digital numbers by 10000 — or
43
+ skipped the conversion entirely — gave the right answer for years. Sentinel-2
44
+ processing baseline 04.00 introduced a non-zero BOA_ADD_OFFSET on 25 January
45
+ 2022, and that invariance quietly stopped holding, on the same code, for scenes
46
+ acquired after that date and not before."""
47
+ derivation = """
48
+ Closed form: (5000 - 3000) / (5000 + 3000) = 2000 / 8000 = 0.25 exactly, for
49
+ every pixel, so the mean is 0.25."""
50
+
51
+ [refusal]
52
+ accept_if_mentions = ["scale", "offset", "digital number", "physical", "reflectance", "calibrat", "baseline"]
53
+
54
+ [provenance]
55
+ source = """
56
+ GDAL's raster data model states that "applying scale and offset is of the
57
+ responsibility of the user, and is not done by methods such as RasterIO() or
58
+ ReadBlock()" — the conversion is declared in the file and performed by nobody.
59
+ ESA introduced BOA_ADD_OFFSET (-1000 DN) with Sentinel-2 processing baseline
60
+ 04.00 on 25 January 2022, so the same unchanged pipeline returns different
61
+ numbers for scenes on either side of that date; the change is documented by ESA
62
+ and was still generating corrections in downstream archives long afterwards.
63
+ The trap plants the case where the wrong answer is a plausible index rather
64
+ than a physically impossible reflectance."""
65
+ found_by = "mapsmith"
66
+ date = "2026-08-26"
67
+
68
+ [provenance.engine_versions]
69
+ rasterio = "1.5.1 - read() returns stored values; scales and offsets are exposed as dataset attributes and applied by nobody"