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.
- adapters/__init__.py +0 -0
- adapters/engine_geopandas.py +176 -0
- adapters/engine_naive.py +220 -0
- adapters/engine_rasterio.py +78 -0
- adapters/engine_whitebox.py +34 -0
- adapters/gis_mcp.py +192 -0
- adapters/mapsmith.py +496 -0
- argleton/__init__.py +3 -0
- argleton/model.py +202 -0
- argleton/probes/clean/c001-raster-mean/build.py +36 -0
- argleton/probes/clean/c001-raster-mean/probe.toml +25 -0
- argleton/probes/clean/c002-projected-area/build.py +44 -0
- argleton/probes/clean/c002-projected-area/probe.toml +25 -0
- argleton/probes/clean/c003-raster-mean-nodata/build.py +35 -0
- argleton/probes/clean/c003-raster-mean-nodata/probe.toml +26 -0
- argleton/probes/clean/c004-points-in-polygon/build.py +60 -0
- argleton/probes/clean/c004-points-in-polygon/probe.toml +29 -0
- argleton/probes/clean/c005-polygon-area/build.py +38 -0
- argleton/probes/clean/c005-polygon-area/probe.toml +26 -0
- argleton/probes/clean/c006-named-layer/build.py +35 -0
- argleton/probes/clean/c006-named-layer/probe.toml +26 -0
- argleton/probes/clean/c007-distance-in-metres/build.py +42 -0
- argleton/probes/clean/c007-distance-in-metres/probe.toml +27 -0
- argleton/probes/clean/c008-equal-area-crs/build.py +39 -0
- argleton/probes/clean/c008-equal-area-crs/probe.toml +37 -0
- argleton/probes/clean/c009-native-resolution-classes/build.py +51 -0
- argleton/probes/clean/c009-native-resolution-classes/probe.toml +31 -0
- argleton/probes/clean/c010-physical-values/build.py +49 -0
- argleton/probes/clean/c010-physical-values/probe.toml +30 -0
- argleton/probes/clean/c011-solid-parcel/build.py +52 -0
- argleton/probes/clean/c011-solid-parcel/probe.toml +28 -0
- argleton/probes/clean/c012-disjoint-concessions/build.py +56 -0
- argleton/probes/clean/c012-disjoint-concessions/probe.toml +28 -0
- argleton/probes/clean/c013-flat-pipeline/build.py +42 -0
- argleton/probes/clean/c013-flat-pipeline/probe.toml +28 -0
- argleton/probes/clean/c014-convex-parcel/build.py +70 -0
- argleton/probes/clean/c014-convex-parcel/probe.toml +28 -0
- argleton/probes/clean/c015-wells-off-the-seam/build.py +73 -0
- argleton/probes/clean/c015-wells-off-the-seam/probe.toml +28 -0
- argleton/probes/clean/c016-decimal-degrees/build.py +29 -0
- argleton/probes/clean/c016-decimal-degrees/probe.toml +27 -0
- argleton/probes/clean/c017-equal-populations/build.py +60 -0
- argleton/probes/clean/c017-equal-populations/probe.toml +28 -0
- argleton/probes/clean/c018-plain-keys/build.py +59 -0
- argleton/probes/clean/c018-plain-keys/probe.toml +28 -0
- argleton/probes/clean/c019-fully-contained/build.py +64 -0
- argleton/probes/clean/c019-fully-contained/probe.toml +28 -0
- argleton/probes/clean/c020-one-owner-each/build.py +55 -0
- argleton/probes/clean/c020-one-owner-each/probe.toml +27 -0
- argleton/probes/clean/c021-greenwich-variant/build.py +41 -0
- argleton/probes/clean/c021-greenwich-variant/probe.toml +41 -0
- argleton/probes/schema/probe.schema.json +138 -0
- argleton/probes/schema/result.schema.json +103 -0
- argleton/probes/traps/001-tiff-predictor/README.md +75 -0
- argleton/probes/traps/001-tiff-predictor/build.py +58 -0
- argleton/probes/traps/001-tiff-predictor/probe.toml +57 -0
- argleton/probes/traps/002-feet-as-metres/README.md +79 -0
- argleton/probes/traps/002-feet-as-metres/build.py +44 -0
- argleton/probes/traps/002-feet-as-metres/probe.toml +70 -0
- argleton/probes/traps/003-nodata-in-statistics/README.md +61 -0
- argleton/probes/traps/003-nodata-in-statistics/build.py +51 -0
- argleton/probes/traps/003-nodata-in-statistics/probe.toml +62 -0
- argleton/probes/traps/004-mismatched-crs-join/README.md +65 -0
- argleton/probes/traps/004-mismatched-crs-join/build.py +65 -0
- argleton/probes/traps/004-mismatched-crs-join/probe.toml +75 -0
- argleton/probes/traps/005-bowtie-area/README.md +63 -0
- argleton/probes/traps/005-bowtie-area/build.py +41 -0
- argleton/probes/traps/005-bowtie-area/probe.toml +73 -0
- argleton/probes/traps/006-default-layer/README.md +65 -0
- argleton/probes/traps/006-default-layer/build.py +55 -0
- argleton/probes/traps/006-default-layer/probe.toml +63 -0
- argleton/probes/traps/007-buffer-in-degrees/README.md +57 -0
- argleton/probes/traps/007-buffer-in-degrees/build.py +42 -0
- argleton/probes/traps/007-buffer-in-degrees/probe.toml +64 -0
- argleton/probes/traps/008-web-mercator-area/README.md +55 -0
- argleton/probes/traps/008-web-mercator-area/build.py +39 -0
- argleton/probes/traps/008-web-mercator-area/probe.toml +68 -0
- argleton/probes/traps/009-resampled-classes/README.md +88 -0
- argleton/probes/traps/009-resampled-classes/build.py +46 -0
- argleton/probes/traps/009-resampled-classes/probe.toml +76 -0
- argleton/probes/traps/010-scale-offset/README.md +76 -0
- argleton/probes/traps/010-scale-offset/build.py +55 -0
- argleton/probes/traps/010-scale-offset/probe.toml +69 -0
- argleton/probes/traps/011-polygon-holes/README.md +47 -0
- argleton/probes/traps/011-polygon-holes/build.py +55 -0
- argleton/probes/traps/011-polygon-holes/probe.toml +59 -0
- argleton/probes/traps/012-double-counting/README.md +40 -0
- argleton/probes/traps/012-double-counting/build.py +58 -0
- argleton/probes/traps/012-double-counting/probe.toml +52 -0
- argleton/probes/traps/013-z-dimension/README.md +42 -0
- argleton/probes/traps/013-z-dimension/build.py +43 -0
- argleton/probes/traps/013-z-dimension/probe.toml +53 -0
- argleton/probes/traps/014-centroid-outside/README.md +42 -0
- argleton/probes/traps/014-centroid-outside/build.py +72 -0
- argleton/probes/traps/014-centroid-outside/probe.toml +59 -0
- argleton/probes/traps/015-boundary-semantics/README.md +41 -0
- argleton/probes/traps/015-boundary-semantics/build.py +74 -0
- argleton/probes/traps/015-boundary-semantics/probe.toml +57 -0
- argleton/probes/traps/016-coordinate-parsing/README.md +40 -0
- argleton/probes/traps/016-coordinate-parsing/build.py +36 -0
- argleton/probes/traps/016-coordinate-parsing/probe.toml +52 -0
- argleton/probes/traps/017-aggregation-weighting/README.md +42 -0
- argleton/probes/traps/017-aggregation-weighting/build.py +62 -0
- argleton/probes/traps/017-aggregation-weighting/probe.toml +53 -0
- argleton/probes/traps/018-join-key-typing/README.md +41 -0
- argleton/probes/traps/018-join-key-typing/build.py +61 -0
- argleton/probes/traps/018-join-key-typing/probe.toml +53 -0
- argleton/probes/traps/019-partial-overlap/README.md +43 -0
- argleton/probes/traps/019-partial-overlap/build.py +66 -0
- argleton/probes/traps/019-partial-overlap/probe.toml +53 -0
- argleton/probes/traps/020-join-cardinality/README.md +42 -0
- argleton/probes/traps/020-join-cardinality/build.py +60 -0
- argleton/probes/traps/020-join-cardinality/probe.toml +51 -0
- argleton/probes/traps/021-ballpark-datum/README.md +100 -0
- argleton/probes/traps/021-ballpark-datum/build.py +44 -0
- argleton/probes/traps/021-ballpark-datum/probe.toml +92 -0
- argleton/published.py +49 -0
- argleton/run.py +170 -0
- argleton/score.py +150 -0
- argleton-0.1.0.dist-info/METADATA +275 -0
- argleton-0.1.0.dist-info/RECORD +124 -0
- argleton-0.1.0.dist-info/WHEEL +4 -0
- argleton-0.1.0.dist-info/entry_points.txt +2 -0
- argleton-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 011 — The courtyard added instead of subtracted
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`parcel.gpkg` holds one parcel with an internal courtyard: an outer ring of
|
|
6
|
+
100 × 100 m and an inner ring of 40 × 40, in EPSG:32632 near Milan.
|
|
7
|
+
|
|
8
|
+
## The right answer, on paper
|
|
9
|
+
|
|
10
|
+
10000 − 1600 = **8400 m²**. Both rings are exact by construction, so the
|
|
11
|
+
subtraction is the only arithmetic involved and the answer has no decimals.
|
|
12
|
+
|
|
13
|
+
## The wrong answer
|
|
14
|
+
|
|
15
|
+
Read the rings as separate polygons and add them: **11600 m²**.
|
|
16
|
+
|
|
17
|
+
The shapefile format encodes holes only by ring winding order, with no
|
|
18
|
+
explicit structure, so a converted file whose inner ring is wound the wrong
|
|
19
|
+
way loses its hole with no error at all — and a reader that iterates rings
|
|
20
|
+
does the same to a file that is perfectly well formed.
|
|
21
|
+
|
|
22
|
+
## Why it is admitted
|
|
23
|
+
|
|
24
|
+
1.16 ha against 0.84 ha are both ordinary parcels, the ratio 1.38 is not a
|
|
25
|
+
suspicious number, and nothing downstream questions a buildable area. The
|
|
26
|
+
courtyard is the part you cannot build on, so getting its sign wrong is worth
|
|
27
|
+
twice its area — and the result is still a plausible planning figure.
|
|
28
|
+
|
|
29
|
+
## The clean twin
|
|
30
|
+
|
|
31
|
+
[c011-solid-parcel](../../clean/c011-solid-parcel/) is the same parcel with no courtyard: 10000 m², and no subtraction to
|
|
32
|
+
get wrong.
|
|
33
|
+
|
|
34
|
+
## Observed
|
|
35
|
+
|
|
36
|
+
| system | answer | verdict |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| naive composition | 11600.0 | silent error |
|
|
39
|
+
| MapSmith | 8400.0 | correct |
|
|
40
|
+
|
|
41
|
+
**This trap found a real defect in MapSmith, the day after the operation it
|
|
42
|
+
tests had shipped.** `measure_area`'s geodesic path took `abs()` of the whole
|
|
43
|
+
geometry, and `Geod.geometry_area_perimeter` returns a signed value whose sign
|
|
44
|
+
follows ring orientation — so the courtyard was added on the ellipsoid while
|
|
45
|
+
the planar path subtracted it: 11609 against 8400. What noticed was the
|
|
46
|
+
distortion check comparing the two, which is the second-order reason for
|
|
47
|
+
having it. Fixed, and pinned by a closed-form test.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Build a parcel with a courtyard: one outer ring, one inner ring.
|
|
2
|
+
|
|
3
|
+
The outer ring is 100 x 100 = 10000 m2 exactly and the courtyard 40 x 40 =
|
|
4
|
+
1600 m2 exactly, so the buildable area is 8400 m2 by subtraction and by
|
|
5
|
+
nothing else. Read the inner ring as a second outer ring and the same two
|
|
6
|
+
numbers add to 11600 instead.
|
|
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-26T00:00:00.000Z")
|
|
18
|
+
|
|
19
|
+
import geopandas as gpd
|
|
20
|
+
from shapely.geometry import Polygon
|
|
21
|
+
|
|
22
|
+
CRS = "EPSG:32632"
|
|
23
|
+
# The fixture sits where it says it is: EPSG:32632 coordinates near Milan
|
|
24
|
+
# rather than at (0, 0), which in that CRS is 500 km west of the central
|
|
25
|
+
# meridian and on the equator — a point in the Atlantic where the projection
|
|
26
|
+
# distorts area by 27%. A translation changes no area, no length and no count,
|
|
27
|
+
# so every truth in this family is unaffected; what changes is that the file
|
|
28
|
+
# stops implying something false.
|
|
29
|
+
EAST, NORTH = 500_000.0, 5_030_000.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rect(x0: float, y0: float, x1: float, y1: float) -> Polygon:
|
|
35
|
+
"""A rectangle in local metres, placed at the fixture's real-world origin."""
|
|
36
|
+
return Polygon([
|
|
37
|
+
(EAST + x0, NORTH + y0), (EAST + x1, NORTH + y0),
|
|
38
|
+
(EAST + x1, NORTH + y1), (EAST + x0, NORTH + y1),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(destination: Path) -> int:
|
|
43
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
gpd.GeoDataFrame(
|
|
45
|
+
{"parcel_id": ["P-1"]},
|
|
46
|
+
geometry=[
|
|
47
|
+
Polygon(rect(0, 0, 100, 100).exterior, [rect(30, 30, 70, 70).exterior])
|
|
48
|
+
],
|
|
49
|
+
crs=CRS,
|
|
50
|
+
).to_file(destination / "parcel.gpkg", layer="parcel", 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,59 @@
|
|
|
1
|
+
id = "011-polygon-holes"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "polygon-holes"
|
|
4
|
+
title = "The courtyard added instead of subtracted"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "buildable_area_m2(parcel.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
parcel.gpkg holds one parcel with an internal courtyard. How many square
|
|
11
|
+
metres can be built on? Answer with the number."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "scalar"
|
|
15
|
+
value = 8400.0
|
|
16
|
+
tolerance = 0.01
|
|
17
|
+
derivation = """
|
|
18
|
+
The outer ring is a 100 x 100 square, 10000 m2 exactly; the inner ring is
|
|
19
|
+
40 x 40, 1600 m2 exactly. A polygon with a hole covers the difference:
|
|
20
|
+
10000 - 1600 = 8400 m2. Every number here comes from the fixture's own
|
|
21
|
+
definition, not from a measurement."""
|
|
22
|
+
|
|
23
|
+
[naive_failure]
|
|
24
|
+
description = """
|
|
25
|
+
Read the rings as two separate polygons — or as the parts of a multipolygon
|
|
26
|
+
— and add their areas: 10000 + 1600 = 11600. The shapefile format encodes
|
|
27
|
+
holes only by ring winding order, with no explicit structure, so a converted
|
|
28
|
+
file whose inner ring is wound the wrong way loses its hole with no error at
|
|
29
|
+
all; and a naive reader that iterates rings does the same to a file that is
|
|
30
|
+
perfectly well formed."""
|
|
31
|
+
observed_value = 11600.0
|
|
32
|
+
plausible = true
|
|
33
|
+
why_plausible = """
|
|
34
|
+
1.16 ha against 0.84 ha are both ordinary parcels, the ratio 1.38 is not a
|
|
35
|
+
suspicious number, and nothing downstream questions a buildable area. The
|
|
36
|
+
courtyard is the part of the parcel you cannot build on, so getting its sign
|
|
37
|
+
wrong is worth exactly twice its area — and the result is still a plausible
|
|
38
|
+
planning figure."""
|
|
39
|
+
derivation = """
|
|
40
|
+
Closed form: 10000 + 1600 = 11600, the two rings summed instead of
|
|
41
|
+
subtracted."""
|
|
42
|
+
|
|
43
|
+
[refusal]
|
|
44
|
+
accept_if_mentions = ["hole", "interior ring", "courtyard", "winding", "donut"]
|
|
45
|
+
|
|
46
|
+
[provenance]
|
|
47
|
+
source = """
|
|
48
|
+
The shapefile specification encodes holes with ring winding order
|
|
49
|
+
(outer rings clockwise, inner rings counter-clockwise) and no explicit
|
|
50
|
+
structure, so a file whose rings are wound wrongly has no holes and raises
|
|
51
|
+
nothing; GDAL documents the driver and its tolerances
|
|
52
|
+
(https://gdal.org/en/stable/drivers/vector/shapefile.html, read 2026-08-26).
|
|
53
|
+
The trap plants the case where the wrong answer is an ordinary parcel rather
|
|
54
|
+
than an obvious artefact."""
|
|
55
|
+
found_by = "mapsmith"
|
|
56
|
+
date = "2026-08-26"
|
|
57
|
+
|
|
58
|
+
[provenance.engine_versions]
|
|
59
|
+
geopandas = "1.1.4/shapely 2.1.2"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 012 — Overlapping concessions added instead of united
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`overlap.gpkg` holds two licence areas of 10000 m² each. The second starts
|
|
6
|
+
60 m into the first, so they share a 40 × 100 m strip.
|
|
7
|
+
|
|
8
|
+
## The right answer, on paper
|
|
9
|
+
|
|
10
|
+
Inclusion–exclusion: 10000 + 10000 − 4000 = **16000 m²** of ground. The strip
|
|
11
|
+
is counted once because it is one piece of ground, whatever the number of
|
|
12
|
+
licences over it.
|
|
13
|
+
|
|
14
|
+
## The wrong answer
|
|
15
|
+
|
|
16
|
+
Sum the area column: **20000 m²**. It is the natural way to answer 'how much
|
|
17
|
+
in total', and it is right whenever the features do not overlap — which is
|
|
18
|
+
most of the time, which is why the habit survives.
|
|
19
|
+
|
|
20
|
+
## Why it is admitted
|
|
21
|
+
|
|
22
|
+
2 ha against 1.6 ha are both ordinary concession totals and nothing in the
|
|
23
|
+
result hints at a shared strip. The documented real-world case is mosaicked
|
|
24
|
+
satellite tiles, which overlap by construction: adding their footprints
|
|
25
|
+
inflates every coverage figure by the overlap, silently.
|
|
26
|
+
|
|
27
|
+
## The clean twin
|
|
28
|
+
|
|
29
|
+
[c012-disjoint-concessions](../../clean/c012-disjoint-concessions/) has the same two squares 60 m apart. Sum and union agree at 20000, so a
|
|
30
|
+
system that adds areas answers it correctly.
|
|
31
|
+
|
|
32
|
+
## Observed
|
|
33
|
+
|
|
34
|
+
| system | answer | verdict |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| naive composition | 20000.0 | silent error |
|
|
37
|
+
| MapSmith | 16000.0 | correct |
|
|
38
|
+
|
|
39
|
+
Second probe of this family: [019](../019-partial-overlap/), where the unit of
|
|
40
|
+
measurement quietly changes from the square metre to the parcel.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Build two overlapping concessions.
|
|
2
|
+
|
|
3
|
+
Each is 100 x 100 = 10000 m2; the second starts at x = 60, so they share a
|
|
4
|
+
40 x 100 = 4000 m2 strip. The ground they cover between them is
|
|
5
|
+
10000 + 10000 - 4000 = 16000 m2. Adding the two areas gives 20000 and counts
|
|
6
|
+
the shared strip twice.
|
|
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-26T00:00:00.000Z")
|
|
18
|
+
|
|
19
|
+
import geopandas as gpd
|
|
20
|
+
from shapely.geometry import Polygon
|
|
21
|
+
|
|
22
|
+
CRS = "EPSG:32632"
|
|
23
|
+
# The fixture sits where it says it is: EPSG:32632 coordinates near Milan
|
|
24
|
+
# rather than at (0, 0), which in that CRS is 500 km west of the central
|
|
25
|
+
# meridian and on the equator — a point in the Atlantic where the projection
|
|
26
|
+
# distorts area by 27%. A translation changes no area, no length and no count,
|
|
27
|
+
# so every truth in this family is unaffected; what changes is that the file
|
|
28
|
+
# stops implying something false.
|
|
29
|
+
EAST, NORTH = 500_000.0, 5_030_000.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def rect(x0: float, y0: float, x1: float, y1: float) -> Polygon:
|
|
35
|
+
"""A rectangle in local metres, placed at the fixture's real-world origin."""
|
|
36
|
+
return Polygon([
|
|
37
|
+
(EAST + x0, NORTH + y0),
|
|
38
|
+
(EAST + x1, NORTH + y0),
|
|
39
|
+
(EAST + x1, NORTH + y1),
|
|
40
|
+
(EAST + x0, NORTH + y1),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(destination: Path) -> int:
|
|
45
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
gpd.GeoDataFrame(
|
|
47
|
+
{"licence": ["L-1", "L-2"], "area_m2": [10000.0, 10000.0]},
|
|
48
|
+
geometry=[
|
|
49
|
+
rect(0, 0, 100, 100),
|
|
50
|
+
rect(60, 0, 160, 100),
|
|
51
|
+
],
|
|
52
|
+
crs=CRS,
|
|
53
|
+
).to_file(destination / "overlap.gpkg", layer="licences", driver="GPKG")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
id = "012-double-counting"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "double-counting"
|
|
4
|
+
title = "Overlapping concessions added instead of united"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "total_ground_area_m2(overlap.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
The two concessions in overlap.gpkg overlap in part. How much ground do they
|
|
11
|
+
cover in total, in square metres? Answer with the number."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "scalar"
|
|
15
|
+
value = 16000.0
|
|
16
|
+
tolerance = 0.01
|
|
17
|
+
derivation = """
|
|
18
|
+
Two 100 x 100 squares, 10000 m2 each; the second starts at x = 60, so they
|
|
19
|
+
share a 40 x 100 = 4000 m2 strip. Inclusion-exclusion: 10000 + 10000 - 4000
|
|
20
|
+
= 16000 m2 of ground. The strip is counted once because it is one piece of
|
|
21
|
+
ground, whatever the number of licences over it."""
|
|
22
|
+
|
|
23
|
+
[naive_failure]
|
|
24
|
+
description = """
|
|
25
|
+
Sum the area column, or sum the geometries' areas: 20000. It is the natural
|
|
26
|
+
way to answer 'how much in total', and it is right whenever the features do
|
|
27
|
+
not overlap — which is most of the time, which is why the habit survives."""
|
|
28
|
+
observed_value = 20000.0
|
|
29
|
+
plausible = true
|
|
30
|
+
why_plausible = """
|
|
31
|
+
2 ha against 1.6 ha are both ordinary concession totals, nothing in the
|
|
32
|
+
result hints at a shared strip, and the sum is what the question sounds like
|
|
33
|
+
it is asking for. The documented real-world case is mosaicked satellite
|
|
34
|
+
tiles, which overlap by construction: adding their footprints inflates every
|
|
35
|
+
coverage figure by the overlap, silently."""
|
|
36
|
+
derivation = """
|
|
37
|
+
Closed form: 10000 + 10000 = 20000, the shared 4000 m2 counted twice."""
|
|
38
|
+
|
|
39
|
+
[refusal]
|
|
40
|
+
accept_if_mentions = ["union", "overlap", "double", "dissolve", "intersection"]
|
|
41
|
+
|
|
42
|
+
[provenance]
|
|
43
|
+
source = """
|
|
44
|
+
The sum-versus-union distinction is the textbook case for ST_Union in the
|
|
45
|
+
PostGIS manual (https://postgis.net/docs/ST_Union.html), which exists
|
|
46
|
+
precisely so that ST_Area(ST_Union(...)) can be written. The trap uses a
|
|
47
|
+
single overlap so the wrong answer is 25% high rather than absurd."""
|
|
48
|
+
found_by = "mapsmith"
|
|
49
|
+
date = "2026-08-26"
|
|
50
|
+
|
|
51
|
+
[provenance.engine_versions]
|
|
52
|
+
geopandas = "1.1.4/shapely 2.1.2"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 013 — The pipe measured in plan view
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`pipeline.gpkg` holds one pipe running from (0, 0, 0) to (400, 0, 300) in
|
|
6
|
+
local metres: 400 m across the ground and 300 m up.
|
|
7
|
+
|
|
8
|
+
## The right answer, on paper
|
|
9
|
+
|
|
10
|
+
√(400² + 300²) = **500 m** exactly — a 3-4-5 triangle, chosen so the answer
|
|
11
|
+
has no decimals to argue about.
|
|
12
|
+
|
|
13
|
+
## The wrong answer
|
|
14
|
+
|
|
15
|
+
Take the length: **400 m**.
|
|
16
|
+
|
|
17
|
+
In PostGIS the error is literally the name of the function — `ST_Length` is
|
|
18
|
+
2D, `ST_3DLength` is 3D — and in Shapely `.length` ignores Z without saying
|
|
19
|
+
so. The geometry carries its elevations the whole time; the measurement
|
|
20
|
+
drops them.
|
|
21
|
+
|
|
22
|
+
## Why it is admitted
|
|
23
|
+
|
|
24
|
+
400 m of pipe is an ordinary quantity, and 20% short is invisible to every
|
|
25
|
+
downstream check: it becomes a bill of materials, a cost estimate, a
|
|
26
|
+
procurement order. Nothing in the number says a dimension was dropped.
|
|
27
|
+
|
|
28
|
+
## The clean twin
|
|
29
|
+
|
|
30
|
+
[c013-flat-pipeline](../../clean/c013-flat-pipeline/) is the same pipe on flat ground: 3D and 2D agree at 500 m, so a system
|
|
31
|
+
that ignores Z answers it correctly.
|
|
32
|
+
|
|
33
|
+
## Observed
|
|
34
|
+
|
|
35
|
+
| system | answer | verdict |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| naive composition | 400.0 | silent error |
|
|
38
|
+
| MapSmith | 500.0 | correct |
|
|
39
|
+
|
|
40
|
+
MapSmith returned `unsupported` the first time this ran — it had no length
|
|
41
|
+
operation at all. `measure_length(method='3d')` is the answer, and a flat
|
|
42
|
+
measurement on 3D geometry now comes back with the 3D length beside it.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Build a pipeline that climbs.
|
|
2
|
+
|
|
3
|
+
One line from (0, 0, 0) to (400, 0, 300): 400 m across and 300 m up, so
|
|
4
|
+
500 m of pipe by Pythagoras — a 3-4-5 triangle, chosen so the answer is an
|
|
5
|
+
integer. The plan view is 400 m, which is what a 2D length returns.
|
|
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-26T00:00:00.000Z")
|
|
17
|
+
|
|
18
|
+
import geopandas as gpd
|
|
19
|
+
from shapely.geometry import LineString
|
|
20
|
+
|
|
21
|
+
CRS = "EPSG:32632"
|
|
22
|
+
# The fixture sits where it says it is: EPSG:32632 coordinates near Milan
|
|
23
|
+
# rather than at (0, 0), which in that CRS is 500 km west of the central
|
|
24
|
+
# meridian and on the equator — a point in the Atlantic where the projection
|
|
25
|
+
# distorts area by 27%. A translation changes no area, no length and no count,
|
|
26
|
+
# so every truth in this family is unaffected; what changes is that the file
|
|
27
|
+
# stops implying something false.
|
|
28
|
+
EAST, NORTH = 500_000.0, 5_030_000.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(destination: Path) -> int:
|
|
33
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
gpd.GeoDataFrame(
|
|
35
|
+
{"pipe_id": ["PL-1"]},
|
|
36
|
+
geometry=[LineString([(EAST + 0, NORTH + 0, 0), (EAST + 400, NORTH + 0, 300)])],
|
|
37
|
+
crs=CRS,
|
|
38
|
+
).to_file(destination / "pipeline.gpkg", layer="pipeline", driver="GPKG")
|
|
39
|
+
return 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
id = "013-z-dimension"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "z-dimension"
|
|
4
|
+
title = "The pipe measured in plan view"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "pipe_length_m(pipeline.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
pipeline.gpkg holds a pipe with elevations. How many metres of pipe are
|
|
11
|
+
needed? Answer with the number."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "scalar"
|
|
15
|
+
value = 500.0
|
|
16
|
+
tolerance = 0.01
|
|
17
|
+
derivation = """
|
|
18
|
+
The line runs from (0, 0, 0) to (400, 0, 300): 400 m horizontally and 300 m
|
|
19
|
+
vertically, so its true length is sqrt(400^2 + 300^2) = 500 m exactly — a
|
|
20
|
+
3-4-5 triangle, chosen so the answer has no decimals to argue about."""
|
|
21
|
+
|
|
22
|
+
[naive_failure]
|
|
23
|
+
description = """
|
|
24
|
+
Take the length: 400. In PostGIS the error is literally the name of the
|
|
25
|
+
function — ST_Length is 2D and ST_3DLength is 3D — and in Shapely the .length
|
|
26
|
+
property ignores Z without saying so. The geometry carries its Z the whole
|
|
27
|
+
time; the measurement drops it."""
|
|
28
|
+
observed_value = 400.0
|
|
29
|
+
plausible = true
|
|
30
|
+
why_plausible = """
|
|
31
|
+
400 m of pipe is an ordinary quantity and 20% short is not visible in any
|
|
32
|
+
downstream check: it becomes a bill of materials, a cost estimate, a
|
|
33
|
+
procurement order. Nothing in the number says a dimension was dropped, and
|
|
34
|
+
the file still has its elevations for anyone who looks."""
|
|
35
|
+
derivation = """
|
|
36
|
+
Closed form: the planar distance from (0, 0) to (400, 0) is 400 exactly."""
|
|
37
|
+
|
|
38
|
+
[refusal]
|
|
39
|
+
accept_if_mentions = ["3D", "z", "elevation", "vertical", "ST_3DLength"]
|
|
40
|
+
|
|
41
|
+
[provenance]
|
|
42
|
+
source = """
|
|
43
|
+
PostGIS documents both: ST_3DLength 'returns the 3-dimensional or
|
|
44
|
+
2-dimensional length of the geometry [...] useful for the true length of a
|
|
45
|
+
pipe with elevation changes' (https://postgis.net/docs/ST_3DLength.html) and
|
|
46
|
+
ST_Length 'returns the 2D length'
|
|
47
|
+
(https://postgis.net/docs/ST_Length.html), both read 2026-08-26. The
|
|
48
|
+
distinction is documented and the default is the wrong one for a pipe."""
|
|
49
|
+
found_by = "mapsmith"
|
|
50
|
+
date = "2026-08-26"
|
|
51
|
+
|
|
52
|
+
[provenance.engine_versions]
|
|
53
|
+
geopandas = "1.1.4/shapely 2.1.2"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 014 — The parcel located by a point that is not on it
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`parcel.gpkg` holds an L-shaped parcel: a 100 × 20 bar plus a 20 × 80 upright,
|
|
6
|
+
3600 m². `districts.gpkg` holds district A — exactly that L — and district B,
|
|
7
|
+
the notch the L does not occupy.
|
|
8
|
+
|
|
9
|
+
## The right answer, on paper
|
|
10
|
+
|
|
11
|
+
Every point of the parcel is in **A**. That is what containment means, and
|
|
12
|
+
it is what the question asks.
|
|
13
|
+
|
|
14
|
+
## The wrong answer
|
|
15
|
+
|
|
16
|
+
Reduce the parcel to its centroid and ask which district contains it: **B**.
|
|
17
|
+
|
|
18
|
+
The composite centroid is at x = (2000·50 + 1600·10)/3600 = 32.22 and
|
|
19
|
+
y = (2000·10 + 1600·60)/3600 = 32.22 — a point in the notch, on no part of
|
|
20
|
+
the parcel at all.
|
|
21
|
+
|
|
22
|
+
## Why it is admitted
|
|
23
|
+
|
|
24
|
+
A district name is the least suspicious answer form there is: no magnitude to
|
|
25
|
+
sanity-check, no units, no precision. L-shaped, U-shaped and crescent parcels
|
|
26
|
+
are ordinary — a plot around a courtyard, a municipality wrapping a bay — and
|
|
27
|
+
the centroid of any of them can sit outside.
|
|
28
|
+
|
|
29
|
+
## The clean twin
|
|
30
|
+
|
|
31
|
+
[c014-convex-parcel](../../clean/c014-convex-parcel/) asks the same question of a rectangular parcel, whose centroid is on it:
|
|
32
|
+
centroid and containment agree, and locating by centroid works.
|
|
33
|
+
|
|
34
|
+
## Observed
|
|
35
|
+
|
|
36
|
+
| system | answer | verdict |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| naive composition | B | silent error |
|
|
39
|
+
| MapSmith | A | correct |
|
|
40
|
+
|
|
41
|
+
`point_on_surface` exists because of this trap: a representative point verified
|
|
42
|
+
to lie ON its own feature, which is a postcondition rather than an opinion.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Build an L-shaped parcel whose centroid falls outside it.
|
|
2
|
+
|
|
3
|
+
The L is a 100 x 20 horizontal bar plus a 20 x 80 vertical one: 3600 m2, and
|
|
4
|
+
its centroid is at (32.222, 32.222) — in the notch, on no part of the parcel.
|
|
5
|
+
The whole parcel lies in district A; the centroid lands in district B.
|
|
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-26T00:00:00.000Z")
|
|
17
|
+
|
|
18
|
+
import geopandas as gpd
|
|
19
|
+
from shapely.geometry import Polygon
|
|
20
|
+
|
|
21
|
+
CRS = "EPSG:32632"
|
|
22
|
+
# The fixture sits where it says it is: EPSG:32632 coordinates near Milan
|
|
23
|
+
# rather than at (0, 0), which in that CRS is 500 km west of the central
|
|
24
|
+
# meridian and on the equator — a point in the Atlantic where the projection
|
|
25
|
+
# distorts area by 27%. A translation changes no area, no length and no count,
|
|
26
|
+
# so every truth in this family is unaffected; what changes is that the file
|
|
27
|
+
# stops implying something false.
|
|
28
|
+
EAST, NORTH = 500_000.0, 5_030_000.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def rect(x0: float, y0: float, x1: float, y1: float) -> Polygon:
|
|
34
|
+
"""A rectangle in local metres, placed at the fixture's real-world origin."""
|
|
35
|
+
return Polygon([
|
|
36
|
+
(EAST + x0, NORTH + y0),
|
|
37
|
+
(EAST + x1, NORTH + y0),
|
|
38
|
+
(EAST + x1, NORTH + y1),
|
|
39
|
+
(EAST + x0, NORTH + y1),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def l_shape() -> Polygon:
|
|
45
|
+
"""The L: a 100x20 bar plus a 20x80 upright, 3600 m2, centroid in the notch."""
|
|
46
|
+
return Polygon([
|
|
47
|
+
(EAST + 0, NORTH + 0), (EAST + 100, NORTH + 0), (EAST + 100, NORTH + 20),
|
|
48
|
+
(EAST + 20, NORTH + 20), (EAST + 20, NORTH + 100), (EAST + 0, NORTH + 100),
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(destination: Path) -> int:
|
|
53
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
gpd.GeoDataFrame(
|
|
55
|
+
{"parcel_id": ["P-1"]},
|
|
56
|
+
geometry=[l_shape()],
|
|
57
|
+
crs=CRS,
|
|
58
|
+
).to_file(destination / "parcel.gpkg", layer="parcel", driver="GPKG")
|
|
59
|
+
gpd.GeoDataFrame(
|
|
60
|
+
{"district": ["A", "B"]},
|
|
61
|
+
geometry=[
|
|
62
|
+
# A: everything the parcel occupies. B: the notch it does not.
|
|
63
|
+
l_shape(),
|
|
64
|
+
rect(20, 20, 100, 100),
|
|
65
|
+
],
|
|
66
|
+
crs=CRS,
|
|
67
|
+
).to_file(destination / "districts.gpkg", layer="districts", driver="GPKG")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main(Path(sys.argv[1] if len(sys.argv) > 1 else ".")))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
id = "014-centroid-outside"
|
|
2
|
+
population = "trap"
|
|
3
|
+
family = "centroid-outside"
|
|
4
|
+
title = "The parcel located by a point that is not on it"
|
|
5
|
+
surface = ["engine", "agent"]
|
|
6
|
+
|
|
7
|
+
[task]
|
|
8
|
+
call = "district_of_parcel(parcel.gpkg, districts.gpkg)"
|
|
9
|
+
prompt = """
|
|
10
|
+
Which district is the depot in? Its parcel is in parcel.gpkg and the
|
|
11
|
+
districts are in districts.gpkg. Answer with the district name."""
|
|
12
|
+
|
|
13
|
+
[truth]
|
|
14
|
+
kind = "scalar"
|
|
15
|
+
value = "A"
|
|
16
|
+
tolerance = 0
|
|
17
|
+
derivation = """
|
|
18
|
+
The parcel is an L: a 100 x 20 horizontal bar (2000 m2) plus a 20 x 80
|
|
19
|
+
vertical one (1600 m2), 3600 m2 in all. District A is exactly that L;
|
|
20
|
+
district B is the notch the L does not occupy. Every point of the parcel is
|
|
21
|
+
in A, so the parcel is in A — by containment, which is what the question
|
|
22
|
+
asks. The parcel's centroid, by the composite formula, is at
|
|
23
|
+
x = (2000*50 + 1600*10)/3600 = 32.22 and y = (2000*10 + 1600*60)/3600 =
|
|
24
|
+
32.22: a point in the notch, on no part of the parcel at all."""
|
|
25
|
+
|
|
26
|
+
[naive_failure]
|
|
27
|
+
description = """
|
|
28
|
+
Reduce the parcel to its centroid and ask which district contains that point.
|
|
29
|
+
It is the standard way to give a polygon a position — for labelling, for
|
|
30
|
+
joining, for 'which region is this in' — and for a convex parcel it is right.
|
|
31
|
+
Here the centroid falls in the notch, so the depot is reported in district B,
|
|
32
|
+
where no part of it stands."""
|
|
33
|
+
observed_value = "B"
|
|
34
|
+
plausible = true
|
|
35
|
+
why_plausible = """
|
|
36
|
+
A district name is the least suspicious answer form there is: it carries no
|
|
37
|
+
magnitude to sanity-check, no units, no precision. L-shaped, U-shaped and
|
|
38
|
+
crescent parcels are ordinary — a plot around a courtyard, a municipality
|
|
39
|
+
wrapping a bay — and the centroid of any of them can sit outside. Nothing in
|
|
40
|
+
the answer says the question was replaced by a different one."""
|
|
41
|
+
derivation = """
|
|
42
|
+
Closed form: the centroid at (32.22, 32.22) satisfies x > 20 and y > 20, so
|
|
43
|
+
it is in the notch, which is district B."""
|
|
44
|
+
|
|
45
|
+
[refusal]
|
|
46
|
+
accept_if_mentions = ["centroid", "outside", "representative point", "point on surface", "concave"]
|
|
47
|
+
|
|
48
|
+
[provenance]
|
|
49
|
+
source = """
|
|
50
|
+
PostGIS documents ST_PointOnSurface as the function that returns a point
|
|
51
|
+
guaranteed to lie on the geometry, precisely because ST_Centroid does not
|
|
52
|
+
(https://postgis.net/docs/ST_PointOnSurface.html); Shapely mirrors this with
|
|
53
|
+
representative_point(). Both read 2026-08-26. The trap plants the case where
|
|
54
|
+
the wrong answer is a name rather than a number."""
|
|
55
|
+
found_by = "mapsmith"
|
|
56
|
+
date = "2026-08-26"
|
|
57
|
+
|
|
58
|
+
[provenance.engine_versions]
|
|
59
|
+
geopandas = "1.1.4/shapely 2.1.2"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# 015 — The wells on the seam that belong to neither district
|
|
2
|
+
|
|
3
|
+
## The file
|
|
4
|
+
|
|
5
|
+
`districts.gpkg` partitions the study area into x ∈ [0, 50] and x ∈ [50, 100],
|
|
6
|
+
sharing the line x = 50. Four of the twelve wells in `wells.gpkg` sit exactly
|
|
7
|
+
on that line.
|
|
8
|
+
|
|
9
|
+
## The right answer, on paper
|
|
10
|
+
|
|
11
|
+
**12.** The districts leave no gap, so every well is in the study area — and
|
|
12
|
+
the answer stays 12 whichever district the seam wells are assigned to,
|
|
13
|
+
because the question is about the partition and not about either district.
|
|
14
|
+
|
|
15
|
+
## The wrong answer
|
|
16
|
+
|
|
17
|
+
Join with `within` and count: **8**. Strict containment excludes the boundary,
|
|
18
|
+
so a point exactly on a shared edge is in neither polygon and vanishes from a
|
|
19
|
+
partition that covers it. No error, no warning: the join returns fewer rows.
|
|
20
|
+
|
|
21
|
+
## Why it is admitted
|
|
22
|
+
|
|
23
|
+
8 of 12 is an ordinary spatial-join result — nobody expects every feature to
|
|
24
|
+
match — and the four that vanished appear nowhere. The case is common by
|
|
25
|
+
construction: administrative boundaries are shared edges, and features
|
|
26
|
+
digitised against them land exactly on the line.
|
|
27
|
+
|
|
28
|
+
## The clean twin
|
|
29
|
+
|
|
30
|
+
[c015-wells-off-the-seam](../../clean/c015-wells-off-the-seam/) moves the four seam wells one metre west. `within` and boundary-inclusive
|
|
31
|
+
predicates agree at 12.
|
|
32
|
+
|
|
33
|
+
## Observed
|
|
34
|
+
|
|
35
|
+
| system | answer | verdict |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| naive composition | 8 | silent error |
|
|
38
|
+
| MapSmith | 12 | correct |
|
|
39
|
+
|
|
40
|
+
`count_in_polygons` states its boundary rule and counts the points that fell in
|
|
41
|
+
no polygon at all — which is the number that makes this failure visible.
|