make an exported file state its own provenance and limits

The vector selection export caps its features and recorded truncated on the
export record. The file said nothing: an operator downloads
mol-selection.geojson, opens 250 buildings in QGIS where the workbench said
1.400, and has nothing to tell them the difference. For a product whose promise
is that an export is a reproducible result, a file that looks complete and is
not is the sharpest possible violation of it.

Every exported FeatureCollection now carries a geointel_provenance foreign
member — RFC 7946 requires parsers to ignore unknown members, so QGIS and
ogr2ogr are unaffected, and the detection export already used the same
convention for its trust classification. It names the source, the dataset and
its edition, the selection, and whether the file is complete; completeness is
derived from the counts as well as the flag, so a caller that forgets the flag
cannot produce a file claiming to hold everything.

Applied uniformly to the selection, dataset, detection and segmentation
exports, which previously disclosed three different amounts of nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:56:37 +02:00
co-authored by Claude Opus 5
parent 12aaf1bb4d
commit 0d8f146fc4
3 changed files with 385 additions and 0 deletions
@@ -0,0 +1,125 @@
"""A downloaded export must carry its own provenance and its own limits.
Counterpart to ``test_export_provenance_member``: this reads the file the
service actually writes, so it proves the member survives serialisation rather
than only that the helper builds it.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
from app.models import Dataset, Export
from app.services.export_service import ExportService
from app.services.storage_service import StorageService
from app.services.vector_feature_service import VectorFeatureService
from tests.test_sprint107_map_selection_export import FakeSession, _govern_fixture_dataset
BBOX = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}
def _dataset(dataset_id):
dataset = _govern_fixture_dataset(
Dataset(
id=dataset_id,
project_id=uuid4(),
name="grb-buildings.geojson",
dataset_type="vector",
source="grb",
source_name="grb",
source_version="2024-06",
observed_at=datetime(2024, 6, 1, tzinfo=timezone.utc),
status="ready",
)
)
return dataset
def _selection(*, feature_count: int, total: int, truncated: bool, warning: str | None = None):
return {
"selection_bbox": BBOX,
"feature_count": feature_count,
"total_feature_count": total,
"limit": 250,
"truncated": truncated,
"summary": {"selection_edge_warning": warning} if warning else {},
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [5.0, 51.0]},
"properties": {"vector_feature_id": f"vf-{index}"},
}
for index in range(feature_count)
],
},
}
def _export(monkeypatch, tmp_path: Path, selection) -> dict:
dataset_id = uuid4()
dataset = _dataset(dataset_id)
db = FakeSession({(Dataset, dataset_id): dataset})
export_path = tmp_path / "selection.geojson"
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", lambda *_args, **_kwargs: selection)
ExportService.export_vector_selection_geojson(db, dataset_id, BBOX, limit=250, name="selection")
assert [item for item in db.added if isinstance(item, Export)]
return json.loads(export_path.read_text(encoding="utf-8"))
def test_a_truncated_export_file_states_that_it_is_partial(monkeypatch, tmp_path: Path) -> None:
written = _export(monkeypatch, tmp_path, _selection(feature_count=2, total=1_400, truncated=True))
provenance = written["geointel_provenance"]
assert provenance["complete"] is False
assert "1400" in provenance["completeness_note"].replace(".", "")
# The features are still there; the file simply no longer implies it holds
# everything the selection contains.
assert len(written["features"]) == 2
def test_a_complete_export_file_says_so(monkeypatch, tmp_path: Path) -> None:
written = _export(monkeypatch, tmp_path, _selection(feature_count=2, total=2, truncated=False))
provenance = written["geointel_provenance"]
assert provenance["complete"] is True
assert provenance["completeness_note"] is None
def test_the_file_identifies_its_source_edition(monkeypatch, tmp_path: Path) -> None:
written = _export(monkeypatch, tmp_path, _selection(feature_count=1, total=1, truncated=False))
provenance = written["geointel_provenance"]
assert provenance["source_name"] == "grb"
assert provenance["source_version"] == "2024-06"
assert provenance["observed_at"].startswith("2024-06-01")
assert provenance["selection_bbox"] == BBOX
def test_selection_caveats_travel_into_the_file(monkeypatch, tmp_path: Path) -> None:
written = _export(
monkeypatch,
tmp_path,
_selection(feature_count=1, total=1, truncated=False, warning="22 objecten liggen deels buiten de selectie."),
)
assert written["geointel_provenance"]["warnings"] == ["22 objecten liggen deels buiten de selectie."]
def test_the_file_remains_a_valid_feature_collection(monkeypatch, tmp_path: Path) -> None:
"""The provenance is a foreign member, not a change to the GeoJSON shape."""
written = _export(monkeypatch, tmp_path, _selection(feature_count=1, total=1, truncated=False))
assert written["type"] == "FeatureCollection"
assert isinstance(written["features"], list)
assert written["features"][0]["type"] == "Feature"
@@ -0,0 +1,129 @@
"""An exported GeoJSON must say what it is and what it leaves out.
The vector selection export caps its features and records ``truncated`` in the
export *record*. The file itself said nothing: an operator downloads
``mol-selection.geojson``, opens it in QGIS and sees 250 buildings where the
workbench said 1.400, with nothing in the file to indicate the difference.
For a product whose promise is that an export is a reproducible result, a file
that looks complete and is not is the sharpest possible violation. RFC 7946
allows foreign members on a FeatureCollection, and the detection export already
uses one; this makes that convention uniform.
"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from app.services.export_service import ExportService
def test_provenance_names_the_source_and_the_moment() -> None:
dataset_id = uuid4()
project_id = uuid4()
member = ExportService.provenance_member(
source="vector_selection",
project_id=project_id,
dataset_id=dataset_id,
source_name="grb",
source_version="2024-06",
observed_at=datetime(2024, 6, 1, tzinfo=timezone.utc),
)
assert member["source"] == "vector_selection"
assert member["dataset_id"] == str(dataset_id)
assert member["project_id"] == str(project_id)
assert member["source_name"] == "grb"
assert member["source_version"] == "2024-06"
assert member["observed_at"].startswith("2024-06-01")
assert member["exported_at"]
def test_a_truncated_export_says_so_in_words() -> None:
member = ExportService.provenance_member(
source="vector_selection",
project_id=uuid4(),
dataset_id=uuid4(),
feature_count=250,
total_feature_count=1_400,
truncated=True,
)
assert member["complete"] is False
assert member["feature_count"] == 250
assert member["total_feature_count"] == 1_400
assert "1400" in member["completeness_note"].replace(".", "").replace(",", "")
assert "250" in member["completeness_note"]
def test_a_complete_export_is_stated_as_complete() -> None:
member = ExportService.provenance_member(
source="detection_run",
project_id=uuid4(),
dataset_id=uuid4(),
feature_count=12,
total_feature_count=12,
truncated=False,
)
assert member["complete"] is True
assert member["completeness_note"] is None
def test_counts_that_disagree_are_treated_as_incomplete() -> None:
"""A caller that forgets the flag must not produce a file claiming completeness."""
member = ExportService.provenance_member(
source="vector_selection",
project_id=uuid4(),
dataset_id=uuid4(),
feature_count=100,
total_feature_count=140,
truncated=False,
)
assert member["complete"] is False
def test_selection_context_travels_with_the_file() -> None:
area_id = uuid4()
bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
member = ExportService.provenance_member(
source="vector_selection",
project_id=uuid4(),
dataset_id=uuid4(),
selection_bbox=bbox,
selection_area_id=area_id,
warnings=["22 van de 100 objecten liggen deels buiten de selectie."],
)
assert member["selection_bbox"] == bbox
assert member["selection_area_id"] == str(area_id)
assert member["warnings"] == ["22 van de 100 objecten liggen deels buiten de selectie."]
def test_empty_context_is_omitted_rather_than_written_as_null_noise() -> None:
member = ExportService.provenance_member(
source="dataset",
project_id=uuid4(),
dataset_id=uuid4(),
)
assert "selection_bbox" not in member
assert "warnings" not in member
assert "source_version" not in member
def test_the_member_attaches_under_a_reserved_key() -> None:
collection = {"type": "FeatureCollection", "features": []}
ExportService.attach_provenance(
collection,
ExportService.provenance_member(source="dataset", project_id=uuid4(), dataset_id=uuid4()),
)
assert collection["type"] == "FeatureCollection"
assert collection["geointel_provenance"]["source"] == "dataset"