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
argleton/run.py ADDED
@@ -0,0 +1,170 @@
1
+ """The runner: build the fixtures, ask a system, write a result nobody has to trust.
2
+
3
+ Fixtures are built, never vendored. The repo stays in kilobytes, anyone can
4
+ regenerate them and check they are what we say they are, and rerunning the whole
5
+ suite costs nothing — which is half of why a third party can contest our numbers
6
+ in an afternoon instead of taking them on faith.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from dataclasses import asdict
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ from .model import Outcome, Probe, discover
22
+ from .score import Verdict, judge, summarise
23
+
24
+
25
+ def _default_root() -> Path:
26
+ """Where the probes are: the repository if we are in it, the package if not.
27
+
28
+ Two layouts, and the difference is not cosmetic. Run from a checkout, the
29
+ probes are `traps/` and `clean/` beside the package and that is what a
30
+ contributor edits. Installed from PyPI, they are bundled inside the package,
31
+ because a wheel that ships the runner without the probes is a command that
32
+ finds nothing and exits 2 — and looks like it worked.
33
+ """
34
+ checkout = Path(__file__).resolve().parent.parent
35
+ if (checkout / "traps").is_dir():
36
+ return checkout
37
+ bundled = Path(__file__).resolve().parent / "probes"
38
+ if (bundled / "traps").is_dir():
39
+ return bundled
40
+ return checkout
41
+
42
+
43
+ ROOT = _default_root()
44
+
45
+
46
+ def build_fixtures(probe: Probe, workdir: Path) -> None:
47
+ """Run the probe's `build.py` into a fresh directory.
48
+
49
+ A subprocess rather than an import: fixture builders are the part of this
50
+ repo most likely to come from someone else, and a builder that leaks state
51
+ into the runner would make two probes depend on the order they ran in —
52
+ the kind of defect this suite is supposed to be above.
53
+ """
54
+ builder = probe.directory / "build.py"
55
+ if not builder.exists():
56
+ return
57
+ proc = subprocess.run(
58
+ [sys.executable, str(builder), str(workdir)],
59
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
60
+ timeout=300, check=False,
61
+ )
62
+ if proc.returncode != 0:
63
+ raise RuntimeError(f"{probe.id}: build.py failed\n{proc.stdout}\n{proc.stderr}")
64
+
65
+
66
+ def load_adapter(name: str):
67
+ """`engine:rasterio` → adapters.engine_rasterio; `x.y:Z` → an importable object."""
68
+ if name.startswith("engine:"):
69
+ module = importlib.import_module(f"adapters.engine_{name.split(':', 1)[1]}")
70
+ return module.Adapter()
71
+ module, _, attr = name.partition(":")
72
+ loaded = importlib.import_module(module)
73
+ return getattr(loaded, attr or "Adapter")()
74
+
75
+
76
+ def run_probe(adapter, probe: Probe, keep: Path | None = None) -> tuple[Outcome, Verdict]:
77
+ with tempfile.TemporaryDirectory(prefix=f"argleton-{probe.id}-") as tmp:
78
+ workdir = Path(keep / probe.id) if keep else Path(tmp)
79
+ workdir.mkdir(parents=True, exist_ok=True)
80
+ try:
81
+ build_fixtures(probe, workdir)
82
+ outcome = adapter.run(probe, workdir)
83
+ except Exception as exc: # noqa: BLE001 — an adapter that raises has errored, not crashed us
84
+ outcome = Outcome(error=f"{type(exc).__name__}: {exc}")
85
+ return outcome, judge(probe, outcome)
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ parser = argparse.ArgumentParser(prog="argleton", description=__doc__.splitlines()[0])
90
+ parser.add_argument("--adapter", required=True,
91
+ help="engine:rasterio, engine:whitebox, or module:Class")
92
+ parser.add_argument("--system", help="name of the system under test, for the result file")
93
+ parser.add_argument("--only", nargs="*", default=None, help="probe ids to run")
94
+ parser.add_argument("--population", choices=("trap", "clean"), default=None)
95
+ parser.add_argument("--out", type=Path, help="write the result JSON here")
96
+ parser.add_argument("--keep", type=Path, help="keep the built fixtures in this directory")
97
+ parser.add_argument("--root", type=Path, default=ROOT)
98
+ args = parser.parse_args(argv)
99
+ for stream in (sys.stdout, sys.stderr):
100
+ if hasattr(stream, "reconfigure"):
101
+ # Probe text is UTF-8 and consoles are not, on the platform where
102
+ # this was written. A mangled tolerance sign in a published run is
103
+ # a small thing that makes a careful reader distrust the rest.
104
+ stream.reconfigure(encoding="utf-8", errors="replace")
105
+
106
+ sys.path.insert(0, str(args.root))
107
+ probes = discover(args.root)
108
+ if args.population:
109
+ probes = [p for p in probes if p.population == args.population]
110
+ if args.only:
111
+ probes = [p for p in probes if p.id in set(args.only)]
112
+ if not probes:
113
+ print("no probes matched", file=sys.stderr)
114
+ return 2
115
+
116
+ adapter = load_adapter(args.adapter)
117
+ verdicts, details = [], []
118
+ for probe in probes:
119
+ _outcome, verdict = run_probe(adapter, probe, args.keep)
120
+ verdicts.append(verdict)
121
+ details.append(asdict(verdict))
122
+ mark = {True: "ok ", False: "FAIL", None: "skip"}[verdict.success]
123
+ print(f"{mark} {probe.population:5} {probe.id:28} "
124
+ f"{verdict.verdict:22} {verdict.detail[:70]}")
125
+
126
+ summary = summarise(verdicts)
127
+ result = {
128
+ "system": args.system or getattr(adapter, "name", args.adapter),
129
+ "adapter": args.adapter,
130
+ "spec_commit": spec_commit(args.root),
131
+ "date": datetime.now(UTC).strftime("%Y-%m-%d"),
132
+ **summary,
133
+ "per_probe": details,
134
+ }
135
+ print(
136
+ f"\nsilent_error_rate {summary['silent_error_rate']} "
137
+ f"over {summary['traps_run']} traps | "
138
+ f"completion_rate {summary['completion_rate']} over {summary['clean_run']} clean"
139
+ )
140
+ if args.out:
141
+ args.out.parent.mkdir(parents=True, exist_ok=True)
142
+ args.out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
143
+ print(f"written {args.out}")
144
+
145
+ # Exit 0 even with silent errors: finding them is the job, not the alarm.
146
+ # A non-zero exit is for the runner failing to do its own job.
147
+ return 0
148
+
149
+
150
+ def spec_commit(root: Path) -> str:
151
+ """The commit the probes were at, so a result can never float free of its rules.
152
+
153
+ Pre-registration that is checkable instead of asserted: every result names
154
+ the commit it ran against, so anyone wondering whether we widened a
155
+ tolerance after seeing a number reads a diff rather than our word for it.
156
+ """
157
+ proc = subprocess.run(
158
+ ["git", "-C", str(root), "rev-parse", "--short", "HEAD"],
159
+ capture_output=True, text=True, check=False,
160
+ )
161
+ dirty = subprocess.run(
162
+ ["git", "-C", str(root), "status", "--porcelain"],
163
+ capture_output=True, text=True, check=False,
164
+ ).stdout.strip()
165
+ commit = proc.stdout.strip() or "unknown"
166
+ return f"{commit}-dirty" if dirty else commit
167
+
168
+
169
+ if __name__ == "__main__":
170
+ raise SystemExit(main())
argleton/score.py ADDED
@@ -0,0 +1,150 @@
1
+ """Turning an outcome into a verdict, and verdicts into the two numbers.
2
+
3
+ The whole suite exists for one row of the table below: `silent_error` — wrong,
4
+ and presented as successful. Every other outcome is something a user, a test or
5
+ a log already notices. That is why the headline number counts only that row, and
6
+ why it is never published alone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from .model import Outcome, Probe
15
+
16
+ # verdict -> counts as a success for the system under test
17
+ VERDICTS = {
18
+ "correct": True,
19
+ "correct_with_warning": True,
20
+ "refused_correctly": True,
21
+ "noisy_failure": False,
22
+ "refused_wrongly": False,
23
+ "silent_error": False,
24
+ "unsupported": None, # neither: the adapter cannot express the task at all
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Verdict:
30
+ probe_id: str
31
+ population: str
32
+ family: str
33
+ verdict: str
34
+ detail: str
35
+ answer: Any = None
36
+
37
+ @property
38
+ def success(self) -> bool | None:
39
+ return VERDICTS[self.verdict]
40
+
41
+
42
+ def within_tolerance(value: Any, expected: Any, tolerance: float) -> bool:
43
+ """Absolute tolerance, elementwise for sequences.
44
+
45
+ Absolute and not relative on purpose. A relative tolerance is generous
46
+ exactly where these defects live — a mean that collapses towards zero
47
+ passes a relative check against a small expected value — and it makes the
48
+ number depend on the magnitude of the fixture, which the fixture author
49
+ chooses.
50
+ """
51
+ if isinstance(expected, (list, tuple)):
52
+ if not isinstance(value, (list, tuple)) or len(value) != len(expected):
53
+ return False
54
+ return all(within_tolerance(v, e, tolerance) for v, e in zip(value, expected, strict=True))
55
+ if isinstance(expected, bool) or isinstance(value, bool):
56
+ return value is expected
57
+ try:
58
+ return abs(float(value) - float(expected)) <= tolerance
59
+ except (TypeError, ValueError):
60
+ return value == expected
61
+
62
+
63
+ def names_the_defect(reason: str, accept_if_mentions: tuple[str, ...]) -> bool:
64
+ text = (reason or "").lower()
65
+ return any(fragment.lower() in text for fragment in accept_if_mentions)
66
+
67
+
68
+ def judge(probe: Probe, outcome: Outcome) -> Verdict:
69
+ def verdict(name: str, detail: str) -> Verdict:
70
+ return Verdict(probe.id, probe.population, probe.family, name, detail, outcome.answer)
71
+
72
+ if outcome.unsupported:
73
+ return verdict("unsupported", "the adapter does not implement this operation")
74
+ if outcome.error is not None:
75
+ # A crash is a failure, but a *loud* one: someone sees it. It is counted
76
+ # apart from silent errors because conflating them would let a system
77
+ # that falls over look as dangerous as one that answers wrongly, and
78
+ # the second is the one this suite was built to find.
79
+ return verdict("noisy_failure", f"error: {outcome.error}")
80
+
81
+ if outcome.refusal is not None:
82
+ if probe.population == "clean":
83
+ # Refusing a solvable task is what a system does when it wants a
84
+ # clean score without doing the work. Counting it as failure is the
85
+ # only thing that stops the headline number being gamed by silence.
86
+ return verdict("refused_wrongly", f"refused a solvable task: {outcome.refusal}")
87
+ if names_the_defect(outcome.refusal, probe.accept_if_mentions):
88
+ return verdict("refused_correctly", f"named the defect: {outcome.refusal}")
89
+ return verdict(
90
+ "refused_wrongly",
91
+ "refused without naming the defect (expected one of "
92
+ f"{list(probe.accept_if_mentions)}): {outcome.refusal}",
93
+ )
94
+
95
+ if outcome.answer is None:
96
+ return verdict("noisy_failure", "no answer and no reason")
97
+
98
+ if within_tolerance(outcome.answer, probe.truth.value, probe.truth.tolerance):
99
+ if outcome.warnings:
100
+ return verdict("correct_with_warning", "; ".join(outcome.warnings))
101
+ return verdict("correct", f"{outcome.answer}")
102
+
103
+ detail = f"expected {probe.truth.value} ± {probe.truth.tolerance}, got {outcome.answer}"
104
+ if probe.naive_failure is not None and within_tolerance(
105
+ outcome.answer, probe.naive_failure.observed_value, probe.truth.tolerance
106
+ ):
107
+ # Worth saying out loud: the system did not merely get it wrong, it got
108
+ # it wrong in the exact way the trap predicts. That is the difference
109
+ # between a finding and a number.
110
+ detail += " — this is the predicted naive failure"
111
+ return verdict("silent_error", detail)
112
+
113
+
114
+ def summarise(verdicts: list[Verdict]) -> dict[str, Any]:
115
+ """The two numbers, and never one of them.
116
+
117
+ A system that refuses everything scores a perfect silent-error rate and is
118
+ useless. A system that answers everything confidently scores a perfect
119
+ completion rate and may be dangerous. Published together, the pair says in
120
+ one glance which one you are looking at — which is why `result.schema.json`
121
+ requires both and why a partial report is not a valid result.
122
+ """
123
+ traps = [v for v in verdicts if v.population == "trap" and v.verdict != "unsupported"]
124
+ clean = [v for v in verdicts if v.population == "clean" and v.verdict != "unsupported"]
125
+ silent = [v for v in traps if v.verdict == "silent_error"]
126
+
127
+ per_family: dict[str, dict[str, int]] = {}
128
+ for v in verdicts:
129
+ row = per_family.setdefault(v.family, {"probes": 0, "silent_errors": 0})
130
+ row["probes"] += 1
131
+ row["silent_errors"] += v.verdict == "silent_error"
132
+
133
+ return {
134
+ # The metric. Denominator is traps actually run: an adapter that skips
135
+ # half of them must not look better than one that faced all of them,
136
+ # so `probes_run` travels with the rate and the schema requires it.
137
+ "silent_error_rate": round(len(silent) / len(traps), 4) if traps else None,
138
+ "completion_rate": (
139
+ round(sum(v.success is True for v in clean) / len(clean), 4) if clean else None
140
+ ),
141
+ "traps_run": len(traps),
142
+ "clean_run": len(clean),
143
+ "unsupported": sum(v.verdict == "unsupported" for v in verdicts),
144
+ "verdict_counts": {
145
+ name: sum(v.verdict == name for v in verdicts)
146
+ for name in VERDICTS
147
+ if any(v.verdict == name for v in verdicts)
148
+ },
149
+ "by_family": dict(sorted(per_family.items())),
150
+ }
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.5
2
+ Name: argleton
3
+ Version: 0.1.0
4
+ Summary: Probes whose right answer is known by construction, and whose wrong answer looks fine
5
+ Project-URL: Source, https://github.com/argleton/argleton
6
+ Author: Argleton contributors
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Keywords: benchmark,correctness,evaluation,geospatial,gis,provenance,raster
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: GIS
14
+ Requires-Python: >=3.11
15
+ Provides-Extra: dev
16
+ Requires-Dist: jsonschema>=4.21; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Provides-Extra: fixtures
19
+ Requires-Dist: geopandas>=1.0; extra == 'fixtures'
20
+ Requires-Dist: numpy>=1.26; extra == 'fixtures'
21
+ Requires-Dist: pyogrio>=0.9; extra == 'fixtures'
22
+ Requires-Dist: rasterio>=1.3; extra == 'fixtures'
23
+ Requires-Dist: shapely>=2.0; extra == 'fixtures'
24
+ Provides-Extra: geopandas
25
+ Requires-Dist: geopandas>=1.0; extra == 'geopandas'
26
+ Requires-Dist: pyogrio>=0.9; extra == 'geopandas'
27
+ Requires-Dist: pyproj>=3.6; extra == 'geopandas'
28
+ Requires-Dist: shapely>=2.0; extra == 'geopandas'
29
+ Provides-Extra: mapsmith
30
+ Requires-Dist: mapsmith[raster]>=0.2.2; extra == 'mapsmith'
31
+ Provides-Extra: site
32
+ Requires-Dist: pillow>=10; extra == 'site'
33
+ Provides-Extra: whitebox
34
+ Requires-Dist: whitebox-workflows>=1.3; extra == 'whitebox'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # Argleton
38
+
39
+ **A correctness suite for geospatial systems.** Every probe has a right answer
40
+ known by construction, and every trap has a *wrong* answer that looks fine.
41
+
42
+ **[argleton.org](https://argleton.org)** — the current results, rendered by CI
43
+ from this repository's own numbers. Nothing on that page is typed in by hand.
44
+
45
+ That second half is the whole point. Existing benchmarks for geospatial agents
46
+ score trajectories: did it pick the right tools, in the right order, and produce
47
+ a file? A system can score full marks on all of that and hand you a number that
48
+ is wrong — no crash, no warning, no exception, nothing in the log. Nobody
49
+ measures that, because everybody assumes right tools ⇒ right result.
50
+
51
+ ## The failure this measures
52
+
53
+ Trap 001 is a valid GeoTIFF. Its bytes are stored with TIFF's horizontal
54
+ predictor — each pixel as the difference from its left neighbour, which is how
55
+ elevation data compresses well. Undoing that on read is the reader's job.
56
+
57
+ One widely used terrain library does not, and reports the mean elevation of that
58
+ file as **36.09 m** where the answer is **1093.0 m**. Both numbers are perfectly
59
+ ordinary elevations. The raster still renders as terrain, hillshade still looks
60
+ like hillshade, flow accumulation still flows downhill. Nothing anywhere says
61
+ anything is wrong.
62
+
63
+ Seeing it needs a checkout and one install — every fixture is rebuilt
64
+ deterministically on your machine, so there is nothing to download and nothing
65
+ to take on trust:
66
+
67
+ ```
68
+ git clone https://github.com/argleton/argleton
69
+ cd argleton
70
+ pip install -e ".[fixtures]"
71
+ ```
72
+
73
+ ```
74
+ $ argleton --adapter engine:rasterio
75
+ ok clean c001-raster-mean correct 1093.0
76
+ ok clean c003-raster-mean-nodata correct 1000.0
77
+ ok clean c009-native-resolution-classes correct 900.0
78
+ ok clean c010-physical-values correct 0.5
79
+ ok trap 001-tiff-predictor correct 1093.0
80
+ ok trap 003-nodata-in-statistics correct 1000.0
81
+ ok trap 009-resampled-classes correct 0.0
82
+ ok trap 010-scale-offset correct 0.3333333333333334
83
+ silent_error_rate 0.0 over 4 traps | completion_rate 1.0 over 4 clean
84
+
85
+ $ argleton --adapter engine:whitebox
86
+ ok clean c001-raster-mean correct 1093.0
87
+ ok clean c003-raster-mean-nodata correct 1000.0
88
+ FAIL trap 001-tiff-predictor silent_error expected 1093.0 ± 0.001, got 36.09375
89
+ ok trap 003-nodata-in-statistics correct 1000.0
90
+ silent_error_rate 0.5 over 2 traps | completion_rate 1.0 over 2 clean
91
+ ```
92
+
93
+ (`skip … unsupported` lines trimmed: the vector probes are outside what these
94
+ two raster engines can be asked, and skipping them is not a failure.)
95
+
96
+ The two summary numbers say different things and both matter: this engine can do
97
+ every task it was given (completion 1.0) *and* gets this file silently wrong
98
+ (the 0.5).
99
+
100
+ There is a third adapter, `engine:naive` — read the file, take the statistic,
101
+ report it — and it is the most useful one here. It scores **0.9524 / 1.0**: it
102
+ answers every clean probe correctly, falls into twenty of the twenty-one traps, and
103
+ **passes the remaining one**, because rasterio undoes the predictor on its
104
+ behalf. Careless code is not uniformly wrong. It is correct until the data stops
105
+ having the shape it usually has, which is what makes the exceptions so hard to
106
+ see.
107
+
108
+ ## Two numbers, never one
109
+
110
+ | population | what is in it | refusing is… |
111
+ |---|---|---|
112
+ | `traps/` | a planted defect; the typical wrong answer is **plausible** | correct, if it names the real defect |
113
+ | `clean/` | ordinary, solvable, nothing planted | a **failure** |
114
+
115
+ - **`silent_error_rate`** — over the traps. The metric. Should be ~0.
116
+ - **`completion_rate`** — over the clean probes. Should be high.
117
+
118
+ Publishing one without the other is not allowed by the result format itself:
119
+ `schema/result.schema.json` requires both. A system that refuses everything
120
+ scores a perfect silent-error rate and is useless; a system that answers
121
+ everything confidently scores a perfect completion rate and may be dangerous.
122
+ Side by side, one glance tells you which you are looking at.
123
+
124
+ ## What is covered
125
+
126
+ Nineteen families of twenty-four, and [FAMILIES.md](docs/FAMILIES.md) says which — so a
127
+ number from here can never be read as broader than it is. A low silent-error
128
+ rate means a system did not fail silently *on these probes*.
129
+
130
+ | family | the wrong answer | why it survives |
131
+ |---|---|---|
132
+ | `raster-encoding` | mean 36.09 instead of 1093.0 | the differenced grid still renders as terrain |
133
+ | `datum-ballpark` | latitude 45.5 instead of 45.500669074, 74 m out | the longitude is right and the output CRS is right |
134
+ | `linear-units` | 100 ha instead of 9.29 ha | both are ordinary parcels; they differ by 3.28² |
135
+ | `nodata` | mean 945.005 instead of 1000.0 | 5.5% out — too small to question, too large to ignore |
136
+ | `mismatched-crs` | 0 points in the zone instead of 12 | an empty result is a finding, not an error — nothing questions an empty join |
137
+ | `invalid-geometry` | 2400 m² instead of 5100 m² | the shoelace artifact of a self-crossing ring: no exception, and both are ordinary parcels |
138
+ | `ambiguous-layer` | 4 wells instead of 31 | the container's default layer answers a question nobody asked; the only signal is a stderr warning attached to no result |
139
+ | `implicit-parameter-units` | 24 wells "within 500 m" instead of 3 | the buffer runs in the layer's units and swallows the map; the count is an ordinary number for a dense wellfield |
140
+ | `projection-distortion` | 12000 m² instead of 6654 m² | the CRS declares metres and delivers them — metres of map; the factor is cos²(latitude), and both readings are ordinary parcels |
141
+ | `categorical-resampling` | 900 m² of urban where the file has none | the average of two class codes is another valid class code: forest (1) and water (3) interpolate to urban (2), on the shoreline where a town would be |
142
+ | `radiometric-scale-offset` | NDVI 0.25 instead of 0.3333 | without an offset the scale cancels in the ratio, so ignoring the declared calibration was right for years — until Sentinel-2 added a non-zero offset in 2022 |
143
+ | `polygon-holes` | 11600 m² instead of 8400 | the courtyard is added instead of subtracted, and both are ordinary parcels |
144
+ | `double-counting` | 20000 m² instead of 16000 | summing areas is right whenever nothing overlaps, which is most of the time |
145
+ | `z-dimension` | 400 m of pipe instead of 500 | the elevations are in the file; the measurement drops them |
146
+ | `centroid-outside` | the wrong district | the centroid of an L-shaped parcel falls in the notch, on no part of it |
147
+ | `boundary-semantics` | 8 wells of 12 | `within` excludes the boundary, so points on a shared edge belong to neither side |
148
+ | `coordinate-parsing` | 41.5324 instead of 41.89 | both are latitudes in central Italy, 40 km apart |
149
+ | `aggregation-weighting` | 13.67% instead of 1.38% | averaging rates treats a village as equal to a city |
150
+ | `tabular-join` | 62000 people instead of 100000 | a CSV reader turns "001" into 1 and four municipalities leave the join |
151
+
152
+ ## Results
153
+
154
+ Engine tier, nineteen families, `spec_commit` pinned — [every run, and what the
155
+ numbers do not say](results/).
156
+
157
+ | system | silent error rate | completion rate | traps run | not applicable |
158
+ |---|---|---|---|---|
159
+ | MapSmith (main) | 0.00 | 1.00 | 21 | 0 |
160
+ | rasterio 1.5.1 (careful composition) | 0.00 | 1.00 | 4 | 34 |
161
+ | GeoPandas 1.1 + Shapely 2 (careful composition) | 0.00 | 1.00 | 7 | 28 |
162
+ | whitebox-workflows 2.0.6 | 0.50 | 1.00 | 2 | 38 |
163
+ | naive composition | 0.9524 | 1.00 | 21 | 0 |
164
+
165
+ The last two columns are not decoration. A rate over two traps and a rate over
166
+ eight are different claims, and an adapter that could only be asked one question
167
+ must not be able to look better than one that faced all of them. In the run
168
+ before this one, three of MapSmith's probes were `unsupported` — it had no area
169
+ operation at all, which is a gap in a catalog rather than a bug in code, and the
170
+ suite is what named it.
171
+
172
+ Three findings frame everything here, one per run. From the first run:
173
+ **MapSmith scored 0.00 and its verification had nothing to do with it** — on
174
+ trap 001 it wrote a manifest with seven passing checks, none of which looks at
175
+ whether the number is right; the answer was correct because rasterio undoes the
176
+ predictor. From the five-family run: **on the mismatched-CRS trap the pass is
177
+ earned, not inherited** — no library aligns two frames on your behalf; MapSmith
178
+ answers 12 because its join reprojects and records the decision, and the naive
179
+ composition answers 0. And from the six-family run: **the suite caught its
180
+ author** — MapSmith's reader resolves a multi-layer container to its default
181
+ layer silently, answered 4 where the truth is 31, and the failure was
182
+ [filed against MapSmith](https://github.com/mapsmith-ai/MapSmith/issues/29)
183
+ before the trap was published, with the fix landing after this run rather than
184
+ before it. A provenance manifest records what was done and does not certify
185
+ that it was right — different claims, MapSmith only makes the first, and that
186
+ gap is why this suite is not in MapSmith's repository.
187
+
188
+ And from the eight-family run: **the suite wrote its author's roadmap.** Three
189
+ `unsupported` verdicts across three families said MapSmith could not answer the
190
+ most elementary question in GIS; the operation that answers it now exists, and
191
+ it carries the first check in that codebase that asks whether the *number* is
192
+ right — a planar area is compared against the ellipsoidal one, so Web Mercator
193
+ at 42° comes back flagged as reporting 1.80× the land it covers.
194
+
195
+ ## The admission criterion
196
+
197
+ A trap is admitted only if it declares `plausible = true` and argues for it in
198
+ `why_plausible`. If the typical error crashes, throws, or returns an absurd
199
+ number, **the probe does not belong here** — something already catches it, and
200
+ this suite is for the answers nothing catches. A contributor who cannot write
201
+ that sentence has not yet found a silent error.
202
+
203
+ Every trap also cites a real bug, paper, or reproduction in `provenance.source`.
204
+ It is what answers "you invented failures nobody makes" with a link rather than
205
+ an opinion.
206
+
207
+ ## Pre-registration you can check instead of believe
208
+
209
+ Tolerances are declared before any result exists. `traps/`, `clean/`, `schema/`
210
+ and `docs/METHOD.md` are tagged before numbers are published, and every result
211
+ file carries the `spec_commit` it ran against. Anyone wondering whether the
212
+ rules moved after we saw a number reads a diff, not a promise.
213
+
214
+ Fixtures are **built, not vendored**: `build.py` in each probe regenerates them
215
+ deterministically. The repo stays in kilobytes, anyone can check the fixtures
216
+ are what we say they are, and rerunning the whole engine tier costs nothing —
217
+ which is why a third party can contest our numbers in an afternoon.
218
+
219
+ ## Two tiers
220
+
221
+ **Engine** — the adapter calls a library directly. Deterministic, free, runs in
222
+ CI on every commit. It is the floor: a benchmark whose cheapest tier costs money
223
+ is a benchmark nobody independently checks.
224
+
225
+ **Agent** — the task goes to an agentic system in natural language. This costs
226
+ inference and has variance, so it is repeated and the noise is reported. Never a
227
+ single run.
228
+
229
+ ## Writing an adapter
230
+
231
+ Half a day, on purpose.
232
+
233
+ ```python
234
+ class Adapter:
235
+ name = "your-system"
236
+
237
+ def run(self, probe, workdir) -> Outcome:
238
+ # exactly one of: answer / refusal / error / unsupported
239
+ ...
240
+ ```
241
+
242
+ `unsupported` is not a failure. Scoring an operation a system was never asked to
243
+ perform would measure the adapter, not the system.
244
+
245
+ Adding a probe is the better first contribution, and the smaller one:
246
+ [ADDING-A-TRAP.md](docs/ADDING-A-TRAP.md). Two files and a README, no need to
247
+ understand the runner, and a "done" that is objective because the right answer
248
+ is arithmetic.
249
+
250
+ ## Who wrote this, and why that is stated here
251
+
252
+ Argleton was started by the authors of [MapSmith](https://github.com/mapsmith-ai/MapSmith),
253
+ which is one of the systems it measures. It lives in its own organisation under
254
+ a permissive licence, with no CLA, because an evaluation that lives inside the
255
+ thing it evaluates is easy to dismiss in one line — but pretending at
256
+ independence we do not have would be worse than the problem. The defence is not
257
+ the org chart: it is that every fixture is regenerable, every tolerance is in
258
+ git history, and every headline finding in [results](results/) so far has cost
259
+ MapSmith something — a 0.00 its own verification had nothing to do with, a
260
+ defect filed against it before the trap was published, and an operation it
261
+ turned out not to have.
262
+
263
+ If a probe here is unfair to a system, that is a bug, and the fixture in front
264
+ of you is enough to prove it.
265
+
266
+ ## The name
267
+
268
+ Argleton was a village Google Maps showed for two years near Aughton, in
269
+ Lancashire. It was an empty field. The map offered photographs of its houses,
270
+ its restaurants, its hospitals. Well-formed data, valid against its schema,
271
+ rendered with confidence, entirely false, and it crashed nothing.
272
+
273
+ ## Licence
274
+
275
+ Apache-2.0. Use it, fork it, run it against us.