mapsmith 0.3.0__tar.gz → 0.4.0__tar.gz

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 (134) hide show
  1. {mapsmith-0.3.0 → mapsmith-0.4.0}/.gitignore +5 -0
  2. mapsmith-0.4.0/.zenodo.json +19 -0
  3. {mapsmith-0.3.0 → mapsmith-0.4.0}/CHANGELOG.md +385 -3
  4. mapsmith-0.4.0/CITATION.cff +31 -0
  5. {mapsmith-0.3.0 → mapsmith-0.4.0}/PKG-INFO +349 -74
  6. {mapsmith-0.3.0 → mapsmith-0.4.0}/README.md +348 -73
  7. {mapsmith-0.3.0 → mapsmith-0.4.0}/SECURITY.md +71 -8
  8. {mapsmith-0.3.0 → mapsmith-0.4.0}/docs/argleton-run.json +6 -6
  9. {mapsmith-0.3.0 → mapsmith-0.4.0}/docs/benchmarks.md +32 -4
  10. {mapsmith-0.3.0 → mapsmith-0.4.0}/docs/catalog-entry-spec.md +43 -7
  11. {mapsmith-0.3.0 → mapsmith-0.4.0}/examples/03_validated_plans.ipynb +23 -23
  12. {mapsmith-0.3.0 → mapsmith-0.4.0}/pyproject.toml +1 -1
  13. {mapsmith-0.3.0 → mapsmith-0.4.0}/schema/operation.schema.json +11 -2
  14. {mapsmith-0.3.0 → mapsmith-0.4.0}/server.json +3 -3
  15. {mapsmith-0.3.0 → mapsmith-0.4.0}/site/build.py +4 -1
  16. {mapsmith-0.3.0 → mapsmith-0.4.0}/site/index.template.html +107 -24
  17. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/__init__.py +1 -1
  18. mapsmith-0.4.0/src/mapsmith/antimeridian.py +205 -0
  19. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/catalog.py +1506 -66
  20. mapsmith-0.4.0/src/mapsmith/discovery_log.py +182 -0
  21. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/dispatch.py +15 -2
  22. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/duckdb_engine.py +13 -4
  23. mapsmith-0.4.0/src/mapsmith/engines/esri.py +239 -0
  24. mapsmith-0.4.0/src/mapsmith/engines/linework.py +845 -0
  25. mapsmith-0.4.0/src/mapsmith/engines/network.py +1085 -0
  26. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/raster.py +171 -2
  27. mapsmith-0.4.0/src/mapsmith/engines/sampling.py +610 -0
  28. mapsmith-0.4.0/src/mapsmith/engines/spatial_stats.py +794 -0
  29. mapsmith-0.4.0/src/mapsmith/engines/summaries.py +582 -0
  30. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/vector.py +618 -15
  31. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/whitebox_engine.py +479 -1
  32. mapsmith-0.4.0/src/mapsmith/grid.py +435 -0
  33. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/plans/registry.py +290 -0
  34. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/provenance.py +80 -1
  35. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/readers.py +397 -354
  36. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/server.py +66 -2
  37. mapsmith-0.4.0/src/mapsmith/sql_policy.py +255 -0
  38. mapsmith-0.4.0/src/mapsmith/stacks.py +290 -0
  39. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/verify.py +50 -4
  40. mapsmith-0.4.0/tests/conftest.py +37 -0
  41. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/data/manifest_spec_validator.py +21 -1
  42. mapsmith-0.4.0/tests/test_antimeridian.py +302 -0
  43. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_catalog.py +1 -1
  44. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_catalog_applicability.py +32 -3
  45. mapsmith-0.4.0/tests/test_contours_and_cost.py +287 -0
  46. mapsmith-0.4.0/tests/test_dashboard.py +319 -0
  47. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_discovery_contract.py +95 -9
  48. mapsmith-0.4.0/tests/test_discovery_log.py +268 -0
  49. mapsmith-0.4.0/tests/test_discovery_report.py +217 -0
  50. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_duckdb_sandbox.py +69 -0
  51. mapsmith-0.4.0/tests/test_environment.py +505 -0
  52. mapsmith-0.4.0/tests/test_grid_registration.py +436 -0
  53. mapsmith-0.4.0/tests/test_linework.py +443 -0
  54. mapsmith-0.4.0/tests/test_network.py +472 -0
  55. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_path_containment.py +357 -241
  56. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_plans.py +55 -0
  57. mapsmith-0.4.0/tests/test_published_figures.py +275 -0
  58. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_retrieval_at_scale.py +221 -219
  59. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_retrieval_degradation.py +99 -1
  60. mapsmith-0.4.0/tests/test_sampling.py +491 -0
  61. mapsmith-0.4.0/tests/test_select_and_extract.py +720 -0
  62. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_showcase.py +320 -17
  63. mapsmith-0.4.0/tests/test_south_up_and_mixed.py +257 -0
  64. mapsmith-0.4.0/tests/test_spatial_stats.py +485 -0
  65. mapsmith-0.4.0/tests/test_sql_credentials.py +289 -0
  66. mapsmith-0.4.0/tests/test_stacks.py +289 -0
  67. mapsmith-0.4.0/tests/test_summaries.py +468 -0
  68. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_verify.py +279 -2
  69. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_verify_repair.py +9 -1
  70. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_whitebox_derivatives.py +351 -245
  71. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_worked_example.py +17 -83
  72. mapsmith-0.3.0/src/mapsmith/sql_policy.py +0 -105
  73. mapsmith-0.3.0/tests/test_discovery_report.py +0 -102
  74. mapsmith-0.3.0/tests/test_sql_credentials.py +0 -117
  75. {mapsmith-0.3.0 → mapsmith-0.4.0}/.dockerignore +0 -0
  76. {mapsmith-0.3.0 → mapsmith-0.4.0}/CLAUDE.md +0 -0
  77. {mapsmith-0.3.0 → mapsmith-0.4.0}/CONTRIBUTING.md +0 -0
  78. {mapsmith-0.3.0 → mapsmith-0.4.0}/Dockerfile +0 -0
  79. {mapsmith-0.3.0 → mapsmith-0.4.0}/LICENSE +0 -0
  80. {mapsmith-0.3.0 → mapsmith-0.4.0}/MANIFESTO.md +0 -0
  81. {mapsmith-0.3.0 → mapsmith-0.4.0}/TRADEMARKS.md +0 -0
  82. {mapsmith-0.3.0 → mapsmith-0.4.0}/docker-compose.yml +0 -0
  83. {mapsmith-0.3.0 → mapsmith-0.4.0}/examples/01_verified_geoprocessing.ipynb +0 -0
  84. {mapsmith-0.3.0 → mapsmith-0.4.0}/examples/02_terrain_hydrology.ipynb +0 -0
  85. {mapsmith-0.3.0 → mapsmith-0.4.0}/examples/README.md +0 -0
  86. {mapsmith-0.3.0 → mapsmith-0.4.0}/examples/fixtures/mount_st_helens_dem.tif +0 -0
  87. {mapsmith-0.3.0 → mapsmith-0.4.0}/funding.json +0 -0
  88. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/__init__.py +0 -0
  89. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/geodesy.py +0 -0
  90. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/engines/sedona_engine.py +0 -0
  91. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/gdal_policy.py +0 -0
  92. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/jobs.py +0 -0
  93. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/plans/__init__.py +0 -0
  94. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/plans/executor.py +0 -0
  95. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/plans/models.py +0 -0
  96. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/plans/validator.py +0 -0
  97. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/preview.py +0 -0
  98. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/retrieval.py +0 -0
  99. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/ui.py +0 -0
  100. {mapsmith-0.3.0 → mapsmith-0.4.0}/src/mapsmith/workspace.py +0 -0
  101. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/data/discovery_queries.json +0 -0
  102. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/data/manifest-v1.schema.json +0 -0
  103. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_ambiguous_layer.py +0 -0
  104. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_band_math.py +0 -0
  105. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_catalog_entry_spec.py +0 -0
  106. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_catalog_retrieval.py +0 -0
  107. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_centroid_convert.py +0 -0
  108. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_clip_reclassify_raster.py +0 -0
  109. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_describe_raster.py +0 -0
  110. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_engines.py +0 -0
  111. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_gdal_policy.py +0 -0
  112. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_geodesy.py +0 -0
  113. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_geoparquet2.py +0 -0
  114. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_measure_area.py +0 -0
  115. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_merge_simplify.py +0 -0
  116. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_nearest_explode.py +0 -0
  117. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_overlay_dissolve.py +0 -0
  118. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_preview.py +0 -0
  119. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_raster.py +0 -0
  120. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_raster_reproject_bands.py +0 -0
  121. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_redaction.py +0 -0
  122. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_resample_raster.py +0 -0
  123. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_retrieval.py +0 -0
  124. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_smoke.py +0 -0
  125. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_sql_axis_order.py +0 -0
  126. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_terrain_derivatives.py +0 -0
  127. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_tier_a_operations.py +0 -0
  128. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_ui.py +0 -0
  129. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_verification_status.py +0 -0
  130. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_voronoi.py +0 -0
  131. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_whitebox.py +0 -0
  132. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_whitebox_encoding.py +0 -0
  133. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_whitebox_geographic_refusal.py +0 -0
  134. {mapsmith-0.3.0 → mapsmith-0.4.0}/tests/test_workspace.py +0 -0
