Enable governed GRB evolution
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-16 19:30:46 +02:00
parent 1d515e4901
commit a8b18942f5
12 changed files with 448 additions and 59 deletions
@@ -27,6 +27,10 @@ from app.services.vector_feature_service import VectorFeatureService
class TemporalAnalysisService:
IDENTITY_COMPARISON_LIMIT = 5_000
GOVERNED_GRB_IDENTITY_OPERATORS = {
"provision_regional_grb_buildings.py",
"provision_regional_grb_context.py",
}
@staticmethod
def list_series(db: Session, project_id: UUID) -> list[TemporalSeriesRead]:
@@ -345,7 +349,9 @@ class TemporalAnalysisService:
) -> tuple[TemporalObjectChanges, dict[str, Any], list[str]]:
earlier_config = earlier.source_metadata if isinstance(earlier.source_metadata, dict) else {}
later_config = later.source_metadata if isinstance(later.source_metadata, dict) else {}
if not earlier_config.get("identity_stable") or not later_config.get("identity_stable"):
earlier_identity = TemporalAnalysisService._identity_contract(earlier)
later_identity = TemporalAnalysisService._identity_contract(later)
if earlier_identity is None or later_identity is None or earlier_identity != later_identity:
return (
TemporalObjectChanges(available=False),
{"type": "FeatureCollection", "features": []},
@@ -368,8 +374,7 @@ class TemporalAnalysisService:
if not full_dataset_area:
query = query.filter(ST_Intersects(VectorFeature.geometry, selection_shape))
return (
query.filter(VectorFeature.source_feature_id.isnot(None))
.order_by(VectorFeature.source_feature_id.asc())
query.order_by(VectorFeature.source_feature_id.asc())
.limit(TemporalAnalysisService.IDENTITY_COMPARISON_LIMIT + 1)
.all()
)
@@ -386,6 +391,16 @@ class TemporalAnalysisService:
["Object-level preview was skipped because the selection exceeds the 5,000 feature safety limit."],
)
identity_prefixes = earlier_identity[1]
if not TemporalAnalysisService._rows_match_identity_contract(earlier_rows, identity_prefixes) or not (
TemporalAnalysisService._rows_match_identity_contract(later_rows, identity_prefixes)
):
return (
TemporalObjectChanges(available=False),
{"type": "FeatureCollection", "features": []},
["De geselecteerde objecten bevatten geen volledig verifieerbare stabiele bronidentiteit."],
)
earlier_by_id = {str(row.source_feature_id): row for row in earlier_rows if row.source_feature_id}
later_by_id = {str(row.source_feature_id): row for row in later_rows if row.source_feature_id}
earlier_ids = set(earlier_by_id)
@@ -459,3 +474,50 @@ class TemporalAnalysisService:
{"type": "FeatureCollection", "features": features},
warnings,
)
@staticmethod
def _identity_contract(dataset: Dataset) -> tuple[str, tuple[str, ...]] | None:
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
declared_stable = source_metadata.get("identity_stable")
if declared_stable is False:
return None
configured_prefixes = source_metadata.get("identity_prefixes")
prefixes = tuple(
sorted(
{
str(value).strip()
for value in configured_prefixes
if str(value).strip()
}
)
) if isinstance(configured_prefixes, list) else ()
if declared_stable is True:
return str(source_metadata.get("identity_scheme") or "declared_source_feature_id"), prefixes
provenance = dataset.provenance_metadata if isinstance(dataset.provenance_metadata, dict) else {}
if (
dataset.source_name != "grb"
or not str(dataset.temporal_series_key or "").startswith("grb:")
or source_metadata.get("authority_level") != "authoritative"
or source_metadata.get("geometry_clipped_to_area") is not True
or provenance.get("operator_tool") not in TemporalAnalysisService.GOVERNED_GRB_IDENTITY_OPERATORS
or provenance.get("reference_truncated") is not False
):
return None
if dataset.reference_layer_name == "buildings" and source_metadata.get("collection") == "GRB/GBG":
prefixes = ("GBG.",)
else:
collections = source_metadata.get("collections")
if not isinstance(collections, list) or not collections:
return None
prefixes = tuple(sorted(f"{str(collection)}:{str(collection)}." for collection in collections))
return "grb_ogc_feature_id", prefixes
@staticmethod
def _rows_match_identity_contract(rows: list[VectorFeature], prefixes: tuple[str, ...]) -> bool:
identities = [str(row.source_feature_id or "").strip() for row in rows]
if any(not identity for identity in identities) or len(set(identities)) != len(identities):
return False
return not prefixes or all(identity.startswith(prefixes) for identity in identities)