make_valid Can Return a Line Instead of a Polygon
A successful make_valid() call is not the same thing as a successful polygon delivery.
That distinction matters whenever a GIS or Earth-observation pipeline accepts polygon inputs, runs Shapely repair, checks is_valid, and then assumes every passing object can continue downstream as a usable polygon footprint. Shapely explicitly documents that make_valid() can return lower-dimensional or mixed-dimensional geometries. With the linework method, a polygon may become a MultiLineString or GeometryCollection; with structure, a collapsed polygon may become a LineString or, when collapsed components are discarded, an empty Polygon. Shapely also exposes validity and emptiness as separate predicates.
The admission question is therefore not simply:
is the repaired geometry valid?
It is:
does this repaired result satisfy the declared nonempty polygon-only
delivery contract, and has any changed footprint been authorized?
This playbook uses a fixed synthetic planar fixture: a rectangle, a collapsed ring, a self-crossing bowtie, and a polygon with an out-and-back spike. No real parcels, imagery, ownership claims, CRS transformations, raster operations, or point assignments are involved. The fixture defines twelve repair combinations plus four controls. The rectangular baseline keeps the identifier square in the code and result tables.
The driver was executed on October 19, 2026 with CPython 3.13.5, Shapely 2.1.2, GEOS 3.13.1 and NumPy 2.3.5. All sixteen case groups matched the stated expectations. The twelve main results were all topologically valid, yet their policy dispositions were 3 ACCEPT_BASELINE, 5 REVIEW_REPAIR, and 4 HOLD_NONPOLYGON_OR_EMPTY. This is the defect the laboratory exposes: is_valid == True is much weaker than polygon-output acceptance.
Declare the polygon-only delivery contract before repairing anything
The output contract should exist before the first call to make_valid(). Otherwise, the repair algorithm effectively defines the schema after seeing the data, which reverses the direction of control.
For this laboratory, the delivery contract is deliberately narrow:
Property | Required state |
Missingness | Geometry must not be None |
Emptiness | Geometry must be nonempty |
Topological validity | is_valid must be true |
Geometry family | Polygon or MultiPolygon only |
Dimensional result | Area-bearing polygonal result; no line-only or mixed result |
Semantic approval | Any changed footprint requires domain authorization |
Evidence | Original geometry and complete repair result must both be retained |
The first four checks determine whether an object is technically eligible for a polygon-only candidate dataset. They do not decide whether a changed object represents the intended real-world footprint.
That semantic boundary is critical. Shapely's is_valid predicate accepts geometry types generally; its documentation even demonstrates a valid LineString, while missing values return false. Validity does not mean "is a polygon," "contains area," "is nonempty," or "matches the data owner's intent."
Similarly, is_empty answers a different question. An empty geometry and a missing geometry are not interchangeable: the documentation shows that is_empty(Point()) is true while is_empty(None) is false. A robust gate therefore tests missingness separately instead of treating an emptiness predicate as a universal input-presence test.
For this contract, the dispositions are explicit.
ACCEPT_BASELINE applies only to the already approved baseline rectangle when the repaired result is geometrically equal to that unchanged footprint. This is not a general "valid polygon" status.
REVIEW_REPAIR applies to a valid, nonempty Polygon or MultiPolygon produced from one of the deliberately invalid inputs. It is technically eligible for review, but it remains a repair candidate rather than an approved delivery.
HOLD_NONPOLYGON_OR_EMPTY applies whenever the result is mixed-dimensional, lower-dimensional, or empty. No polygon extraction, line deletion, row deletion, or empty-object acceptance may occur implicitly.
REPAIR_AND_RERUN is a workflow action, not an automatic fallback. It means an authorized owner has chosen a repair method and an output policy, after which the process reruns from the retained original. It does not mean "take the polygon part of whatever make_valid() returned."
This contract should remain separate from point-to-polygon membership. Whether a point is inside, covered by, or ambiguously located on a polygon boundary is a different decision problem. The related guide to boundary ownership and assignment policy addresses those predicates, holds invalid geometry for resolution, and treats geometric repair as a separate contract.
The central rule is simple:
A library-success result enters downstream processing only when its output type, emptiness, validity, dimensionality, and authorization state all satisfy the declared contract.
That rule is stricter than result.is_valid, by design.
Freeze coordinates, software, repair options, and original evidence
The synthetic coordinates are already defined in one planar coordinate system. No CRS is assigned or changed, and no reprojection, axis swapping, snapping, or coordinate transformation is part of the test. Keeping that boundary fixed prevents topology repair from becoming entangled with a separate coordinate-provenance problem. The related guide to coordinate validation and CRS provenance addresses that upstream problem; the present fixture assumes its coordinate contract is already settled.
The tested software must also be frozen in the evidence record.
The execution reported this environment:
CPython: 3.13.5
Shapely: 2.1.2
GEOS: 3.13.1
NumPy: 2.3.5
The Shapely and GEOS pairing agrees with Shapely's 2.1.2 release notes, which state that the 2.1.2 wheels included GEOS 3.13.1.
Shapely's official release notes list version 2.2.0, dated October 7, 2026, after the 2.1.2 build used here. The observations in this article are pinned to 2.1.2 and have not been reproduced here under 2.2.0. A version upgrade is a new test condition and should generate a new observation record.
The relevant make_valid behavior is documented in the pinned Shapely 2.1.2 make_valid reference. Two methods are exposed. linework nodes the input rings into linework and extracts polygons while trying to preserve every edge and vertex. structure reasons from shell and hole organization: it makes rings valid, merges shells, and subtracts holes. Crucially, the documentation says structure assumes that shells and holes are correctly categorized in the input. That assumption is why structure should not be described as universally preferable merely because it produces a cleaner polygon in one synthetic case.
The same make_valid reference documents that structure requires GEOS 3.10 or newer, that method selection was added in Shapely 2.1.0, and that keep_collapsed controls whether collapsed components survive under structure. For linework, keep_collapsed must remain true. These are documented interface conditions, not rules inferred from this fixture.
The four immutable sources are:
square:
(0,0) -> (4,0) -> (4,3) -> (0,3) -> (0,0)
collapsed:
(0,0) -> (3,0) -> (6,0) -> (0,0)
bowtie:
(0,0) -> (4,4) -> (0,4) -> (4,0) -> (0,0)
spike:
(0,0) -> (4,0) -> (4,3) -> (2,3) ->
(2,5) -> (2,3) -> (0,3) -> (0,0)
The source named square is an already valid 4-by-3 rectangle, approved as the unchanged baseline, with diagnostic area 12 square coordinate units. The collapsed source traces a ring entirely along one line. The bowtie self-crosses and has two triangular lobes under the expected repair for this fixture. The spike follows a rectangle but leaves the top edge at (2,3), travels to (2,5), and returns along the same segment before completing the outline.
Those descriptions are fixture semantics, not claims about cadastral interpretation.
The expected type and area matrix for this fixed synthetic fixture is:
Input | linework, keep=True | structure, keep=True | structure, keep=False |
square | Polygon; area 12 | Polygon; area 12 | Polygon; area 12 |
collapsed | MultiLineString; area 0 | LineString; area 0 | Polygon EMPTY; area 0 |
bowtie | MultiPolygon; area 8 | MultiPolygon; area 8 | MultiPolygon; area 8 |
spike | GeometryCollection; area 12 | Polygon; area 12 | Polygon; area 12 |
These values serve as explicit assertion targets for the fixture. They are diagnostic expectations for synthetic examples, not a rule saying equal area proves identity or a changed area proves error. All areas in this article use square coordinate units, not an inferred geographic unit.
The complete executable driver follows. After all checks pass, it emits a JSON report containing original and output WKB, output WKT, options, recursive component paths, validity, emptiness, area, polygon eligibility, and the disposition for every main case. It accumulates that report in memory; it is not a durable production journal for failed attempts.
Save the driver as make_valid_fixture.py in an isolated CPython 3.13.5 environment. Python can omit assert statements under optimization, so the driver rejects optimized execution. Install the pinned wheels and run:
python -m pip install --only-binary=:all: "shapely==2.1.2" "numpy==2.3.5"
python make_valid_fixture.py > repair-observations.json
python -m json.tool repair-observations.json
Require a zero exit status from the driver, a parseable JSON report, case_count equal to 16, and status equal to EXPECTED_OUTCOMES_MATCH. A failure before the final print leaves no complete report; do not treat an empty or partial output file as evidence of success.
"""Check a nonempty polygon-only admission contract."""
import json
import sys
from collections import Counter
import numpy as np
import shapely
from shapely.geometry import (
GeometryCollection, LineString, MultiPolygon, Polygon,
)
if not debug:
raise RuntimeError("Run without -O or PYTHONOPTIMIZE.")
INPUTS = {
"square": Polygon([(0, 0), (4, 0), (4, 3), (0, 3), (0, 0)]),
"collapsed": Polygon([(0, 0), (3, 0), (6, 0), (0, 0)]),
"bowtie": Polygon([(0, 0), (4, 4), (0, 4), (4, 0), (0, 0)]),
"spike": Polygon([
(0, 0), (4, 0), (4, 3), (2, 3),
(2, 5), (2, 3), (0, 3), (0, 0),
]),
}
MODES = [("linework", True), ("structure", True), ("structure", False)]
# Fixed synthetic expectations, not real-world ownership evidence.
EXPECTED = {
"square": [("Polygon", 12), ("Polygon", 12), ("Polygon", 12)],
"collapsed": [("MultiLineString", 0), ("LineString", 0), ("Polygon", 0)],
"bowtie": [("MultiPolygon", 8), ("MultiPolygon", 8), ("MultiPolygon", 8)],
"spike": [("GeometryCollection", 12), ("Polygon", 12), ("Polygon", 12)],
}
def leaves(geom, path=()):
if geom is None:
return []
if (geom.geom_type == "GeometryCollection"
or geom.geom_type.startswith("Multi")):
return [
part
for i, child in enumerate(shapely.get_parts(geom))
for part in leaves(child, path + (i,))
]
return [{"path": list(path), "type": geom.geom_type,
"empty": bool(shapely.is_empty(geom)), "wkt": geom.wkt}]
def eligible(geom):
return (geom is not None and not shapely.is_empty(geom)
and shapely.is_valid(geom)
and geom.geom_type in {"Polygon", "MultiPolygon"})
def main():
records = []
for rid, source in INPUTS.items():
input_wkb_hex = source.wkb_hex
for i, (method, keep) in enumerate(MODES):
result = shapely.make_valid(
source, method=method, keep_collapsed=keep,
)
expected_type, expected_area = EXPECTED[rid][i]
assert result.geom_type == expected_type
assert result.is_valid and result.area == expected_area
assert result.is_empty == (
rid == "collapsed" and method == "structure" and not keep
)
permitted = bool(eligible(result))
assert permitted == (
rid in {"square", "bowtie"}
or (rid == "spike" and method == "structure")
)
# Algorithm success is not permission to discard components.
status = "ACCEPT_BASELINE" if rid == "square" else (
"REVIEW_REPAIR" if permitted
else "HOLD_NONPOLYGON_OR_EMPTY"
)
if rid == "square":
assert source.equals(result)
records.append({
"id": rid, "method": method, "keep_collapsed": keep,
"input_valid": source.is_valid,
"input_wkb_hex": input_wkb_hex,
"output_wkb_hex": result.wkb_hex,
"output_wkt": result.wkt,
"type": result.geom_type, "valid": result.is_valid,
"empty": result.is_empty, "area": result.area,
"leaves": leaves(result), "polygon_eligible": permitted,
"decision": status,
})
assert not eligible(None)
assert not eligible(Polygon())
assert shapely.is_valid(Polygon())
try:
shapely.make_valid(
INPUTS["collapsed"], method="linework", keep_collapsed=False,
)
except ValueError:
invalid_option = "REJECTED"
else:
raise AssertionError("Invalid linework option was accepted")
nested = GeometryCollection([
GeometryCollection([LineString([(8, 0), (9, 0)])]),
MultiPolygon([INPUTS["square"]]),
])
assert [p["type"] for p in leaves(nested)] == ["LineString", "Polygon"]
counts = Counter(record["decision"] for record in records)
assert counts == {
"ACCEPT_BASELINE": 3, "REVIEW_REPAIR": 5,
"HOLD_NONPOLYGON_OR_EMPTY": 4,
}
controls = {
"missing": "HOLD", "empty": "HOLD",
"invalid_option": invalid_option, "recursive_parts": "MATCH",
}
report = {
"python": sys.version, "shapely": shapely.__version__,
"geos": shapely.geos_version_string, "numpy": np.__version__,
"cases": records, "controls": controls,
"decision_counts": dict(counts),
"case_count": len(records) + len(controls),
"status": "EXPECTED_OUTCOMES_MATCH",
}
print(json.dumps(report, indent=2))
if name == "__main__":
main()For these finite two-dimensional geometries, WKB serialization retains the coordinates and complete returned components. It is not a complete dataset archive: retain source IDs, attributes, coordinate-reference metadata and relevant serialization settings separately. Default WKB export does not include an SRID. The baseline assertion uses spatial equality rather than WKT or WKB string identity, because the same footprint can have a different coordinate traversal order.
For review work, the minimum proof unit should be:
source ID
original WKB
repair method
repair options
full result WKB
recursive component paths
result type
validity
emptiness
diagnostic area
technical eligibility
policy disposition
software versions
In production, persist the original geometry and its provenance before repair, and record each attempt and exception. Never overwrite the original geometry column with a repaired object before that evidence is retained. The laboratory driver above demonstrates result checks; production durability requires that additional storage workflow.
Run linework and reconcile every returned component
The linework method clearly demonstrates why output admission cannot be inferred from the input class.
Shapely's linework documentation says the method attempts to preserve the input's edges and vertices, nodes them, and extracts valid polygons using an even-odd strategy for assigning enclosed regions. It warns that comparatively simple invalid geometry can generate complex output, and its example shows a polygon repair returning a MultiLineString.
The execution produced these linework results:
Source | Output type | Empty | Area | Recursive leaves | Policy |
square | Polygon | False | 12 | Polygon | ACCEPT_BASELINE |
collapsed | MultiLineString | False | 0 | LineString, LineString | HOLD_NONPOLYGON_OR_EMPTY |
bowtie | MultiPolygon | False | 8 | Polygon, Polygon | REVIEW_REPAIR |
spike | GeometryCollection | False | 12 | Polygon, LineString | HOLD_NONPOLYGON_OR_EMPTY |
The baseline rectangle is uncomplicated. Its result remains a nonempty valid Polygon with area 12, and source.equals(result) is true. Because this source is the explicitly authorized unchanged baseline, its disposition is ACCEPT_BASELINE.
The collapsed ring is more instructive. Its exact linework result in this run was:
MULTILINESTRING ((0 0, 3 0), (3 0, 6 0))
It is nonempty and valid. It is also not a polygon. A pipeline whose only postcondition is:
assert result.is_valid
would let it through even though the polygon-only contract has been lost completely.
That is not a Shapely defect. The result is consistent with the documented possibility of lower-dimensional make_valid output. The pipeline defect is assuming that validity preserves geometry family.
The bowtie becomes:
MULTIPOLYGON (
((4 0, 0 0, 2 2, 4 0)),
((4 4, 2 2, 0 4, 4 4))
)
Its two polygon leaves have total area 8. This passes the technical candidate gate because the contract explicitly permits both Polygon and MultiPolygon, the result is nonempty, and the result is valid. But it does not become ACCEPT_BASELINE: it is a repaired footprint and therefore remains REVIEW_REPAIR.
The spike is the strongest counterexample:
GEOMETRYCOLLECTION (
POLYGON ((4 3, 4 0, 0 0, 0 3, 2 3, 4 3)),
LINESTRING (2 3, 2 5)
)
Its polygon component is the 4-by-3 rectangle. Its line component is the original out-and-back segment. The aggregate geometry is valid. Its area is 12 because the line contributes no area.
Using the earlier driver definitions, the following example of an unsafe selection policy returns reassuring diagnostics and then selects only the polygon:
result = shapely.make_valid(INPUTS["spike"])
assert result.is_valid
assert result.area == 12
polygon = next(g for g in result.geoms if g.geom_type == "Polygon")
The assignment does not itself delete the LineString: result still contains the complete collection. However, forwarding or retaining only polygon without preserving result would discard the line evidence. Removing LineString (2 3, 2 5) from the delivered result is an output-policy decision, not an incidental implementation detail.
The correct reaction under the declared contract is HOLD_NONPOLYGON_OR_EMPTY. An authorized later policy may decide that lower-dimensional debris is irrelevant to the intended area feature, but that decision must be named, documented, and rerun from the source. The generic topology-repair stage must not invent it.
Recursive inspection matters for the same reason. Shapely's get_parts documentation explicitly warns that one call does not necessarily return individual parts of a Multi* geometry nested inside a GeometryCollection; multiple calls may be necessary.
The fixture therefore recurses:
def leaves(geom, path=()):
if geom is None:
return []
if (geom.geom_type == "GeometryCollection"
or geom.geom_type.startswith("Multi")):
return [
part
for i, child in enumerate(shapely.get_parts(geom))
for part in leaves(child, path + (i,))
]
return [{
"path": list(path),
"type": geom.geom_type,
"empty": bool(shapely.is_empty(geom)),
"wkt": geom.wkt,
}]Its nested negative control is deliberately constructed as:GeometryCollection
├── GeometryCollection
│ └── LineString
└── MultiPolygon
└── PolygonThe expected recursive leaf types are:
["LineString", "Polygon"]
The execution matched that expectation.
Component paths should be preserved as well. A flat list of types can tell you that a line exists, but a path such as [0, 0] or [1, 0] retains enough hierarchy to trace the leaf back through its parent result. In a production exception ledger, that makes later review much more defensible than a destructive "explode and keep polygons" operation.
Compare structure, collapse behavior, valid empties, and rejected options
The structure method changes the shape of the admission problem.
Under Shapely 2.1.2, the structure method first makes rings valid, then reasons about shells and holes. The documentation conditions that reasoning on shell/hole categorization being correct in the input. A concise polygon result is therefore not proof that structure discovered the intended geography; it means the algorithm produced a valid result under its documented assumptions.
With keep_collapsed=True, the execution produced:
Source | Output | Empty | Area | Policy |
square | Polygon | False | 12 | ACCEPT_BASELINE |
collapsed | LineString | False | 0 | HOLD_NONPOLYGON_OR_EMPTY |
bowtie | MultiPolygon | False | 8 | REVIEW_REPAIR |
spike | Polygon | False | 12 | REVIEW_REPAIR |
The collapsed source becomes:
LINESTRING (0 0, 3 0, 6 0, 0 0)
Again, it is valid and nonempty but fails the polygon contract.
The bowtie remains a two-part MultiPolygon, with area 8:
MULTIPOLYGON (
((0 0, 2 2, 4 0, 0 0)),
((2 2, 0 4, 4 4, 2 2))
)
Its coordinate order differs from the linework representation, which is one reason string equality is not an appropriate cross-method oracle. The policy question is not whether two WKT strings happen to match; it is whether the chosen repair method is authorized and whether the resulting footprint satisfies the review criteria.
The spike becomes a single polygon:
POLYGON ((0 0, 0 3, 2 3, 4 3, 4 0, 0 0))
This is technically polygon-eligible, but its status is still REVIEW_REPAIR. The structure result omits the out-and-back spike from the area geometry even with keep_collapsed=True. Preserving collapsed components does not promise preservation of every retraced edge in a ring that still yields a polygon. Nothing in a valid Polygon, area 12, or clean-looking WKT proves that the segment was accidental. Deciding that it carried no domain meaning belongs to the data owner or domain reviewer.
The keep_collapsed=False branch exposes a different failure mode. Shapely documents that this option controls whether lower-dimensional collapsed components are retained under structure; it also documents that the option must remain true for linework.
The three non-collapsed cases remain polygonal:
square -> Polygon, area 12
bowtie -> MultiPolygon, area 8
spike -> Polygon, area 12
The collapsed source, however, becomes:
POLYGON EMPTY
The execution recorded all four properties simultaneously:
type = Polygon
valid = True
empty = True
area = 0.0
This is the control that defeats a type-only gate:
result.geom_type in {"Polygon", "MultiPolygon"}
and it also defeats a validity-only gate:
shapely.is_valid(result)
Both are true. The delivery still fails because there is no usable nonempty footprint.
This distinction follows the API separation between validity and emptiness: is_valid asks whether a geometry is well formed, whereas is_empty explicitly tests whether a geometry has no geometric content.
An empty polygon should therefore retain its source ID and the repair evidence with:
HOLD_NONPOLYGON_OR_EMPTY
It should not disappear from the row population, and it should not be converted to a successful zero-area delivery.
Missing input is a separate control:
eligible(None) is False
That path should be caught before repair. Shapely documents None as a missing geometry value, and is_empty(None) is false, which is another reason the pipeline should not infer "present" from a false emptiness test.
The invalid method-option combination also needs a negative control:
shapely.make_valid(
INPUTS["collapsed"],
method="linework",
keep_collapsed=False,
)
The execution raised ValueError, so the control recorded:
invalid_option = "REJECTED"
That behavior is consistent with the 2.1.2 documentation, which states that keep_collapsed must be true for linework.
Do not catch that exception and replace it with Polygon(). Doing so would turn an invalid invocation into a valid-looking empty object and destroy the distinction between "library rejected the requested operation" and "the repair produced an empty geometry."
The decision logic can be summarized as follows. HOLD is the general rejection route for missing, empty, invalid or non-polygonal candidates. Record the actual rejection reason; HOLD_NONPOLYGON_OR_EMPTY is the narrower label used for the four rejected main cases in this fixture.
Figure 1. Candidate admission and authorized recovery from originals.
Every rejection preserves exception evidence. A rerun requires an approved method and output policy, and starts from the retained original. Selecting polygon components or accepting empties cannot happen as an implicit fallback.
Build the admission gate without confusing diagnostics with meaning
The candidate-admission function in the driver is intentionally small. It accepts a single Shapely geometry or None under the established planar coordinate assumptions. It does not validate arbitrary Python objects, arrays, CRS provenance, application-specific area thresholds, or domain approval:
def eligible(geom):
return (
geom is not None
and not shapely.is_empty(geom)
and shapely.is_valid(geom)
and geom.geom_type in {"Polygon", "MultiPolygon"}
)Its strength comes from what surrounds it, not from cleverness inside it.
First, the function checks presence. A missing geometry does not participate.
Second, it checks emptiness independently.
Third, it checks topological validity.
Fourth, it checks the declared geometry family.
Only then does it classify an object as technically polygon-eligible.
That result should be named accordingly. A variable called approved, safe, or correct would overstate what has been established. polygon_eligible, as used in the driver, is much better because it describes the narrow schema decision actually made.
The distinction becomes obvious when the five repaired polygonal candidates are examined:
bowtie / linework / keep=True
bowtie / structure / keep=True
bowtie / structure / keep=False
spike / structure / keep=True
spike / structure / keep=False
All five are nonempty, valid, and polygonal. None receives automatic acceptance. They all receive REVIEW_REPAIR.
The baseline rectangle is different because its status comes from an external authorization condition:
if rid == "square":
assert source.equals(result)
That equality check shows that the library result has the same geometric footprint as the authorized source baseline. It is not a universal proof of semantic correctness. It works here because the rectangle was predeclared as the approved unchanged control.
Area is similarly diagnostic rather than dispositive.
The spike demonstrates the weakness of an area-only rule. Its source scenario is designed so that the repaired polygon has area 12 and the linework GeometryCollection also reports area 12. Yet those two outputs carry different component evidence:
linework:
GeometryCollection
Polygon area 12
LineString area 0
structure:
Polygon area 12
The following test checks area only:
assert result.area == 12
It cannot determine whether suppressing the line is authorized.
The bowtie illustrates the complementary issue. The expected repaired area is 8. That value is useful because it helps catch an unexpected algorithmic result for this exact fixture. It does not establish that "8" is the intended footprint of any real self-crossing feature.
This is the broader engineering principle:
Scalar summaries detect some discrepancies; they do not establish geometric identity or business meaning.
Type behaves the same way. A Polygon result is evidence about the geometry class, not evidence that the correct area survived. A matching record count shows only that the totals agree; it does not prove that the same source rows survived or that each repaired footprint is correct. A valid topology result is evidence that GEOS considers the geometry well formed under its rules, not a domain sign-off.
The evidence record must stay richer than a few aggregate fields. This abbreviated JSON display shows its structure; the WKB strings are shortened for readability:
{
"id": "spike",
"method": "linework",
"keep_collapsed": true,
"input_wkb_hex": "...",
"output_wkb_hex": "...",
"type": "GeometryCollection",
"valid": true,
"empty": false,
"area": 12.0,
"leaves": [
{"path": [0], "type": "Polygon"},
{"path": [1], "type": "LineString"}
],
"polygon_eligible": false,
"decision": "HOLD_NONPOLYGON_OR_EMPTY"
}
The complete driver records full WKB rather than the shortened placeholders shown here.
This is also why raster evidence should not be imported into the same acceptance rule. A raster's validity population is a different object with different failure modes; the related article on raster validity and copy completeness illustrates how matching counts can conceal different valid-pixel memberships. Its lesson is relevant to quality assurance, but the present workflow does not rasterize, copy masks, sample imagery, or determine raster-processing order.
The vector rule remains narrower: retain every repair component and judge the complete geometry returned by the repair, not an area-only projection of it.
Apply dispositions, recover from originals, and assign review ownership
The complete twelve-row main-case matrix from the execution is:
ID | Method | Keep collapsed | Output type | Valid | Empty | Area | Leaves | Decision |
square | linework | True | Polygon | True | False | 12 | Polygon | ACCEPT_BASELINE |
square | structure | True | Polygon | True | False | 12 | Polygon | ACCEPT_BASELINE |
square | structure | False | Polygon | True | False | 12 | Polygon | ACCEPT_BASELINE |
collapsed | linework | True | MultiLineString | True | False | 0 | LineString, LineString | HOLD_NONPOLYGON_OR_EMPTY |
collapsed | structure | True | LineString | True | False | 0 | LineString | HOLD_NONPOLYGON_OR_EMPTY |
collapsed | structure | False | Polygon | True | True | 0 | Polygon | HOLD_NONPOLYGON_OR_EMPTY |
bowtie | linework | True | MultiPolygon | True | False | 8 | Polygon, Polygon | REVIEW_REPAIR |
bowtie | structure | True | MultiPolygon | True | False | 8 | Polygon, Polygon | REVIEW_REPAIR |
bowtie | structure | False | MultiPolygon | True | False | 8 | Polygon, Polygon | REVIEW_REPAIR |
spike | linework | True | GeometryCollection | True | False | 12 | Polygon, LineString | HOLD_NONPOLYGON_OR_EMPTY |
spike | structure | True | Polygon | True | False | 12 | Polygon | REVIEW_REPAIR |
spike | structure | False | Polygon | True | False | 12 | Polygon | REVIEW_REPAIR |
The controls were:
Control | Observed result | Required interpretation |
None | Not eligible | HOLD missing input |
Polygon() | Valid but empty; not eligible | HOLD empty geometry |
linework, keep_collapsed=False | ValueError | Reject invalid option combination |
Nested collection recursion | LineString, Polygon leaves | Recursive inspection matched |
The counts reconcile exactly:
3 ACCEPT_BASELINE
5 REVIEW_REPAIR
4 HOLD_NONPOLYGON_OR_EMPTY
12 main repair cases
+ 4 controls
= 16 case groups
The fact that every one of the twelve repair outputs was valid is the point of the experiment, not a reason to relax the contract.
A production implementation should turn each disposition into an explicit release workflow.
For ACCEPT_BASELINE, preserve the normal evidence record, then admit the unchanged approved geometry.
For REVIEW_REPAIR, place the candidate in a review dataset alongside its immutable original. The reviewer should see the source ID, original geometry, candidate geometry, chosen method, keep_collapsed policy, component ledger, and relevant diagnostics. The repair must not replace its source merely because it looks cleaner.
For HOLD_NONPOLYGON_OR_EMPTY, preserve the complete result. In schematic type notation, do not reduce:
GeometryCollection(Polygon, LineString)
to:
Polygon
without an approved rule. Do not delete a source row because its geometry collapsed to lines. Do not promote POLYGON EMPTY because a schema validator accepts the class name.
For REPAIR_AND_RERUN, start again from the retained original artifact. This status requires two approvals:
· an approved repair method, such as the project's decision to use structure under conditions where its shell/hole assumptions have been reviewed; and
· an approved output policy, such as how lower-dimensional remnants, empty results, multipart polygon output, and review failures are to be represented.
That distinction prevents a common anti-pattern: first discard inconvenient components and only afterward describe the remaining polygon as the repair.
A controlled rerun should generate a new candidate dataset rather than mutating the old result in place. Suggested lineage fields include:
source_id
source_wkb
repair_run_id
shapely_version
geos_version
method
keep_collapsed
candidate_wkb
candidate_component_manifest
candidate_status
reviewer
review_decision
review_reason
approved_output_policy_version
The source WKB should remain immutable through all iterations.
Downstream consumers then need revalidation against the approved output policy. A consumer that previously assumed one simple Polygon per row may not support a legitimate MultiPolygon, even though the delivery contract permits one. That is not an argument for flattening or losing lobes; it means the consumer contract must be reconciled before release.
This is where the handoff to satellite-data analysis becomes an organizational issue rather than a Shapely issue: preprocessing and analytical interpretation meet at an evidence boundary, so repaired spatial inputs should arrive with their provenance and unresolved review state intact. The linked role article describes preprocessing and geospatial analytical responsibilities, but it is not normative documentation for Shapely behavior.
Ownership should be divided rather than concentrated in one "GIS script owner."
The library/toolchain owner controls supported Shapely/GEOS versions, dependency changes, executable probes, error handling, and regression evidence. A move from Shapely 2.1.2 to 2.2.0, for example, is an explicit revalidation trigger. The release notes document that newer version; this article's observations remain pinned to the tested environment.
The data-contract owner defines what the target dataset is allowed to contain: Polygon only, Polygon plus MultiPolygon, whether empties are representable, whether missing rows remain in the release, and how exception records are serialized.
The domain reviewer decides whether a changed footprint represents the intended feature. That responsibility cannot be delegated to is_valid, area equality, or the visual tidiness of a repaired polygon.
The related discussion of geospatial roles and responsibilities provides context for separating preprocessing, GIS analysis, and remote-sensing work. Those role descriptions do not change the technical acceptance contract established here.
Review should be retriggered when any of the following changes:
Shapely version
GEOS version
make_valid method
keep_collapsed policy
input construction logic
component-filtering policy
allowed geometry families
new mixed-dimensional output pattern
new empty-output pattern
domain interpretation of a repaired source
A passing fixture is not proof across all versions and polygon pathologies. It establishes something narrower: under CPython 3.13.5, Shapely 2.1.2, GEOS 3.13.1, NumPy 2.3.5 and these exact synthetic coordinates, the observed outputs matched the stated expectations and exposed the weaknesses of a validity-only gate.
The recovery model should be equally narrow. Retaining originals allows an approved rule to be rerun from known inputs. It does not mean a lossy downstream artifact can always be inverted into the original, and it does not prove that unknown remote copies or already-published products can be reconstructed automatically.
That is why original preservation belongs before topology repair, not after a repair turns out to be controversial.
Extend geospatial quality practice with Refonte Learning
The durable engineering habit is not "always choose linework" or "always choose structure." It is to treat topology repair as the creation of a candidate artifact whose complete dimensional result must pass an explicit contract.
For the tested fixture, that means preserving the input, retaining every output component, testing missingness, emptiness, validity and geometry family independently, and keeping algorithmic success separate from authorization. A valid line is still a line. A valid empty polygon is still empty. A mixed GeometryCollection is still mixed. A same-area polygon is not proof that discarded zero-area evidence was meaningless.
Only the already approved unchanged baseline rectangle receives ACCEPT_BASELINE. Nonempty polygonal repairs wait in REVIEW_REPAIR. Mixed, collapsed, or empty results remain HOLD_NONPOLYGON_OR_EMPTY. REPAIR_AND_RERUN starts from the original only after both the method and output policy have been approved.
Readers building broader Earth-observation workflows can review Refonte Learning's Remote Sensing Scientist/Engineer program. The official page describes three months at 10–12 hours per week, expects Python familiarity and a relevant technical background, and requires applicants to be working toward a bachelor's or higher degree. It lists preprocessing, quality masking and geospatial analysis topics, and offers a Training Certificate and a Certificate of Internship after successful completion. These are broader curriculum topics; the page does not specifically list Shapely, GEOS or this make_valid laboratory.