@@ -61,3 +61,8 @@ uv.lock
61
61
 
62
62
  # The site is generated: `python site/build.py dist` rebuilds it from a real run.
63
63
  site/generated/
64
+
65
+ # Written by `benchmarks/dashboard.py` and by MAPSMITH_DISCOVERY_LOG:
66
+ # both hold the queries somebody typed, so they stay out of the repository.
67
+ dashboard.html
68
+ *.jsonl
@@ -0,0 +1,19 @@
1
+ {
2
+ "upload_type": "software",
3
+ "title": "MapSmith: geospatial processing for AI agents, with verifiable provenance",
4
+ "version": "0.4.0",
5
+ "creators": [
6
+ {"name": "The MapSmith project"}
7
+ ],
8
+ "license": "agpl-3.0-or-later",
9
+ "access_right": "open",
10
+ "keywords": [
11
+ "GIS",
12
+ "geospatial",
13
+ "provenance",
14
+ "reproducibility",
15
+ "Model Context Protocol",
16
+ "MCP"
17
+ ],
18
+ "description": "<p>An MCP server that gives AI agents professional geoprocessing under a rule the agent cannot bend: every geometry and every number comes from a real engine run, never from the model.</p><p>Each operation that writes a dataset also writes a manifest beside it &mdash; the inputs and their hashes, the exact parameters, the coordinate-system decisions and the reason for each, the engine and its version, the environment that changed the answer, and the deterministic checks that ran. The manifest is written <em>before</em> a failed check raises, so the audit trail survives the error rather than disappearing with it.</p><p>Capabilities are reached through a searchable catalogue rather than a wall of tools, because a caller's ability to pick the right tool degrades long before a catalogue does. The catalogue answers with a set of candidates and the text that distinguishes them, not with a verdict.</p><p>A manifest records what was done; it does <em>not</em> certify that what was done was right. MapSmith says so in its own documentation, and a separate correctness suite exists to measure the difference.</p>"
19
+ }
@@ -4,9 +4,391 @@ All notable changes to MapSmith are documented here, in the format of
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows
5
5
  [semantic versioning](https://semver.org/).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.4.0] — 2026-08-31
8
8
 
9
- Nothing yet.
9
+ ### Added
10
+
11
+ - **The caller chooses the geoprocessing stack once, at the start, and it is
12
+ never swapped underneath them.** `MAPSMITH_STACK` takes `opensource` (the
13
+ default: GDAL, GeoPandas, DuckDB, Whitebox) or `esri`, which routes to ArcPy
14
+ on a machine that has a licence for it. The rule that makes the choice worth
15
+ making is what happens at the edges: when the chosen stack cannot do
16
+ something, MapSmith says which of three things is true — the tool does not
17
+ exist, this licence tier does not include it, or it would need an online
18
+ service — and falls back only where the caller asked for a fallback, naming
19
+ the substitution in the manifest. An engine quietly replaced by another is a
20
+ result whose provenance record is true and whose number came from software
21
+ nobody chose.
22
+ - **Citation metadata** (`CITATION.cff`). Every release from here on is
23
+ archived and assigned a DOI, so the file is a release artifact: a test fails
24
+ if its version is not the version being released, or if its release date is
25
+ in the future.
26
+ - **A manifest can now say which configuration produced the numbers**
27
+ (`environment`, section 3.8 of the manifest specification). The field existed
28
+ in the published specification and in the schema, and nothing in MapSmith
29
+ could produce it — a whole section describing something the reference
30
+ implementation could not do.
31
+
32
+ What made it concrete: a GeoTIFF and the `.aux.xml` beside it can declare
33
+ different georeferencing, and GDAL prefers the sidecar by documented design,
34
+ because that is how somebody overrides georeferencing they know to be wrong.
35
+ Both readings are the library behaving exactly as written. On one fixture the
36
+ same file gives 40,000 m² or 160,000 m² and an origin a hundred kilometres
37
+ apart, and until now nothing MapSmith wrote could say which of the two it
38
+ read. There is nothing upstream to fix and everything to state.
39
+
40
+ So: `describe_dataset` reports both sources when a raster has two, and the
41
+ nine operations that read a raster's grid directly refuse instead, naming
42
+ both and saying how to choose. The terrain and sampling operations are not
43
+ covered yet and the README says so rather than implying otherwise; the
44
+ terrain engine stops on the same file for a different reason, because its own
45
+ reading of the grid disagrees with GDAL's, and its message names neither the
46
+ sidecar nor the way out. That split is the multi-layer refusal (#29)
47
+ applied to a second axis — the format's default answers a question the caller
48
+ never asked, and a manifest could not honestly say which data produced the
49
+ numbers. Describing is different from computing: a file with two
50
+ georeferencings is a thing to be told about.
51
+
52
+ The field is filled where a manifest becomes a file rather than in
53
+ `verify.audited`, and the difference matters: seventeen writers of fifty-seven
54
+ build their record by hand and never reach `audited`, and they are
55
+ concentrated in raster — exactly where georeferencing decides the numbers.
56
+ Empty when there is nothing to say, because a field that appears on every
57
+ operation is a field its reader learns to skip.
58
+ - **Redaction now reads dictionary keys, not only values.** Adding a field that
59
+ holds environment variables exposed a gap that was never about that field:
60
+ `{"AWS_SECRET_ACCESS_KEY": "AKIA…"}` passed through untouched, because the
61
+ scanner looks for `name=value` *inside a string* and in a dictionary the name
62
+ is the key. That was true of every dictionary a manifest carries. Bare `key`
63
+ stays off the list on purpose — `sort_key` and `primary_key` are ordinary
64
+ field names, and a mask that fires on those teaches its reader to distrust it.
65
+ - **`select_features` and `extract_layer`: two remedies MapSmith was already
66
+ recommending without offering.** The mixed-geometry warning said *select the
67
+ features the question is about*; the multi-layer refusal said *extract the
68
+ layer you mean into its own dataset*. Both then handed over a `run_sql`
69
+ incantation, which is a long way round for "keep the lines". Now they name an
70
+ operation, and the hints were rewritten to do so — an error message that
71
+ points at a longer route than the one that exists is a defect of its own,
72
+ because it is the only thing a blocked caller reads.
73
+ - `select_features` keeps features by geometry family (`line` keeps
74
+ MultiLineString too), by exact geometry type, or by an attribute condition
75
+ (`field_equals`, `field_in`, `field_between`, inclusive at both ends).
76
+ Deliberately no expression language: that is `run_sql` with extra steps and
77
+ its own way of going wrong. Filter values arriving as text against a numeric
78
+ column are **converted and recorded**, and the conversion tries `int` before
79
+ `float` because it has to: `float("9007199254740993")` is 9007199254740992,
80
+ so going through float would return **the row next to** the one asked for —
81
+ one feature kept, every check green, and a manifest naming a number nobody
82
+ typed. OSM ids, BIGINT keys and cadastral references all live past 2^53.
83
+ Five more inputs are refused rather than answered with an empty dataset,
84
+ because an empty dataset with a complete manifest reads as a finding:
85
+ `"high"` against an integer column, `"nan"` (which `float()` accepts and
86
+ which equals nothing, including itself), `"inf"`, `field_equals` with no
87
+ value, and a range whose minimum is above its maximum. Matching nothing for
88
+ a reason that is not one of those is reported as a warning, with the hint
89
+ that a typo and a genuinely empty result look identical.
90
+ - A **family selection says what it could not keep**. `line` keeps
91
+ MultiLineString because a MultiLineString is line-shaped — and by the same
92
+ argument a GeometryCollection holding a polygon is a polygon to everybody
93
+ except its type name, so dropping it without a word would be that care
94
+ applied to half the problem. Types no family covers, and null geometries,
95
+ are counted in a non-critical check that names them.
96
+ - `extract_layer` copies one named layer out of a multi-layer container. The
97
+ refusal it resolves (issue #29) stays exactly as strict; what changes is
98
+ that the way out is now one call whose manifest names the layer, the
99
+ container and the layers left behind.
100
+ - **A bounding box that spans the planet for data two degrees wide** is now
101
+ reported with the sentence that makes it readable (`antimeridian.py`).
102
+ RFC 7946 §3.1.9 splits a geometry at the antimeridian, so the bounds computed
103
+ from its coordinates come out as the ordinary west-to-east form spanning the
104
+ world; §5.2 defines the crossing form as the one with west greater than east.
105
+ Nothing in a planar geometry library can produce the second from the first,
106
+ so `describe_dataset` reported `(-180, -17.5, 180, -16.5)` for a Fijian survey
107
+ zone — arithmetically correct, and the wrong answer to the question anybody
108
+ asked. The plain box is still returned unchanged; when the data crosses, a
109
+ `true_extent`, a width and a note saying what a filter on the plain box would
110
+ select come with it. Distinguishing a real crossing from a dataset that
111
+ genuinely covers the world needs a geometry probe, not arithmetic: a single
112
+ global rectangle has only ±180 as longitudes and they wrap onto one value.
113
+ Detection deliberately carries **no threshold on the plain span**: the first
114
+ version required 350° before looking further, which only ever fires on data
115
+ split exactly at ±180 — the well-formed case. Buoys at 170°E and 140°W span
116
+ 310° and were reported in silence. What replaces it is a rule with a meaning:
117
+ a crossing layer reads narrower when the seam is treated as continuous *and*
118
+ occupies less than half the world going the short way, so points scattered at
119
+ -179, -90, 0, 90 and 179 are global data rather than a crossing. Only
120
+ degree-based geographic systems are considered — EPSG:4807 is geographic in
121
+ grads, where wrapping at 360 means nothing.
122
+
123
+ - **`locate_extreme_cell`** answers where the lowest or highest value of a
124
+ raster is, as a coordinate. No operation could be asked that before, which is
125
+ why MapSmith reported `unsupported` twice on Argleton's `grid-registration`
126
+ family — the one whose whole question is whether a system knows where its own
127
+ cells are. Nodata is excluded rather than competing for the minimum, and a tie
128
+ is reported rather than broken in silence: two cells at the same extreme is a
129
+ plateau, a flat pond or a saturated sensor, and naming one of them is a
130
+ confident answer to a question about a region.
131
+
132
+ - **Ten more operations, and this time the gap they fill is a shape rather
133
+ than a subject.** Of sixty-one catalogue entries, three produced an `answer`
134
+ and none of them took a vector layer — so the commonest question in GIS, *how
135
+ much land is there*, had no operation for anybody holding parcels, and a
136
+ search declaring it came back empty. Four operations close that:
137
+ `summarize_field` (totals, averages and extremes of an attribute, per group if
138
+ asked), `spatial_autocorrelation` (global Moran's I — whether the map holds a
139
+ pattern at all, which is the question to ask before `hot_spots` shows where),
140
+ `nearest_neighbour_index` (Clark-Evans R, with the study area treated as the
141
+ decision it is rather than taken silently from the points' own extent), and
142
+ `compare_layers` (what actually changed between two versions of a delivery).
143
+ None of them writes a file, so none carries a manifest: there is no artefact
144
+ to attach one to, and inventing a file to have something to sign would be
145
+ worse than the gap.
146
+
147
+ Three answer requests from the discovery benchmark that neither labeller could
148
+ place. `contour_lines` turns a surface into isolines. `least_cost_path` routes
149
+ across a cost raster where `network_shortest_path` needs roads — charging per
150
+ unit of DISTANCE, so a diagonal step costs sqrt(2) and not 1, which is the
151
+ difference between a route a person would walk and a staircase.
152
+ `transform_by_control_points` puts a survey on real ground by fitting a
153
+ transform from points known in both systems: *"my traverse is sitting in the
154
+ middle of the river"* has an answer, and the answer that matters is the
155
+ residual, which is in the manifest per control point with the worst one named.
156
+ An exactly determined fit says in words that its zero residual is evidence of
157
+ nothing.
158
+
159
+ And three that existed only inside other operations, which means they existed
160
+ only for whoever already knew where to look: `snap_layer`, `points_along_lines`
161
+ (chainage, what `elevation_profile` does before it reads a surface) and
162
+ `line_intersections`.
163
+
164
+ All ten are catalogue-only. The exposed tool list stays at 28, because that is
165
+ the count with a ceiling and capability is the count without one.
166
+
167
+ - **MapSmith can record its own discovery cases, and deliberately does not
168
+ learn from them.** Set `MAPSMITH_DISCOVERY_LOG=<path>` and every catalogue
169
+ search is written as one JSON line together with the operation run after it:
170
+ the query, the facets declared, which engine ranked it, every candidate
171
+ delivered, and where in that list the chosen one sat.
172
+ `benchmarks/log_to_cases.py` turns those lines into rows shaped like the
173
+ discovery benchmark and flags the two worth reading — a choice the ranking did
174
+ not put first, and a search nothing followed.
175
+
176
+ The figures MapSmith publishes rest on 155 requests written by two language
177
+ models, which is the best set obtainable without users and is not what users
178
+ ask. This closes that gap without closing a feedback loop: a ranker trained on
179
+ what callers pick learns from an ordering it produced, and the pinned model
180
+ revision is what makes a search reproducible a year later. So the log produces
181
+ benchmark rows for a person to accept or discard, and the improvement lands in
182
+ the catalogue text as a reviewable diff. Off unless the variable is set; holds
183
+ queries and operation names but never dataset paths or arguments; confined by
184
+ `MAPSMITH_WORKSPACE` like any other path MapSmith writes; never leaves the
185
+ machine.
186
+
187
+ - **Ten operations, chosen by measurement rather than by taste.** The discovery
188
+ benchmark holds 155 requests written by other model families; twenty-one of
189
+ them were marked `none` by BOTH independent labellers, meaning the catalogue
190
+ could not serve them at all. Grouped, those twenty-one are six families, and
191
+ these ten cover four of them:
192
+
193
+ `sample_raster_at_points`, `elevation_profile` and `line_of_sight` read a
194
+ surface at a place, along a line, and between two positions. Every one counts
195
+ the positions it could not read and puts that count in the manifest, because a
196
+ null that reads as a value is how a nodata of -9999 ends up in an average.
197
+ `line_of_sight` requires an explicit answer on earth curvature: over 30 km the
198
+ planet drops 62 m net of refraction, and there is no way to guess whether the
199
+ caller has a rooftop survey or a radio link.
200
+
201
+ `network_shortest_path` and `service_area` route over a line network the caller
202
+ supplies. The failure they are built around is not a wrong number, it is a
203
+ disconnected graph: two street segments a millimetre apart are one junction on
204
+ the ground and two nodes in a naive build, so the route detours or is reported
205
+ impossible and nothing raises. `tolerance` therefore has no default, and every
206
+ manifest carries the component count, the merged-endpoint count and how far
207
+ each end snapped to the network. `service_area` cuts the last segment where the
208
+ budget runs out — a ten-minute walk ends where the walk ends, not at the next
209
+ junction — and it is a network operation rather than a buffer because a circle
210
+ includes the far bank of a bridgeless river and excludes the house 900 m away
211
+ along a straight road.
212
+
213
+ `viewshed` maps what a set of stations can see. **Its output is a COUNT of
214
+ stations, not a 0/1 flag**, which contradicts Whitebox's own documentation:
215
+ the help says "a Boolean raster, containing 1's and 0's", and measured on 2.0.6
216
+ with two stations on flat ground every cell comes back 2.0. Second time this
217
+ library's prose has described the reverse of what its code does, after the D8
218
+ pointer table.
219
+
220
+ `hot_spots`, `smooth_rates`, `aggregate_to_threshold` and `thin_points` are for
221
+ the moment before somebody publishes a map of noise. Getis-Ord Gi* with a
222
+ Benjamini-Hochberg correction, because at 0.05 over 300 districts about fifteen
223
+ hot spots appear from nothing and they will be somewhere. Empirical-Bayes rates,
224
+ because a choropleth of raw rates over small populations is a map of population
225
+ size. Disclosure-control aggregation that refuses rather than publishing the one
226
+ island it could not merge. And deterministic point thinning that says in the
227
+ manifest that it removed data.
228
+
229
+ - **A dashboard for the whole project** (`benchmarks/dashboard.py`), written
230
+ as one self-contained HTML file: no CDN, no fonts, no analytics, works with
231
+ the network off. Six panels — what exists, whether every operation can
232
+ actually be found, the retrieval figures and the degradation curve, Argleton's
233
+ traps and what each engine does with them, the open questions answered by
234
+ clicking, and a trend appended on each generation.
235
+
236
+ It exists because the numbers were spread across a report nobody reruns, a
237
+ test that prints a curve nobody reads, and a suite in another repository. The
238
+ tuning panel is the point: an operation nothing reaches does not exist, and
239
+ this says which ones, at what rank, with words alone and with the facets a
240
+ caller would declare.
241
+
242
+ Two things it caught immediately, both about itself. It drew ten working
243
+ operations as unreachable by collapsing "the search declined because the two
244
+ rankers shared nothing" into "not found" — three outcomes, one label. And the
245
+ MapSmith adapter appeared as `adapters.mapsmith:Adapter` because the label was
246
+ read from an import path. With those apart: 51 of 61 operations found by words
247
+ alone, 55 in the top three once facets are declared, none unreachable.
248
+
249
+ ### Fixed
250
+
251
+ - **A DEM whose rows run south to north no longer produces a slope eight times
252
+ too steep.** A GeoTIFF's geotransform may have a positive fifth element,
253
+ meaning row 0 is the southern edge — NetCDF, GRIB and HDF index latitude
254
+ upwards, so a straight conversion produces one. whitebox-workflows cannot
255
+ express that and does not say so: it discards the georeferencing and reads the
256
+ grid as unit cells at the origin. MapSmith answered **45 degrees where the
257
+ ground is 5.71**, wrote the output raster at the origin at a tenth of the
258
+ site's size, and **passed all five of its own checks** — `crs_matches` among
259
+ them, because the coordinate system survived and only the geotransform did
260
+ not.
261
+
262
+ Such a file is now rewritten north-up before the engine sees it, the same
263
+ mechanism already used for the TIFF predictor, and the rewrite is disclosed in
264
+ the manifest. Behind that is the general guard: MapSmith now compares the
265
+ engine's idea of the grid with GDAL's and **refuses** if they differ for any
266
+ other reason, because a number computed on a grid nobody can reconcile carries
267
+ a correct-looking CRS on top of it and nothing downstream can tell.
268
+
269
+ Found by [Argleton](https://argleton.org) trap 026.
270
+
271
+ - **Measuring a layer that holds more than one kind of geometry now says so.**
272
+ A GeoPackage layer may declare its type as GEOMETRY and hold whatever it likes;
273
+ only the shapefile enforced one type per file, which is why mixed layers
274
+ arrive exactly when data is converted out of shapefiles. `length` on a polygon
275
+ is its perimeter — true, and the right answer to a question nobody asked — so
276
+ a pipe network with a treatment plant in the same layer totalled **3000 m
277
+ where the pipe is 2000**.
278
+
279
+ `measure_length` and `measure_area` now carry a non-critical check naming the
280
+ geometry types present, with the remedy. It is not an error: measuring a mixed
281
+ layer is a legitimate request and MapSmith cannot know which features the
282
+ question was about. What it can do is refuse to let the total arrive without
283
+ the sentence — especially here, where **every individual row is still
284
+ correct**, so a spot check of the data confirms the data.
285
+
286
+ Found by Argleton trap 027.
287
+
288
+ - **MapSmith now reads `AREA_OR_POINT`, everywhere at once.** GeoTIFF records
289
+ which of two conventions a grid uses — `RasterPixelIsArea`, where a value
290
+ describes the cell it fills, and `RasterPixelIsPoint`, where it is a sample at
291
+ a grid node — and they differ by half a pixel. Every USGS elevation product is
292
+ point-registered. **No line of MapSmith read the tag.** Every place that
293
+ turned a cell index into a coordinate did what `rasterio.xy` does, which is to
294
+ answer as if the file were area-registered: half a cell, 15 m on a 30 m DEM,
295
+ systematic, and with nothing in any output to say so.
296
+
297
+ That is the shape of #28 again — "open a vector file" as six copies of one
298
+ decision, four of them missing a branch — so the fix has the same shape as its
299
+ fix. `mapsmith/grid.py` is now the only place that decides where a value sits,
300
+ a test fails if a second copy appears, and every operation that converts
301
+ between cells and coordinates records the registration in its manifest,
302
+ including the ordinary case: a manifest that mentions the convention only when
303
+ it is unusual leaves a reader unable to tell *area* from *nobody looked*.
304
+
305
+ What changed behind that: sampling reads at the nodes on a point-registered
306
+ grid, so `sample_raster_at_points`, `elevation_profile` and `line_of_sight`
307
+ stop being half a cell off; `least_cost_path` starts, routes and ends on the
308
+ right cells; `contour_lines` applies its half-cell correction in the direction
309
+ the registration calls for, where an unconditional one put a USGS DEM's
310
+ contours a **whole** cell out; `zonal_statistics` offsets the zones for the
311
+ coverage computation so each cell is weighted around its own sample, and hands
312
+ back the caller's own geometry; and every raster MapSmith writes carries the
313
+ input's registration forward, where `profile.copy()` had been silently
314
+ converting point to area on the way out.
315
+
316
+ Found by building [Argleton](https://argleton.org) trap 024, which measures
317
+ exactly this and which MapSmith could not attempt. It can now, and it answers
318
+ both halves of the pair — the trap at 412090 and its clean twin at 412105,
319
+ fifteen metres apart, from files that differ in one metadata tag.
320
+
321
+ - **Whitebox places contour vertices half a cell from where they belong, and
322
+ MapSmith now corrects it.** Measured on the installed 2.0.6 with a planar ramp:
323
+ the contour for height 3 came back on the WEST EDGE of the column holding 3
324
+ rather than at its centre. On a 30 m DEM that is 15 m of horizontal error on
325
+ every contour — plausible, well-formed and wrong, which is the third time this
326
+ library's behaviour has diverged from its description here after the TIFF
327
+ predictor and the D8 pointer table.
328
+
329
+ The correction is verified rather than trusted: the DEM is read back at the
330
+ finished vertices and the elevation must equal the height each line claims, so
331
+ a future library version that fixes its own registration fails a critical
332
+ check instead of being silently double-corrected. `contour_lines` also ships
333
+ with Whitebox's default smoothing filter OFF, because a contour whose vertices
334
+ have been moved to look better no longer passes through the elevation it
335
+ names — smoothing is available, recorded in the manifest, and demotes that
336
+ check to non-critical, because then the output is a drawing rather than a
337
+ measurement.
338
+
339
+ - **A search whose facets leave nothing now says which declaration did it.**
340
+ Zero surviving candidates fell into the hand-over branch and came back as
341
+ `status: "choose"` with an empty list and the sentence "0 operations survive
342
+ what you declared, which is few enough to read" — nonsense as prose, and read
343
+ by an agent as *MapSmith cannot do this*. It was false in the case that found
344
+ it: "how much land is in each of these parcels" with `produces="answer"` left
345
+ nothing, while `measure_area` computes exactly that and declares
346
+ `dataset:vector` because it writes the areas into a column. That case is now
347
+ `status: "none_apply"`, listing each declaration with the number of operations
348
+ that would survive without it, smallest first. Arithmetic, not ranking. Found
349
+ by the discovery log above on its first recorded session.
350
+
351
+ ### Changed
352
+
353
+ - **`dataset_inputs` is a new facet, and a new REQUIRED field of the published
354
+ catalogue-entry specification.** How many datasets the caller is holding: 0, 1
355
+ or 2. It exists because the ten operations above broke discovery and it is the
356
+ thing that fixed it — measured, on the 118 independent requests: the surviving
357
+ set for the commonest facet combination went from 26 candidates to 34, over the
358
+ threshold at which the whole set is handed over, and `delivered` fell from 100%
359
+ to 45% with found@3 from 48% to 36%. With arity declared the median set is 9,
360
+ found@3 is 60% and delivered is back to 100%.
361
+
362
+ It is the right *kind* of facet, which is the part that generalises: a fact
363
+ about the caller's own situation rather than a guess about our taxonomy, and
364
+ derivable from each operation's signature so
365
+ `test_the_declared_arity_matches_the_binding` checks the declaration against
366
+ the code instead of trusting it.
367
+
368
+ ### Security
369
+
370
+ - **A statement that says `INSTALL` or `LOAD` is now refused in both modes, and
371
+ this is a breaking change for SQL that names an extension.** Until 0.4.0 only
372
+ the *implicit* forms were off, and an audit before this tag used `run_sql` in
373
+ the default mode to install DuckDB's `aws` extension and read the host's real
374
+ cloud credentials back through a tool result. An `INSTALL` is an HTTPS fetch of
375
+ a native binary executed in the server's process, on a statement written by a
376
+ model, so it is not a thing to do by default in either mode.
377
+
378
+ Extensions already loaded keep working, `spatial` included: MapSmith loads it
379
+ through the Python API before the configuration is locked, so no statement has
380
+ to ask for it. **What breaks** is SQL that says `LOAD spatial` or acquires
381
+ another extension. To allow specific ones, name them where the agent cannot
382
+ reach: `MAPSMITH_ALLOW_EXTENSIONS=postgres,azure` in the environment of the
383
+ process that starts the server — named extensions rather than a switch,
384
+ because "allow everything" is how a geoprocessing server ends up holding a
385
+ credential reader.
386
+
387
+ [SECURITY.md](SECURITY.md) carries the account, including why none of the four
388
+ layers that already existed saw it, and the fact that this is the second
389
+ promise in that file the code contradicted. Both were found by auditing before
390
+ a release rather than reported from outside, and both are recorded rather than
391
+ quietly corrected.
10
392
 
11
393
  ## [0.3.0] — 2026-08-29
12
394
 
@@ -75,7 +457,7 @@ are in `Changed` and one of them affects anybody passing `category=`.
75
457
  with a tool of its own: `measure_length`, `join_table`, `aggregate_weighted`,
76
458
  `parse_coordinates`, `point_on_surface`, `hull_layer`, `validate_geometry`,
77
459
  `count_in_polygons`, `focal_statistics` and `extract_streams`. The catalogue is
78
- at 51 operations (49 available, 2 planned) behind 28 tools.
460
+ at 74 operations (72 available, 2 planned) behind 28 tools.
79
461
  - **Overlay and dissolve declare their semantics in the manifest.** Dropped
80
462
  lower-dimension pieces from an overlay are named rather than silently absent,
81
463
  and a dissolve's aggregation is recorded with the group count verified in
@@ -0,0 +1,31 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use MapSmith in your work, please cite it as below."
3
+ title: "MapSmith: geospatial processing for AI agents, with verifiable provenance"
4
+ type: software
5
+ authors:
6
+ - name: "The MapSmith project"
7
+ repository-code: "https://github.com/mapsmith-ai/MapSmith"
8
+ url: "https://mapsmith.dev"
9
+ license: AGPL-3.0-or-later
10
+ version: "0.4.0"
11
+ date-released: "2026-08-31"
12
+ keywords:
13
+ - GIS
14
+ - geospatial
15
+ - provenance
16
+ - reproducibility
17
+ - Model Context Protocol
18
+ - MCP
19
+ abstract: >-
20
+ An MCP server that gives AI agents professional geoprocessing under a rule
21
+ the agent cannot bend: every geometry and every number comes from a real
22
+ engine run, never from the model. Each operation that writes a dataset also
23
+ writes a manifest beside it — the inputs and their hashes, the exact
24
+ parameters, the coordinate-system decisions and the reason for each, the
25
+ engine and its version, and the deterministic checks that ran — and the
26
+ manifest is written before a failed check raises, so the audit trail survives
27
+ the error. Capabilities are reached through a searchable catalogue rather
28
+ than a wall of tools, because a caller's ability to pick the right tool
29
+ degrades long before a catalogue does. A manifest records what was done; it
30
+ does not certify that what was done was right, and MapSmith says so in its
31
+ own documentation rather than leaving a reader to find out.