Files
pbf/tools/provider-pipeline/selector_collision_evidence_audit.py

309 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""在实际原始 PBF/Style 上重放候选逐层 selector,报告误命中;仅用于人工审核。"""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import hashlib
import importlib.util
import json
from pathlib import Path
from typing import Any
from PIL import Image
from mapbox_vector_tile.Mapbox import vector_tile_pb2
from feature_lineage_replay_audit import load_instantiator
KEY_EXPRESSIONS: dict[str, tuple[str, str]] = {
"icon": ("layout", "icon-image"),
"fill": ("paint", "fill-pattern"),
"line": ("paint", "line-pattern"),
}
ELIGIBLE = {"LAYER_ONLY", "VISUAL_OBJECT_MATCH"}
def predicate(layer: dict[str, Any], comp: dict[str, Any]) -> tuple[str, str] | None:
"""Only original direct-get expressions are candidates, never inferred names."""
role = comp.get("role")
property_location = KEY_EXPRESSIONS.get(role)
key = comp.get("resourceKey")
if property_location is None or not isinstance(key, str) or not key:
return None
section, property_name = property_location
expression = layer.get(section, {}).get(property_name)
if isinstance(expression, list) and len(expression) == 2 and expression[0] == "get" and isinstance(expression[1], str) and expression[1]:
return expression[1], key
return None
def build_candidates(mapping: dict[str, Any], style: dict[str, Any]) -> list[dict[str, Any]]:
"""Output candidate per *original* Style Layer, not one selector shared across layers."""
layers = style["layers"]
seen: set[str] = set()
result: list[dict[str, Any]] = []
for assignment in sorted(mapping["assignments"], key=lambda row: row["visualObjectId"]):
oid = assignment["visualObjectId"]
if oid in seen:
raise ValueError(f"visualObjectId 重复: {oid}")
seen.add(oid)
evidence = assignment.get("renderEvidence") or {}
comps = evidence.get("components") or []
eligible = (assignment["status"] in ELIGIBLE and
isinstance(assignment.get("standardLayer"), str) and
bool(assignment.get("standardLayer")))
source = None
source_layer = evidence.get("sourceLayer")
reasons: set[str] = set()
fields: dict[str, str] = {}
component_layers: set[int] = set()
own_direct: set[int] = set()
if not eligible:
reasons.add("UNRESOLVED_STANDARD_CLASSIFICATION")
if not comps or not isinstance(source_layer, str):
reasons.add("NO_COMPONENTS_OR_SOURCE_LAYER")
for c in comps:
index = c.get("styleIndex")
if type(index) is not int or index < 0 or index >= len(layers):
raise ValueError(f"{oid}: 无效 Style Index")
layer = layers[index]
if layer.get("id") != c.get("styleLayerId") or layer.get("source-layer") != source_layer:
raise ValueError(f"{oid}: Style component/source-layer 不匹配")
if source is None:
source = layer.get("source")
if layer.get("source") != source or not isinstance(source, str):
reasons.add("MULTIPLE_OR_MISSING_SOURCES")
component_layers.add(index)
pair = predicate(layer, c)
if pair:
field, value = pair
if field in fields and fields[field] != value:
reasons.add("CONTRADICTORY_DIRECT_FIELD")
fields[field] = value
own_direct.add(index)
if not fields:
reasons.add("NO_DIRECT_RESOURCE_FIELD")
terms = [["==", ["get", f], v] for f, v in sorted(fields.items())]
all_predicate = terms[0] if len(terms) == 1 else ["all", *terms] if terms else None
for index in sorted(component_layers):
status = "CANDIDATE_ONLY" if not reasons else "BLOCKED_STATIC"
result.append({
"visualObjectTypeId": oid,
"standardLayer": assignment.get("standardLayer"),
"assignmentStatus": assignment["status"],
"styleLayerId": layers[index]["id"],
"styleIndex": index,
"sourceId": source,
"sourceLayer": source_layer,
"selector": all_predicate if status == "CANDIDATE_ONLY" else None,
"selectorOrigin": "DIRECT_OR_CONJUNCTION" if index in own_direct else "INHERIT_SAME_RAW_FEATURE_CANDIDATE",
"staticBlockers": sorted(reasons),
"sampleTargetMatches": 0,
"sameStandardOtherTypeHits": 0,
"crossStandardHits": 0,
"unresolvedOrUnknownHits": 0,
"outOfCatalogHits": 0,
"counterexamples": [],
"runtimeAuthorized": False,
})
return result
def _append_counterexample(row: dict[str, Any], category: str, tile: str, fid: int | None, actual: str):
if len(row["counterexamples"]) < 3:
row["counterexamples"].append({
"category": category, "tile": tile, "featureId": fid,
"actualVisualObjectTypeId": actual,
})
def evaluate_examples(
rows: list[dict[str, Any]],
observations: list[dict[str, Any]],
assignments: dict[str, dict[str, Any]],
*,
eval_selector,
) -> dict[str, Any]:
"""Feature observations are original-Style filtered matches; no runtime grant."""
by_layer: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
if row["selector"] is not None:
by_layer[row["styleLayerId"]].append(row)
hits = Counter()
for obs in observations:
actual_id = obs["visualObjectTypeId"]
actual = assignments.get(actual_id)
for layer_id in obs["matchedStyleLayerIds"]:
for row in by_layer.get(layer_id, ()):
if not eval_selector(row["selector"], obs["properties"], obs["zoom"]):
continue
if actual_id == row["visualObjectTypeId"]:
field = "sampleTargetMatches"
elif actual is None:
field = "outOfCatalogHits"
elif actual.get("status") not in ELIGIBLE or not actual.get("standardLayer"):
field = "unresolvedOrUnknownHits"
elif actual["standardLayer"] == row["standardLayer"]:
field = "sameStandardOtherTypeHits"
else:
field = "crossStandardHits"
row[field] += 1
hits[field] += 1
if field not in {"sampleTargetMatches", "sameStandardOtherTypeHits"}:
_append_counterexample(
row, field, obs["tile"], obs.get("featureId"), actual_id
)
sample_status = Counter()
for row in rows:
if row["selector"] is None:
row["sampleStatus"] = "NO_CANDIDATE"
elif row["crossStandardHits"] or row["unresolvedOrUnknownHits"] or row["outOfCatalogHits"]:
row["sampleStatus"] = "CROSS_CLASS_OR_RESIDUAL_COLLISION"
elif row["sampleTargetMatches"] == 0:
row["sampleStatus"] = "TARGET_NOT_OBSERVED"
else:
row["sampleStatus"] = "SAMPLE_NO_FORBIDDEN_MATCH"
sample_status[row["sampleStatus"]] += 1
return {"candidateStatusCounts": dict(sorted(sample_status.items())),
"observationMatchCounts": dict(sorted(hits.items()))}
def run_audit(style: dict[str, Any], mapping: dict[str, Any], catalog: dict[str, Any],
pbf_root: Path, sprite_json: dict[str, Any], sprite_hashes: dict[str, str]) -> dict[str, Any]:
engine = load_instantiator()
rows = build_candidates(mapping, style)
catalog_ids = [obj.get("visual_object_id") for obj in catalog["visual_objects"]]
if len(catalog_ids) != len(set(catalog_ids)):
raise ValueError("Actual Catalog Visual Object ID 重复")
mapping_ids = {assignment["visualObjectId"] for assignment in mapping["assignments"]}
if set(catalog_ids) != mapping_ids:
raise ValueError("Mapping 与 Catalog 的 Visual Object ID 覆盖不一致")
relevant_layers: dict[str, list[tuple[int, dict]]] = defaultdict(list)
candidate_source_layers: set[str] = set()
for row in rows:
if row["selector"] is not None:
candidate_source_layers.add(row["sourceLayer"])
source_owners: dict[str, set[str]] = defaultdict(set)
for index, layer in enumerate(style["layers"]):
sl = layer.get("source-layer")
if sl in candidate_source_layers:
relevant_layers[sl].append((index, layer))
source_owners[sl].add(layer.get("source"))
if any(len(owners) != 1 or None in owners for owners in source_owners.values()):
raise ValueError("相同 source-layer 对应多个 Style Source,单 PBF Root 无法唯一验证")
# Only tiles already referenced by validated Catalog examples, all raw features
# within each tile, and all original Style components for those features.
examples = {obj["example"]["tile"] for obj in catalog["visual_objects"]}
assignment_by_id = {row["visualObjectId"]: row for row in mapping["assignments"]}
if len(assignment_by_id) != len(mapping["assignments"]):
raise ValueError("Mapping Visual Object ID 冲突")
# Limit in-memory observations by processing each tile separately.
sample_stats = Counter()
aggregate = Counter()
for tile in sorted(examples):
parts = tile.split("/")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
raise ValueError(f"无效 tile: {tile}")
z, x, y = map(int, parts)
if x >= 1 << z or y >= 1 << z:
raise ValueError(f"超出 Tile 范围: {tile}")
source_path = pbf_root / str(z) / str(x) / (str(y) + ".pbf")
if not source_path.is_file():
raise ValueError(f"原始 PBF 不存在: {source_path}")
decoded = vector_tile_pb2.tile()
decoded.ParseFromString(source_path.read_bytes())
source_layer_counts = Counter(layer.name for layer in decoded.layers)
if any(source_layer_counts[sl] > 1 for sl in candidate_source_layers):
raise ValueError(f"原始 PBF 中 SourceLayer 名称重复: {tile}")
observations = []
for tile_layer in decoded.layers:
style_members = relevant_layers.get(tile_layer.name)
if not style_members:
continue
for feature in tile_layer.features:
sample_stats["sourceLayerRawFeaturesExamined"] += 1
props = engine.decode_feature_properties(tile_layer, feature)
matched_layers = []
components = []
for index, layer in style_members:
if engine.matches(layer, props, z):
matched_layers.append(layer["id"])
components.extend(engine.component_from_layer(layer, index, props, z, sprite_json, sprite_hashes))
if not matched_layers:
continue
vo_id = engine.stable_id("VO", {
"geometry_role": engine.geometry_role(feature.type),
"components": engine.signature_components(components),
})
observations.append({
"visualObjectTypeId": vo_id,
"properties": props, "zoom": z,
"matchedStyleLayerIds": matched_layers,
"tile": tile, "featureId": int(feature.id) if feature.HasField("id") else None,
})
sample_stats["styledRawFeaturesObserved"] += 1
partial = evaluate_examples(
rows, observations, assignment_by_id,
eval_selector=lambda expr, props, zoom: engine.evaluate(expr, props, zoom) is True,
)
for key, val in partial["observationMatchCounts"].items():
aggregate[key] += val
sample_stats["tilesScanned"] += 1
# Recompute final row status because some target samples may be found later.
statuses = Counter()
for row in rows:
if row["selector"] is None:
row["sampleStatus"] = "NO_CANDIDATE"
elif any(row[k] for k in ("crossStandardHits", "unresolvedOrUnknownHits", "outOfCatalogHits")):
row["sampleStatus"] = "CROSS_CLASS_OR_RESIDUAL_COLLISION"
elif not row["sampleTargetMatches"]:
row["sampleStatus"] = "TARGET_NOT_OBSERVED"
else:
row["sampleStatus"] = "SAMPLE_NO_FORBIDDEN_MATCH"
statuses[row["sampleStatus"]] += 1
return {
"schemaVersion": "navsea-selector-collision-evidence-audit/v1",
"scope": "261_RECORDED_EXAMPLE_TILES_ALL_RAW_FEATURES_NOT_ALL_PROVIDER_TILES",
"observedTileCount": sample_stats["tilesScanned"],
"sourceLayerRawFeaturesExamined": sample_stats["sourceLayerRawFeaturesExamined"],
"styledRawFeaturesObserved": sample_stats["styledRawFeaturesObserved"],
"candidateStatusCounts": dict(sorted(statuses.items())),
"observationMatchCounts": dict(sorted(aggregate.items())),
"displayRuntimeAuthorizedCount": 0,
"primaryAuthorizedCount": 0,
"entries": rows,
}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
for name in ("style", "mapping", "catalog", "pbf-root", "sprite-json", "sprite-png", "output"):
p.add_argument(f"--{name}", type=Path, required=True)
args = p.parse_args()
style_bytes = args.style.read_bytes()
mapping = json.loads(args.mapping.read_text(encoding="utf-8"))
expected = (mapping.get("renderEvidenceSource") or {}).get("styleSha256")
if expected != hashlib.sha256(style_bytes).hexdigest():
raise ValueError("Mapping 绑定的原 Style SHA-256 不一致")
catalog = json.loads(args.catalog.read_text(encoding="utf-8"))
declared = (catalog.get("inputs") or {}).get("style")
if not isinstance(declared, str) or not Path(declared).is_file():
raise ValueError("Catalog 声明的原 Style 路径不可校验")
if hashlib.sha256(Path(declared).read_bytes()).digest() != hashlib.sha256(style_bytes).digest():
raise ValueError("Catalog 原 Style SHA-256 不一致")
style = json.loads(style_bytes)
sprite_json = json.loads(args.sprite_json.read_text(encoding="utf-8"))
with Image.open(args.sprite_png) as atlas:
hashes = load_instantiator().sprite_hashes(sprite_json, atlas.convert("RGBA"))
out = run_audit(style, mapping, catalog, args.pbf_root, sprite_json, hashes)
args.output.parent.mkdir(parents=True, exist_ok=True)
if args.output.exists():
raise ValueError("审计结果已存在:避免覆盖前一份不可变证据;请增版本")
args.output.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({k: v for k, v in out.items() if k != "entries"}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())