distinguish a redrawn footprint from a demolition, and derive estimate

disclosure from data

Change detection had only added/removed/unchanged, so a building extended by
an annexe dropped below the IoU threshold and was reported twice: once as
removed and once as added. That hides exactly the category a change-detection
product exists to show and inflates both counts. A "modified" class now covers
the band between the modified floor and the unchanged threshold.

Matching also ran as a full cross product with no spatial index, unlike the QA
matcher beside it: two municipal building layers meant hundreds of millions of
geometry intersections. It uses an STRtree and considers larger footprints
first, so a big footprint is not left over after a small neighbour claimed its
counterpart.

The assistant guaranteed honesty about estimated values by rewriting the
model's sentences with regular expressions, which only fires when it
recognises the phrasing the model happened to produce. estimate_disclosures
derives the same statement from the metric metadata, so it holds regardless of
how the answer was worded. The prose substitution stays as a second layer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:33:37 +02:00
co-authored by Claude Opus 5
parent dd87a62e8f
commit 23d6e0372b
6 changed files with 373 additions and 59 deletions
@@ -0,0 +1,88 @@
"""Estimate disclosure must come from the data, not from patching prose.
``ensure_estimate_disclosure`` rewrites the model's sentences with regular
expressions to insert the word "schatting". That only fires when the generated
text happens to contain one of the phrasings it knows, so whether a number is
labelled an estimate depends on how the language model worded it. The
disclosure is derived from the metric metadata instead, so the honesty of the
answer no longer depends on string matching.
"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from app.schemas.assistant import AssistantContextMetric
from app.services.geo_assistant_service import GeoAssistantService
def _metric(theme: str, label: str, *, is_estimate: bool) -> AssistantContextMetric:
return AssistantContextMetric(
theme=theme,
label=label,
value=36783.0,
unit="inwoners",
source="Statbel",
dataset_id=uuid4(),
observed_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
is_estimate=is_estimate,
)
def test_every_estimated_metric_produces_a_disclosure() -> None:
metrics = [
_metric("population", "Inwoners", is_estimate=True),
_metric("buildings", "Gebouwen", is_estimate=False),
]
disclosures = GeoAssistantService.estimate_disclosures(metrics)
assert len(disclosures) == 1
assert disclosures[0].theme == "population"
assert disclosures[0].label == "Inwoners"
assert disclosures[0].source == "Statbel"
assert disclosures[0].dataset_id == metrics[0].dataset_id
assert "schatting" in disclosures[0].reason.casefold()
def test_disclosure_does_not_depend_on_the_generated_wording() -> None:
"""The regex path only fires on phrasings it recognises; this does not."""
metrics = [_metric("population", "Inwoners", is_estimate=True)]
patched = GeoAssistantService.ensure_estimate_disclosure(
"Er wonen daar 36.783 mensen.", metrics
)
disclosures = GeoAssistantService.estimate_disclosures(metrics)
# The prose was left untouched because no known phrase matched...
assert "Datakwaliteit" not in patched
# ...but the structured disclosure is present regardless.
assert len(disclosures) == 1
def test_no_estimates_means_no_disclosures() -> None:
metrics = [_metric("buildings", "Gebouwen", is_estimate=False)]
assert GeoAssistantService.estimate_disclosures(metrics) == []
def test_disclosures_are_deduplicated_per_theme_and_dataset() -> None:
shared = _metric("population", "Inwoners", is_estimate=True)
duplicate = AssistantContextMetric(**{**shared.model_dump(), "label": "Inwoners (2024)"})
disclosures = GeoAssistantService.estimate_disclosures([shared, duplicate])
assert len(disclosures) == 1
def test_disclosures_are_ordered_deterministically() -> None:
metrics = [
_metric("space_occupation", "Ruimtebeslag", is_estimate=True),
_metric("population", "Inwoners", is_estimate=True),
]
themes = [item.theme for item in GeoAssistantService.estimate_disclosures(metrics)]
assert themes == ["population", "space_occupation"]
@@ -0,0 +1,114 @@
"""An extended building is a change, not a deletion plus a new building.
With only added/removed/unchanged, a footprint that grew by an annexe drops
below the IoU threshold and is reported twice: once as removed and once as
added. That hides exactly the category a change-detection product exists to
show, and inflates both counts.
"""
from __future__ import annotations
import pytest
from shapely.geometry import box
from app.services.change_detection_service import ChangeDetectionService
def _feature(feature_id: str, geometry):
return {"feature_id": feature_id, "properties": {}, "geometry": geometry}
def _classify(source, target, *, iou_threshold=0.8, modified_threshold=0.3):
return ChangeDetectionService._classify_features(
source,
target,
iou_threshold=iou_threshold,
modified_threshold=modified_threshold,
)
def test_an_extended_footprint_is_reported_as_modified() -> None:
source = [_feature("b1", box(0, 0, 10, 10))]
target = [_feature("b1-new", box(0, 0, 10, 14))] # IoU 100/140 = 0.71
result = _classify(source, target)
assert [item["change_type"] for item in result] == ["modified"]
assert result[0]["source_feature_id"] == "b1"
assert result[0]["target_feature_id"] == "b1-new"
assert result[0]["iou"] == pytest.approx(100 / 140)
def test_a_nearly_identical_footprint_is_unchanged() -> None:
source = [_feature("b1", box(0, 0, 10, 10))]
target = [_feature("b1", box(0, 0, 10, 10.2))]
result = _classify(source, target)
assert [item["change_type"] for item in result] == ["unchanged"]
def test_a_genuinely_new_building_stays_added() -> None:
source = [_feature("b1", box(0, 0, 10, 10))]
target = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
result = _classify(source, target)
assert sorted(item["change_type"] for item in result) == ["added", "unchanged"]
def test_a_demolished_building_stays_removed() -> None:
source = [_feature("b1", box(0, 0, 10, 10)), _feature("b2", box(50, 50, 60, 60))]
target = [_feature("b1", box(0, 0, 10, 10))]
result = _classify(source, target)
assert sorted(item["change_type"] for item in result) == ["removed", "unchanged"]
def test_barely_overlapping_footprints_are_not_called_modified() -> None:
"""Below the modified floor the two are separate objects, not one changed."""
source = [_feature("b1", box(0, 0, 10, 10))]
target = [_feature("b2", box(9, 9, 19, 19))] # IoU ~0.005
result = _classify(source, target)
assert sorted(item["change_type"] for item in result) == ["added", "removed"]
def test_each_target_is_claimed_at_most_once() -> None:
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(0, 0, 10, 12))]
target = [_feature("t", box(0, 0, 10, 10))]
result = _classify(source, target)
claimed = [item for item in result if item["target_feature_id"] == "t"]
assert len(claimed) == 1
def test_classification_is_independent_of_input_order() -> None:
source = [_feature("a", box(0, 0, 10, 10)), _feature("b", box(30, 30, 40, 40))]
target = [_feature("a2", box(0, 0, 10, 14)), _feature("c", box(70, 70, 80, 80))]
forward = _classify(source, target)
reverse = _classify(list(reversed(source)), list(reversed(target)))
def signature(items):
return sorted(
(item["change_type"], item["source_feature_id"], item["target_feature_id"]) for item in items
)
assert signature(forward) == signature(reverse)
def test_large_populations_do_not_use_a_full_cross_product() -> None:
"""A spatial index keeps a city-sized comparison tractable."""
source = [_feature(f"s{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
target = [_feature(f"t{i}", box(i * 10, 0, i * 10 + 8, 8)) for i in range(400)]
result = _classify(source, target)
assert all(item["change_type"] == "unchanged" for item in result)
assert len(result) == 400