Most spatial questions people actually ask resolve into the same three operations in some order: grow something by a distance, combine it with something else, then merge the pieces. Getting that order right, with the units and reference systems stated, is nearly the whole job of planning — and it is where a plan quietly becomes an answer to a different question. This guide covers the chain specifically, and it is the concrete half of LLM-assisted geoprocessing pipelines.
When to Use This Approach
Use an explicit chain wherever the request contains a distance, a combination, or an aggregation — which is most of them.
| Request shape | Chain | The trap |
|---|---|---|
| “Within 500 m of a river” | buffer → intersect | Buffering in degrees |
| “Parcels touching any protected area” | overlay → dissolve | Dissolving before overlay |
| “Total area within 1 km of any school” | buffer → dissolve → intersect | Double-counting overlaps |
| “Land in both zones” | intersect only | Adding a buffer nobody asked for |
Implementation
The plan is a list of steps, each naming its operation, its inputs by reference, and every parameter that would otherwise be a default.
@dataclass
class Step:
op: str # buffer | intersect | dissolve | ...
inputs: list[str] # names of prior outputs or source layers
output: str
params: dict # distance_metres, crs, dissolve_by, ...
PLAN_SCHEMA = {
"type": "object",
"required": ["steps", "assumptions"],
"properties": {
"steps": {"type": "array", "minItems": 1, "maxItems": 8, "items": STEP_SCHEMA},
"assumptions": {
"type": "object",
"required": ["crs", "distance_unit"],
"properties": {
"crs": {"type": "string", "pattern": "^EPSG:[0-9]{4,6}$"},
"distance_unit": {"enum": ["metres", "feet"]},
},
},
},
}
Forcing assumptions to be present and structured is what makes the units visible. A model that must fill in a reference system will pick one; a model that may omit it will, and the buffer will run in whatever the data happened to be in.
The order check runs over the plan before anything executes. It is a handful of rules, each of which encodes a mistake that produces a plausible wrong answer.
def check_order(plan) -> list[str]:
problems = []
for i, step in enumerate(plan.steps):
if step.op == "buffer" and not is_projected(step.params["crs"]):
problems.append(f"step {i}: buffering in a geographic system — distances are degrees")
if step.op == "area" and any(s.op == "buffer" for s in plan.steps[:i]):
if not any(s.op == "dissolve" for s in plan.steps[:i]):
problems.append(f"step {i}: area after buffers with no dissolve — overlaps double count")
if step.op == "dissolve" and i + 1 < len(plan.steps):
if plan.steps[i + 1].op == "buffer":
problems.append(f"step {i}: dissolving before a buffer is usually the wrong order")
return problems
Validation & Testing
Test the planner on requests, not on plans. A fixture set of a few dozen sentences with expected step sequences catches regressions cheaply and without touching a database.
CASES = [
("schools within 500 m of a main road",
["buffer", "intersect"]),
("total area of parkland within 1 km of any school",
["buffer", "dissolve", "intersect", "area"]),
("parcels that touch a protected area",
["intersect"]),
]
@pytest.mark.parametrize("prompt,expected", CASES)
def test_plan_shape(prompt, expected):
plan = planner.plan(prompt)
assert [s.op for s in plan.steps] == expected
assert check_order(plan) == []
Separately, test the order checker with plans that are deliberately wrong. It is the component most likely to be quietly broken by a refactor, because a checker that returns an empty list always looks like it is working.
Gotchas & Edge Cases
Buffer in a geographic system. The most common single error, and it produces a result rather than a failure. Requiring a projected system in the assumptions, and rejecting plans that buffer without one, closes it entirely.
Dissolve placed after the area calculation. Areas summed over overlapping buffers double-count the overlaps. The rule is that any aggregation over buffered geometry needs a dissolve between them, and it is worth stating that as a check rather than trusting the plan.
Choosing the projection by convenience. A national grid is fine within its country and increasingly wrong outside it. Where the region spans zones, an equal-area projection appropriate to the extent is the honest choice, and it should appear in the assumptions rather than being applied inside a helper.
Overlay against unclean inputs. Intersecting geometry that has not been validated produces slivers, and slivers propagate into the dissolve as fragments that inflate feature counts. Validity is a precondition of the overlay step, not a separate concern.
Chains that grow. A model with a long example list will decompose a two-step request into five, each individually reasonable. Capping the step count in the schema is crude and effective; reviewing the median step count over time catches the drift before the cap starts firing.
Showing the Plan Before Running It
For anything expensive, the plan is worth putting in front of the reader before it executes — not as a technical listing but as a sentence describing what will happen and under which assumptions. “I’ll take everything within 500 metres of a river, measured in the national grid, and total the parkland inside it” is a plan a person can correct, and the correction costs nothing at that point.
The value is concentrated in the assumptions rather than the steps. Readers rarely dispute that a buffer should precede an intersection; they frequently dispute a threshold, a unit, or which version of a dataset was used, and every one of those is invisible unless the plan states it. A confirmation that only shows step names therefore gets agreement without conveying anything.
Tie the confirmation to estimated cost rather than showing it always. A plan that will finish in two seconds should just run, and one that will take four minutes and produce a number somebody will put in a document should be agreed first. That threshold is the same estimate used for routing, which means the confirmation costs nothing extra to implement and appears exactly when a reader’s attention is worth interrupting.
Operating This Step Over Time
Track how often plans are rejected by the order checker and which rule fired. A rule that fires constantly is telling you the prompt needs an example rather than that the checker is working, and the fix belongs upstream.
Watch the assumption values too. If the same reference system appears in almost every plan, it is effectively a default and should become one explicitly, which removes a chance for the model to pick something else. If several appear, the ones that are wrong for their regions are visible directly.
The checker itself needs maintenance of a kind that is easy to forget. Each rule encodes a mistake somebody actually made, and rules added in response to incidents accumulate without anyone removing the ones that no longer apply — a check against an operation that has since been dropped from the vocabulary, for instance, or one that duplicates a constraint the schema now enforces. Reading the rule list alongside the schema once or twice a year keeps the two from drifting into contradiction, which is the state in which a plan is rejected for a reason nobody can explain.
Keep a small set of worked examples current as well. The fixtures used in testing double as the reference for what a good plan looks like, and when the vocabulary changes they are the first thing that should be updated — before the prompt, because the prompt’s examples should be drawn from them rather than written independently.
Frequently Asked Questions
Should the model choose the projection?
It should propose one and the system should validate it against the extent. A model has a reasonable sense of which national grid suits which country, and no way to know whether a request spans two of them. Checking the proposed system against the bounding box of the inputs catches the case where a locally correct choice is regionally wrong.
What if the request needs an operation the plan vocabulary lacks?
Fail explicitly rather than approximating. A model asked for a network distance and given only buffer, overlay and dissolve will produce a straight-line approximation and present it as the answer, which is worse than a refusal naming the missing capability. Keeping the vocabulary small is fine; keeping it small silently is not.
How should the dissolve field be chosen?
From the question, and stated. "Total area" dissolves everything; "area by district" dissolves by district. The two produce very different answers and the difference is a single parameter, which makes it exactly the sort of thing that should appear in the plan rather than being inferred inside an implementation.
Is it worth caching intermediate chain outputs?
The buffer and dissolve steps, yes — they are expensive, deterministic, and frequently reused across questions about the same features. The intersection usually is not, because it depends on both inputs and the second one varies with the question. Keying on operation, inputs and parameters makes the distinction fall out naturally.
Related
- Up to the parent topic: LLM-Assisted Geoprocessing Pipelines
- Decomposing Natural Language into Geoprocessing Steps
- Validating Intermediate Geoprocessing Outputs
- Related topic: Topology Rule Enforcement via LLMs