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,65 @@
1
+ # 004 — Two individually valid CRS, and a count of zero that reads as a finding
2
+
3
+ ## The files
4
+
5
+ `zone.gpkg` holds one rectangle in **EPSG:4326**: longitude 12.30–12.42,
6
+ latitude 41.80–41.95. `points.gpkg` holds forty points in **EPSG:32633** — the
7
+ UTM zone this longitude band belongs to, an entirely ordinary choice for field
8
+ data. Each file on its own is impeccable: valid geometry, declared and correct
9
+ CRS. They just do not share a frame.
10
+
11
+ ## The right answer, on paper
12
+
13
+ Both layers are built from the same longitude/latitude definitions, so
14
+ containment is decided by interval comparison before any projection exists.
15
+ Twelve points sit on the grid {12.33, 12.36, 12.39} × {41.83, 41.86, 41.89,
16
+ 41.92} — inside the rectangle with at least 0.03° (≈ 2.5 km) of margin to every
17
+ edge. The other 28 sit at longitude 13.2 or greater, at least 0.78° (≈ 64 km)
18
+ east of the rectangle: outside on the longitude interval alone.
19
+
20
+ **The count is 12.** The projection step cannot move it: correct transforms
21
+ between EPSG:32633 and EPSG:4326 agree to well under a metre, and reading the
22
+ rectangle's edges as parallels and meridians or as projected chords displaces
23
+ an edge by a few metres — three orders of magnitude below the margins. An
24
+ integer with no room for legitimate disagreement, so the tolerance is 0.
25
+
26
+ ## The wrong answer
27
+
28
+ Read both files, test containment on the raw coordinates: **0**.
29
+
30
+ The points are UTM eastings and northings — every easting above 278 000 — while
31
+ the polygon spans x in [12.30, 12.42]. The predicate is false forty times out
32
+ of forty, exactly, for every algorithm, with no exception and no warning.
33
+ GeoPandas ships a dedicated UserWarning for frames that disagree
34
+ (`_crs_mismatch_warn`, in `geopandas/array.py`), which is how endemic this
35
+ class is — and it cannot fire here, because tested against a bare geometry
36
+ there is no second CRS to disagree with.
37
+
38
+ ## Why it is admitted
39
+
40
+ Zero is the most data-shaped answer a spatial question can return. "No wells
41
+ fall inside the protected zone" is a finding — often the one somebody was
42
+ hoping for — not an error signal. An empty result is a valid, well-formed
43
+ output that downstream code consumes without objection: it becomes an empty
44
+ report section, a zero in a total, a green light. The only wrong thing is that
45
+ two individually right frames were never brought into the same one.
46
+
47
+ ## Observed
48
+
49
+ | adapter | answer | |
50
+ |---|---|---|
51
+ | `engine:geopandas` — aligns the frames, then tests | 12 | ✓ |
52
+ | `adapters.mapsmith` — `spatial_join`, which reprojects and records the decision in `crs_decisions` | 12 | ✓ |
53
+ | `engine:naive` — tests containment on raw coordinates | 0 | ✗ |
54
+
55
+ Worth noting: on family 1 the naive composition passed by accident, because
56
+ rasterio undoes the predictor on its behalf. Here there is no library to be
57
+ saved by — the frames either get aligned by something that read both CRS, or
58
+ the answer is 0.
59
+
60
+ ## Clean twin
61
+
62
+ `clean/c004-points-in-polygon` — the same counting task with both layers in
63
+ EPSG:32633 and a different count (9 of 36), so the pair cannot be passed by
64
+ memorising a number. A system that answers the control and returns 0 on the
65
+ trap has told us exactly one thing: it never read the second CRS.
@@ -0,0 +1,65 @@
1
+ """Build two individually impeccable layers that never meet.
2
+
3
+ The zone is a rectangle in EPSG:4326. The points are stored in EPSG:32633 —
4
+ the UTM zone this longitude band belongs to, an entirely ordinary choice for
5
+ field data. Twelve of the forty points lie inside the rectangle, by
6
+ construction in the shared lon/lat domain; whether a consumer finds them
7
+ depends on exactly one thing: bringing the two frames together before testing
8
+ containment.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
18
+ # builds of the same data differ byte for byte unless the clock is pinned.
19
+ os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-24T00:00:00.000Z")
20
+
21
+ import geopandas as gpd
22
+ from pyproj import Transformer
23
+ from shapely.geometry import Point, Polygon
24
+
25
+ ZONE_LON = (12.30, 12.42)
26
+ ZONE_LAT = (41.80, 41.95)
27
+ # Inside: a 3x4 grid with at least 0.03 degrees of margin to every edge.
28
+ INSIDE_LON = (12.33, 12.36, 12.39)
29
+ INSIDE_LAT = (41.83, 41.86, 41.89, 41.92)
30
+ # Outside: everything at longitude 13.2 or greater — at least 0.78 degrees
31
+ # east of the zone, outside on the longitude interval alone.
32
+ OUTSIDE_LON = (13.2, 13.7, 14.2, 14.9)
33
+ OUTSIDE_LAT = (40.4, 40.9, 41.4, 42.4, 42.9, 43.4, 43.9)
34
+
35
+ ZONE_CRS = "EPSG:4326"
36
+ POINTS_CRS = "EPSG:32633" # UTM zone 33N, which covers 12-18 degrees east
37
+
38
+
39
+ def main(destination: Path) -> int:
40
+ destination.mkdir(parents=True, exist_ok=True)
41
+
42
+ rectangle = Polygon([
43
+ (ZONE_LON[0], ZONE_LAT[0]),
44
+ (ZONE_LON[1], ZONE_LAT[0]),
45
+ (ZONE_LON[1], ZONE_LAT[1]),
46
+ (ZONE_LON[0], ZONE_LAT[1]),
47
+ ])
48
+ gpd.GeoDataFrame(
49
+ {"zone_id": ["Z-1"]}, geometry=[rectangle], crs=ZONE_CRS
50
+ ).to_file(destination / "zone.gpkg", layer="zone", driver="GPKG")
51
+
52
+ lonlat = [(x, y) for x in INSIDE_LON for y in INSIDE_LAT]
53
+ lonlat += [(x, y) for x in OUTSIDE_LON for y in OUTSIDE_LAT]
54
+ forward = Transformer.from_crs(ZONE_CRS, POINTS_CRS, always_xy=True)
55
+ points = [Point(*forward.transform(x, y)) for x, y in lonlat]
56
+ gpd.GeoDataFrame(
57
+ {"well_id": [f"W-{i + 1}" for i in range(len(points))]},
58
+ geometry=points,
59
+ crs=POINTS_CRS,
60
+ ).to_file(destination / "points.gpkg", layer="points", driver="GPKG")
61
+ return 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
@@ -0,0 +1,75 @@
1
+ id = "004-mismatched-crs-join"
2
+ population = "trap"
3
+ family = "mismatched-crs"
4
+ title = "Two individually valid CRS, and a count of zero that reads as a finding"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "points_in_polygon_count(points.gpkg, zone.gpkg)"
9
+ prompt = """
10
+ How many of the points in points.gpkg fall inside the polygon in zone.gpkg?
11
+ Answer with the count."""
12
+
13
+ [truth]
14
+ kind = "count"
15
+ value = 12
16
+ tolerance = 0
17
+ derivation = """
18
+ Both layers are built from the same longitude/latitude definitions. The zone is
19
+ the rectangle [12.30, 12.42] x [41.80, 41.95] in EPSG:4326. The 40 points are
20
+ defined in longitude/latitude and stored reprojected to EPSG:32633: twelve on
21
+ the grid {12.33, 12.36, 12.39} x {41.83, 41.86, 41.89, 41.92}, inside the
22
+ rectangle by interval comparison with at least 0.03 degrees (about 2.5 km) of
23
+ margin to every edge; the other 28 at longitudes 13.2 or greater, at least 0.78
24
+ degrees (about 64 km) east of the rectangle, outside on the longitude interval
25
+ alone. Containment is therefore decided on paper, in the lon/lat domain, before
26
+ any projection exists.
27
+
28
+ The projection step cannot move the answer: correct transforms between
29
+ EPSG:32633 and EPSG:4326 agree to well under a metre, and reading the
30
+ rectangle's edges as parallels and meridians or as projected chords displaces
31
+ an edge by a few metres at most - three orders of magnitude below the margins.
32
+ The answer is an integer with no room for legitimate disagreement, so the
33
+ tolerance is 0."""
34
+
35
+ [naive_failure]
36
+ description = """
37
+ Both files are read and the containment test runs on the raw coordinates. The
38
+ points are UTM eastings and northings - every easting above 278 000, every
39
+ northing above 4 470 000 - while the polygon spans x in [12.30, 12.42] and y in
40
+ [41.80, 41.95]. No point can fall inside, so the count comes back 0: exactly,
41
+ for every algorithm, with no exception and no warning. GeoPandas ships a
42
+ dedicated UserWarning for frames that disagree, but it cannot fire here -
43
+ tested against a bare geometry, there is no second CRS to disagree with."""
44
+ observed_value = 0
45
+ plausible = true
46
+ why_plausible = """
47
+ Zero is the most data-shaped answer a spatial question can return. "No wells
48
+ fall inside the protected zone" is a finding - often the one somebody was
49
+ hoping for - and an empty result is a valid, well-formed output that downstream
50
+ code consumes without objection. Each file on its own is impeccable: valid
51
+ geometry, declared and correct CRS. The only wrong thing is that two
52
+ individually right frames were never brought into the same one."""
53
+ derivation = """
54
+ Closed form: containment in the raw coordinate plane requires
55
+ 12.30 <= x <= 12.42, and every point's x is a UTM easting greater than 278 000.
56
+ The predicate is false 40 times out of 40; the count is exactly 0."""
57
+
58
+ [refusal]
59
+ accept_if_mentions = ["crs", "coordinate reference", "spatial reference", "reproject", "projection", "32633", "4326"]
60
+
61
+ [provenance]
62
+ source = """
63
+ Endemic enough that GeoPandas carries a guard for exactly this class:
64
+ _crs_mismatch_warn in geopandas/array.py warns "CRS mismatch between the CRS of
65
+ left geometries and the CRS of right geometries" whenever two geometry arrays
66
+ disagree (verified in geopandas 1.1.4). The guard exists upstream because the
67
+ empty join from mismatched frames is the canonical first question of spatial
68
+ debugging - and the composition measured here slips past it, because a bare
69
+ geometry has no CRS to compare."""
70
+ found_by = "mapsmith"
71
+ date = "2026-08-24"
72
+
73
+ [provenance.engine_versions]
74
+ geopandas = "1.1.4 - warns on array-vs-array mismatch, silent against a bare shapely geometry"
75
+ shapely = "planar containment on raw coordinates, CRS-unaware by design"
@@ -0,0 +1,63 @@
1
+ # 005 — A self-intersecting parcel whose area is computed anyway
2
+
3
+ ## The file
4
+
5
+ `parcel.gpkg` holds one polygon in **EPSG:32632** whose ring crosses itself: a
6
+ bowtie with two unequal lobes. Nothing refuses to store it — GeoPackage writes
7
+ it, every reader reads it back unchanged, and it renders as two filled
8
+ triangles that look like an ordinary odd-shaped parcel. Invalid rings are
9
+ endemic in real cadastral and digitised data, which is why every serious
10
+ engine ships a repair function.
11
+
12
+ ## The right answer, on paper
13
+
14
+ The ring is A=(0,0), B=(0,100), C=(120,20), D=(120,80) in local metres. Edges
15
+ B→C and D→A cross at X=(75,50), splitting the figure into two triangles:
16
+
17
+ ```
18
+ (A, B, X): 100 · 75 / 2 = 3750 m²
19
+ (C, D, X): 60 · 45 / 2 = 1350 m² total: 5100 m²
20
+ ```
21
+
22
+ **5100 is the only defensible answer.** Both fill rules (even-odd and nonzero
23
+ winding) fill both lobes — which is also what any renderer draws — and
24
+ repairing the ring with `make_valid` yields exactly the two triangles as a
25
+ MultiPolygon of area 5100. A refusal that names the self-intersection is also
26
+ accepted: repairing and refusing-with-reason are both careful behaviours.
27
+
28
+ ## The wrong answer
29
+
30
+ Read the file, take `.area`, report it: **2400**.
31
+
32
+ That is the shoelace sum over the ring, in which the two lobes carry opposite
33
+ orientation and partially cancel: |3750 − 1350| = 2400 — a number that matches
34
+ no definition of a region at all. And it arrives more quietly than the
35
+ library's own manual predicts: Shapely's documentation says of self-crossing
36
+ rings that "exceptions will be raised when they are operated on" — for the
37
+ area, nothing is raised. No exception, no warning, a plausible number.
38
+
39
+ ## Why it is admitted
40
+
41
+ 2400 m² and 5100 m² are both entirely ordinary parcels. The file is well
42
+ formed, the CRS is declared and metric, the geometry renders. The only wrong
43
+ thing is that a ring crosses itself and nothing in the composition ever asked
44
+ `is_valid` — the repair function exists in every engine, and a repair function
45
+ that is never called protects nobody.
46
+
47
+ ## Observed
48
+
49
+ | adapter | answer | |
50
+ |---|---|---|
51
+ | `engine:geopandas` — checks validity, repairs with `make_valid`, **and says so** | 5100.0, `correct_with_warning` | ✓ |
52
+ | `engine:naive` — sums `.area` | 2400.0 | ✗ |
53
+
54
+ The careful adapter's verdict is worth a note: it is scored
55
+ `correct_with_warning`, not plain `correct`, because it *communicated* the
56
+ repair. Measuring after a silent repair would be trading one silence for
57
+ another.
58
+
59
+ ## Clean twin
60
+
61
+ `clean/c005-polygon-area` — a valid L-shaped parcel, same CRS, same question,
62
+ area 7600 m² by decomposition on paper. The trap and its control differ in
63
+ exactly one thing: whether the ring crosses itself.
@@ -0,0 +1,41 @@
1
+ """Build one self-intersecting parcel — a bowtie with unequal lobes.
2
+
3
+ The ring is A -> B -> C -> D -> A with two edges crossing once. Nothing refuses
4
+ to store it: GeoPackage writes it, every reader reads it back byte-identical,
5
+ and the polygon renders as two filled triangles that look like an ordinary odd
6
+ parcel. The coordinates are ordinary UTM 32N metres.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ # GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
16
+ # builds of the same data differ byte for byte unless the clock is pinned.
17
+ os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-24T00:00:00.000Z")
18
+
19
+ import geopandas as gpd
20
+ from shapely.geometry import Polygon
21
+
22
+ CRS = "EPSG:32632"
23
+ E0, N0 = 400_000.0, 5_000_000.0 # ordinary coordinates for the zone
24
+
25
+ # Local shape: A=(0,0), B=(0,100), C=(120,20), D=(120,80). Edges B->C and D->A
26
+ # cross at X=(75,50); the lobes are the triangles (A,B,X) = 3750 m2 and
27
+ # (C,D,X) = 1350 m2. Offsets do not change areas.
28
+ RING = [(0.0, 0.0), (0.0, 100.0), (120.0, 20.0), (120.0, 80.0)]
29
+
30
+
31
+ def main(destination: Path) -> int:
32
+ destination.mkdir(parents=True, exist_ok=True)
33
+ bowtie = Polygon([(E0 + x, N0 + y) for x, y in RING])
34
+ gpd.GeoDataFrame(
35
+ {"parcel_id": ["A-1"]}, geometry=[bowtie], crs=CRS
36
+ ).to_file(destination / "parcel.gpkg", layer="parcel", driver="GPKG")
37
+ return 0
38
+
39
+
40
+ if __name__ == "__main__":
41
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
@@ -0,0 +1,73 @@
1
+ id = "005-bowtie-area"
2
+ population = "trap"
3
+ family = "invalid-geometry"
4
+ title = "A self-intersecting parcel whose area is computed anyway"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "planar_area_m2(parcel.gpkg)"
9
+ prompt = """
10
+ What is the area of the parcel in parcel.gpkg, in square metres, measured in the
11
+ plane of the layer's own coordinate reference system?"""
12
+
13
+ [truth]
14
+ kind = "scalar"
15
+ value = 5100.0
16
+ tolerance = 0.01
17
+ derivation = """
18
+ The ring is A=(0,0), B=(0,100), C=(120,20), D=(120,80) in local metres
19
+ (EPSG:32632, offset 400000/5000000 - offsets do not change areas). Edges B->C
20
+ (y = 100 - 2x/3) and D->A (y = 2x/3) cross at X=(75,50). The two lobes are the
21
+ triangles (A,B,X), area 100*75/2 = 3750, and (C,D,X), area 60*45/2 = 1350. The
22
+ region the ring encloses is therefore 3750 + 1350 = 5100 square metres.
23
+
24
+ WHY 5100 IS THE ONLY CORRECT ANSWER. Every point-set reading of "the area of
25
+ this parcel" agrees: both the even-odd and the nonzero winding rule fill both
26
+ lobes, which is also what any renderer draws, and repairing the ring
27
+ (make_valid) yields exactly the two triangles as a MultiPolygon of area 5100.
28
+ The other number in play, 2400, corresponds to no definition of a region at
29
+ all - it is the signed shoelace sum, in which the two lobes carry opposite
30
+ orientation and partially cancel (see naive_failure). A refusal naming the
31
+ self-intersection is also accepted: repairing and refusing-with-reason are
32
+ both careful behaviours; reporting the artifact is the only wrong one."""
33
+
34
+ [naive_failure]
35
+ description = """
36
+ Read the file, take `.area`, report it. On a self-intersecting ring Shapely
37
+ returns the shoelace sum - lobes with opposite orientation partially cancel -
38
+ with no exception and no warning. That is quieter than the library's own
39
+ manual, which says of self-crossing rings that "exceptions will be raised when
40
+ they are operated on": for the area, nothing is raised. The answer comes back
41
+ 2400."""
42
+ observed_value = 2400.0
43
+ plausible = true
44
+ why_plausible = """
45
+ 2400 m2 is an entirely ordinary parcel, and so is the true 5100 m2 - neither
46
+ looks out of place in a report or a total. Nothing objects along the way: the
47
+ file writes and reads cleanly, the geometry renders as two filled triangles,
48
+ and the area call returns a number rather than raising. Invalid rings are
49
+ endemic in real cadastral and digitised data, which is why every serious
50
+ engine ships a repair function - and a repair function that exists but is not
51
+ called protects nobody."""
52
+ derivation = """
53
+ Closed form: the shoelace sum over A,B,C,D is 0 - 12000 + 7200 + 0 = -4800,
54
+ so the reported area is |-4800|/2 = 2400 - exactly |3750 - 1350|, the larger
55
+ lobe minus the smaller one."""
56
+
57
+ [refusal]
58
+ accept_if_mentions = ["invalid", "self-intersect", "self intersection", "bowtie", "make_valid", "not valid"]
59
+
60
+ [provenance]
61
+ source = """
62
+ Shapely manual (fetched 2026-08-24): "Operations on invalid features may
63
+ fail." and, of self-crossing rings, "Shapely will not prevent the creation of
64
+ such rings, but exceptions will be raised when they are operated on" - yet
65
+ `.area` on this ring raises nothing and returns the artifact (verified on
66
+ shapely 2.1.2). GEOS, PostGIS (ST_MakeValid) and Shapely all ship a repair
67
+ function because invalid rings are endemic in real data; this probe measures
68
+ the composition that never calls it."""
69
+ found_by = "mapsmith"
70
+ date = "2026-08-24"
71
+
72
+ [provenance.engine_versions]
73
+ shapely = "2.1.2 - .area returns the signed-shoelace artifact on a self-intersecting ring, no exception, no warning"
@@ -0,0 +1,65 @@
1
+ # 006 — A two-layer container that answers a question nobody asked
2
+
3
+ ## The file
4
+
5
+ `project.gpkg` holds two layers, both valid, both honestly named: `zones`
6
+ (4 polygons, written first — which makes it the container's **default**) and
7
+ `wells` (31 points, written second). GeoPackage is explicitly a multi-layer
8
+ format; a single project container holding zones and wells together is its
9
+ normal, documented use. Nothing about this file is unusual.
10
+
11
+ ## The right answer, on paper
12
+
13
+ The question names the layer: *how many features are in the `wells` layer?*
14
+ By construction, `wells` holds W-1 through W-31. **The count is 31**, an
15
+ integer with no room for disagreement; the tolerance is 0.
16
+
17
+ ## The wrong answer
18
+
19
+ Read the file, count the rows: **4**.
20
+
21
+ `read_file(path)` with no layer argument returns the container's default
22
+ layer — `zones`. This one is not fully mute, and the honest description says
23
+ so: pyogrio emits a UserWarning naming the default (*"More than one layer
24
+ found in 'project.gpkg': 'zones' (default), 'wells'. Specify layer parameter
25
+ to avoid this warning."*). But the warning goes to stderr, the returned frame
26
+ is perfectly well formed, and nothing in the **result** carries any trace of
27
+ it. A pipeline that logs-and-forgets — the overwhelming default, and the only
28
+ option in most agent stacks, where stderr never reaches the model — reports 4.
29
+
30
+ ## Why it is admitted
31
+
32
+ "4 wells" is an entirely ordinary count, and a count carries no fingerprint of
33
+ the layer it came from. The person who asked about wells receives a number
34
+ computed on zones, and no property of the number says so. The one signal that
35
+ exists is attached to a stream, not to a result — and a signal nothing
36
+ downstream can see is not a defence, it is a description of the defect.
37
+
38
+ ## Observed
39
+
40
+ | adapter | answer | |
41
+ |---|---|---|
42
+ | `engine:geopandas` — passes the layer the question names | 31 | ✓ |
43
+ | `engine:naive` — reads "the file" | 4 | ✗ |
44
+ | `adapters.mapsmith` — `describe_dataset`, which takes a path and nothing else | 4 | ✗ **silent error** |
45
+
46
+ The third row is the reason this probe exists, and it is about the suite's own
47
+ author: MapSmith's reader resolved a multi-layer container to its default
48
+ layer **silently** — its inspection result said `feature_count: 4` with no
49
+ warning field at all, quieter than the bare pyogrio call it wraps. Filed as
50
+ [MapSmith issue #29](https://github.com/mapsmith-ai/MapSmith/issues/29) before
51
+ this trap was published, and that number is in the published
52
+ [2026-08-25 run](../../results/2026-08-25-six-families/).
53
+
54
+ **After the fix** (MapSmith `ebea7d0`, the same day, in that order on
55
+ purpose): operations refuse a container with no chosen layer, naming the
56
+ layers; `describe_dataset` describes every layer so the caller can choose; and
57
+ this adapter's composition now answers **31, correct**. The row above is the
58
+ regression test with a date on it — both dates.
59
+
60
+ ## Clean twin
61
+
62
+ `clean/c006-named-layer` — one layer, honestly named, 17 points, same
63
+ question. With a single layer there is nothing to choose wrongly: a system
64
+ that answers the control and returns 4 on the trap has told us exactly one
65
+ thing — it never chose a layer at all.
@@ -0,0 +1,55 @@
1
+ """Build one GeoPackage holding two layers, neither of them wrong.
2
+
3
+ ``zones`` is written first (4 polygons) and therefore becomes the container's
4
+ default layer; ``wells`` (31 points) is written second. Every reader that is
5
+ handed the file without a layer name gets zones — the question the caller
6
+ asked names wells. Both layers are valid, ordinary and honest; the only trap
7
+ is that the container can answer a question nobody asked.
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 Point, Polygon
22
+
23
+ CRS = "EPSG:32632"
24
+ N_ZONES = 4
25
+ N_WELLS = 31
26
+
27
+
28
+ def main(destination: Path) -> int:
29
+ destination.mkdir(parents=True, exist_ok=True)
30
+ container = destination / "project.gpkg"
31
+
32
+ zones = gpd.GeoDataFrame(
33
+ {"zone_id": [f"Z-{i}" for i in range(1, N_ZONES + 1)]},
34
+ geometry=[
35
+ Polygon([
36
+ (i * 100.0, 0.0), (i * 100.0 + 80.0, 0.0),
37
+ (i * 100.0 + 80.0, 80.0), (i * 100.0, 80.0),
38
+ ])
39
+ for i in range(N_ZONES)
40
+ ],
41
+ crs=CRS,
42
+ )
43
+ zones.to_file(container, layer="zones", driver="GPKG")
44
+
45
+ wells = gpd.GeoDataFrame(
46
+ {"well_id": [f"W-{i}" for i in range(1, N_WELLS + 1)]},
47
+ geometry=[Point(10.0 + i * 7.0, 200.0 + (i % 5) * 11.0) for i in range(N_WELLS)],
48
+ crs=CRS,
49
+ )
50
+ wells.to_file(container, layer="wells", driver="GPKG")
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,63 @@
1
+ id = "006-default-layer"
2
+ population = "trap"
3
+ family = "ambiguous-layer"
4
+ title = "A two-layer container that answers a question nobody asked"
5
+ surface = ["engine", "agent"]
6
+
7
+ [task]
8
+ call = "feature_count(project.gpkg, layer=wells)"
9
+ prompt = """
10
+ How many features are in the `wells` layer of project.gpkg? Answer with the
11
+ count."""
12
+
13
+ [truth]
14
+ kind = "count"
15
+ value = 31
16
+ tolerance = 0
17
+ derivation = """
18
+ By construction: project.gpkg holds exactly two layers, `zones` written first
19
+ (4 polygons) and `wells` written second (31 points, W-1 through W-31). The
20
+ question names the layer, so the answer is the number of features in `wells`:
21
+ 31. An integer with no room for disagreement; the tolerance is 0."""
22
+
23
+ [naive_failure]
24
+ description = """
25
+ Read the file, count the rows: `read_file(path)` with no layer argument
26
+ returns the container's DEFAULT layer, which is `zones` — the count comes back
27
+ 4. This is not fully mute: pyogrio emits a UserWarning naming the default
28
+ ("More than one layer found... 'zones' (default)"). But the warning goes to
29
+ stderr, the returned frame is well formed, and nothing in the RESULT carries
30
+ any trace of it — a pipeline that logs-and-forgets, which is the overwhelming
31
+ default, reports 4. Measured on a system that wraps the same reader: its
32
+ inspection result said `feature_count: 4` with no warning field at all."""
33
+ observed_value = 4
34
+ plausible = true
35
+ why_plausible = """
36
+ "4 wells" is an entirely ordinary count, and nothing about the container
37
+ pushes back: both layers are valid and honestly named, the CRS is declared,
38
+ the file opens everywhere. The person who asked about wells receives a number
39
+ computed on zones, and no property of the number says so — a count carries no
40
+ fingerprint of the layer it came from. The one signal that exists is a
41
+ UserWarning on a stream nobody reads in production, attached to no result."""
42
+ derivation = """
43
+ Closed form: the default layer is the first one written into the container —
44
+ `zones`, with exactly 4 features. A bare read counts those: 4."""
45
+
46
+ [refusal]
47
+ accept_if_mentions = ["layer", "multi-layer", "multilayer", "ambiguous", "more than one", "default"]
48
+
49
+ [provenance]
50
+ source = """
51
+ The guard exists upstream because the mistake is endemic: pyogrio warns "More
52
+ than one layer found in '...': '...' (default)... Specify layer parameter to
53
+ avoid this warning" on every bare read of a multi-layer source (verified,
54
+ pyogrio 0.11/geopandas 1.1.4). GeoPackage is explicitly a multi-layer format,
55
+ and single-file project containers are its normal use. Found while designing
56
+ this probe: the reader of a provenance-first geoprocessing server swallowed
57
+ the same warning and reported the default layer's count with no trace — filed
58
+ there as issue #29 before this trap was published."""
59
+ found_by = "mapsmith"
60
+ date = "2026-08-25"
61
+
62
+ [provenance.engine_versions]
63
+ geopandas = "1.1.4/pyogrio - bare read returns the first layer and warns on stderr; the result object carries nothing"
@@ -0,0 +1,57 @@
1
+ # 007 — A distance of 500, applied in the layer's units instead of the question's
2
+
3
+ ## The file
4
+
5
+ `wells.gpkg` holds 25 wells in **EPSG:4326**. W-1 sits at (12.40, 41.90); three
6
+ wells lie within 400 metres of it, the other 21 lie kilometres away. Everything
7
+ about the file is honest — valid points, declared CRS — and so is the number
8
+ 500 in the question. What is implicit is the unit it gets applied in.
9
+
10
+ ## The right answer, on paper
11
+
12
+ In the local metric at 41.9°N (1° of latitude = 111.1 km, 1° of longitude =
13
+ 82.9 km), the three near wells sit at ~300 m, ~398 m and ~359 m from W-1; the
14
+ nearest excluded well is at 6,932 m. **The count is 3.** The margins carry the
15
+ derivation: at these distances a flat-earth approximation, a geodesic and a
16
+ UTM-planar distance agree to within centimetres, while the included/excluded
17
+ gap is three orders of magnitude wider than any method disagreement.
18
+ Tolerance 0.
19
+
20
+ ## The wrong answer
21
+
22
+ Buffer W-1 by 500, count the wells inside: **24 — every single one**.
23
+
24
+ Shapely, GeoPandas and PostGIS all buffer in the layer's own units by design,
25
+ and none of them can know the caller meant metres. A buffer of 500 *degrees*
26
+ spans longitudes −487.6 to 512.4: the whole coordinate space of the file, and
27
+ then the planet, repeatedly. No exception, no warning — the buffer is a
28
+ perfectly valid polygon, just an absurd one that nothing downstream ever looks
29
+ at. Only the count survives, and the count looks fine.
30
+
31
+ ## Why it is admitted
32
+
33
+ This family's classic demonstration — "the buffered *area* is planet-sized" —
34
+ is loud, and a loud failure belongs in an ordinary test suite. The trap plants
35
+ the quiet variant: a **count**. "24 wells within half a kilometre" is an
36
+ ordinary number for a dense urban wellfield, and a count carries no trace of
37
+ the geometry that produced it. The figure 500 is exactly what the question
38
+ said; the only implicit thing was the unit.
39
+
40
+ ## Observed
41
+
42
+ | adapter | answer | |
43
+ |---|---|---|
44
+ | `engine:geopandas` — projects to the local UTM zone, then measures | 3 | ✓ |
45
+ | `adapters.mapsmith` — `buffer_layer`, whose contract is metres (auto-UTM on geographic layers, decision recorded), then a within-join | 3 | ✓ |
46
+ | `engine:naive` — buffers 500 in the layer's units | 24 | ✗ |
47
+
48
+ The MapSmith pass is earned the same way family 4's was: not by a library that
49
+ happens to save the caller, but by a tool contract that names the unit
50
+ (*metres, always*) and records the reprojection decision in the manifest.
51
+
52
+ ## Clean twin
53
+
54
+ `clean/c007-distance-in-metres` — the same question on a layer already in
55
+ EPSG:32633: five wells within 460 m (offsets exact by Pythagoras), the rest at
56
+ 2.5 km or more. The trap and its control differ in exactly one thing: whether
57
+ the layer's unit is the one the question uses.
@@ -0,0 +1,42 @@
1
+ """Build a wells layer in EPSG:4326 where "within 500 metres" is the question.
2
+
3
+ W-1 sits at (12.40, 41.90). Three wells lie within 400 metres of it; the other
4
+ 21 lie kilometres away. The layer is honest — valid points, declared CRS — and
5
+ the number 500 is honest too. What is implicit is the unit it gets applied in.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ # GeoPackage stamps the write time into `gpkg_contents.last_change`, so two
15
+ # builds of the same data differ byte for byte unless the clock is pinned.
16
+ os.environ.setdefault("OGR_CURRENT_DATE", "2026-08-25T00:00:00.000Z")
17
+
18
+ import geopandas as gpd
19
+ from shapely.geometry import Point
20
+
21
+ CRS = "EPSG:4326"
22
+ LON, LAT = 12.40, 41.90 # W-1
23
+
24
+ # ~300 m north, ~398 m east, ~359 m south-west of W-1 (local metric at 41.9N).
25
+ INSIDE = [(LON, LAT + 0.0027), (LON + 0.0048, LAT), (LON - 0.0036, LAT - 0.0018)]
26
+ # Everything else at least ~4 km away (>= 0.05 degrees in both axes).
27
+ OUTSIDE = [(LON + 0.05 + 0.01 * i, LAT + 0.05 + 0.007 * (i % 7)) for i in range(21)]
28
+
29
+
30
+ def main(destination: Path) -> int:
31
+ destination.mkdir(parents=True, exist_ok=True)
32
+ points = [Point(LON, LAT)] + [Point(*c) for c in INSIDE] + [Point(*c) for c in OUTSIDE]
33
+ gpd.GeoDataFrame(
34
+ {"well_id": [f"W-{i + 1}" for i in range(len(points))]},
35
+ geometry=points,
36
+ crs=CRS,
37
+ ).to_file(destination / "wells.gpkg", layer="wells", driver="GPKG")
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))