Files
pbf/tools/style-visual-object/validate_cm95_classification.py

427 lines
18 KiB
Python
Raw 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
"""CM95 → NavSea Layer 分类基线一致性校验。
只读取已冻结/已完成人工确认的产物,不修改分类结果。
失败时退出码为 1;全部硬校验通过时退出码为 0。
语义建议与人工结果冲突仅记 warning,因为人工结果是最终权威。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
OUTPUT = ROOT / "output"
DEFAULT_PATHS = {
"cm95_catalog": OUTPUT / "cm95-anonymous" / "anonymous-catalog.json",
"standard_catalog": OUTPUT / "navsea-standard" / "standard-catalog.json",
"manual_review": OUTPUT / "cm95-manual-review" / "manual-review-v1.json",
"manual_anchors": OUTPUT / "cm95-manual-review" / "reviewed-anchors-manual-v1.json",
"assignment": OUTPUT / "cm95-manual-review" / "assignment" / "cm95-layer-assignment-v1.json",
"reference_names": OUTPUT / "reference-name-enrichment-v1.json",
"semantic_rules": ROOT / "cm95_semantic_layer_rules_v1.json",
}
VALID_STATUS = {"MATCH", "PARTIAL", "AMBIGUOUS", "COMPONENT", "UNKNOWN"}
class Validation:
def __init__(self) -> None:
self.checks: list[dict[str, Any]] = []
self.warnings: list[dict[str, Any]] = []
def check(self, name: str, condition: bool, detail: Any = None) -> None:
row = {"name": name, "status": "PASS" if condition else "FAIL"}
if detail is not None:
row["detail"] = detail
self.checks.append(row)
def warn(self, name: str, detail: Any) -> None:
self.warnings.append({"name": name, "status": "WARN", "detail": detail})
@property
def ok(self) -> bool:
return all(x["status"] == "PASS" for x in self.checks)
def load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def anonymous_resource_id(resource_key: str, visual_hash: str | None) -> str:
seed = f"{resource_key}|{visual_hash or ''}".encode("utf-8")
return "CM95-VO-" + hashlib.sha256(seed).hexdigest()[:10].upper()
def build_resource_index(cm95: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
by_key: dict[str, dict[str, Any]] = {}
for obj in cm95.get("visual_objects", []):
key = obj.get("resource_key")
if not key:
continue
row = by_key.setdefault(
key,
{
"resource_key": key,
"visual_hash": obj.get("visual_hash"),
"occurrences": 0,
},
)
row["occurrences"] += 1
if not row.get("visual_hash") and obj.get("visual_hash"):
row["visual_hash"] = obj.get("visual_hash")
by_id = {
anonymous_resource_id(key, row.get("visual_hash")): key
for key, row in by_key.items()
}
return by_key, by_id
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument(
"--report",
default=str(OUTPUT / "cm95-manual-review" / "validation-report-v1.json"),
help="校验报告输出路径",
)
args = ap.parse_args()
v = Validation()
missing = [str(p) for p in DEFAULT_PATHS.values() if not p.exists()]
v.check("required_files_exist", not missing, {"missing": missing})
if missing:
report = {
"schema": "cm95-layer-classification-validation/v1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"ok": False,
"checks": v.checks,
"warnings": v.warnings,
}
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 1
cm95 = load(DEFAULT_PATHS["cm95_catalog"])
std = load(DEFAULT_PATHS["standard_catalog"])
manual = load(DEFAULT_PATHS["manual_review"])
anchors = load(DEFAULT_PATHS["manual_anchors"])
assignment = load(DEFAULT_PATHS["assignment"])
names = load(DEFAULT_PATHS["reference_names"])
semantic_rules = load(DEFAULT_PATHS["semantic_rules"])
cm_resources, anon_to_key = build_resource_index(cm95)
resource_keys = set(cm_resources)
decisions = manual.get("decisions", {})
decision_ids = set(decisions)
std_layers = {
str(o["standard_layer_name"])
for o in std.get("visual_objects", [])
if o.get("standard_layer_name")
}
std_resource_layers: dict[str, set[str]] = defaultdict(set)
for o in std.get("visual_objects", []):
key = o.get("resource_key")
layer = o.get("standard_layer_name")
if key and layer:
std_resource_layers[str(key)].add(str(layer))
v.check("standard_catalog_frozen", std.get("catalog_status") == "FROZEN", std.get("catalog_status"))
v.check(
"cm95_anonymous_catalog_has_no_standard_layer",
not any("standard_layer_name" in o for o in cm95.get("visual_objects", [])),
)
v.check("cm95_unique_visual_resources_150", len(resource_keys) == 150, len(resource_keys))
v.check("manual_decisions_150", len(decisions) == 150, len(decisions))
v.check("manual_covers_all_resources", decision_ids == set(anon_to_key), {
"missing": sorted(set(anon_to_key) - decision_ids),
"extra": sorted(decision_ids - set(anon_to_key)),
})
v.check(
"style_hash_cm95_matches",
manual.get("cm95_style_sha256") == cm95.get("style", {}).get("sha256"),
{
"manual": manual.get("cm95_style_sha256"),
"catalog": cm95.get("style", {}).get("sha256"),
},
)
v.check(
"style_hash_standard_matches",
manual.get("standard_style_sha256") == std.get("style", {}).get("sha256"),
{
"manual": manual.get("standard_style_sha256"),
"catalog": std.get("style", {}).get("sha256"),
},
)
status_counts = Counter()
bad_decisions: list[dict[str, Any]] = []
for anon_id, d in decisions.items():
status = d.get("status")
status_counts[status] += 1
expected_key = anon_to_key.get(anon_id)
layers = [str(x) for x in d.get("standard_layers", [])]
std_key = d.get("standard_resource_key")
mode = d.get("assignment_mode")
component_kind = d.get("component_kind")
reasons: list[str] = []
if status not in VALID_STATUS:
reasons.append("invalid_status")
if d.get("resource_key_internal") != expected_key:
reasons.append("resource_key_mismatch")
if any(layer not in std_layers for layer in layers):
reasons.append("unknown_standard_layer")
if std_key and std_key not in std_resource_layers:
reasons.append("unknown_standard_resource")
if status in {"MATCH", "PARTIAL"}:
if len(layers) != 1:
reasons.append("match_partial_requires_one_layer")
if mode not in {"VISUAL_OBJECT_MATCH", "LAYER_ONLY"}:
reasons.append("invalid_match_partial_mode")
if std_key and layers and layers[0] not in std_resource_layers.get(std_key, set()):
reasons.append("standard_resource_layer_mismatch")
elif status == "AMBIGUOUS":
if len(layers) < 2:
reasons.append("ambiguous_requires_two_layers")
if component_kind:
reasons.append("ambiguous_has_component_kind")
elif status == "COMPONENT":
if layers or std_key is not None or mode != "COMPONENT_ONLY":
reasons.append("component_must_have_no_layer_or_standard_resource")
elif status == "UNKNOWN":
if layers or std_key is not None or mode != "UNKNOWN":
reasons.append("unknown_must_have_no_layer_or_standard_resource")
if reasons:
bad_decisions.append({"anonymous_id": anon_id, "reasons": reasons, "decision": d})
v.check("manual_decision_state_constraints", not bad_decisions, bad_decisions[:20])
v.check(
"manual_status_counts_total_150",
sum(status_counts.values()) == 150,
dict(status_counts),
)
# 人工导出 anchors:UNKNOWN 不导出;其余必须一一存在。
anchor_by_key = {
str(a["cm95_resource_key"]): a
for a in anchors.get("anchors", [])
if a.get("cm95_resource_key")
}
expected_anchor_keys = {
d["resource_key_internal"]
for d in decisions.values()
if d.get("status") != "UNKNOWN"
}
v.check(
"manual_anchor_export_matches_non_unknown_decisions",
set(anchor_by_key) == expected_anchor_keys,
{
"expected": len(expected_anchor_keys),
"actual": len(anchor_by_key),
"missing": sorted(expected_anchor_keys - set(anchor_by_key)),
"extra": sorted(set(anchor_by_key) - expected_anchor_keys),
},
)
anchor_mismatches: list[dict[str, Any]] = []
decision_by_key = {d["resource_key_internal"]: d for d in decisions.values()}
for key, a in anchor_by_key.items():
d = decision_by_key[key]
if a.get("status") != d.get("status"):
anchor_mismatches.append({"resource": key, "field": "status", "decision": d.get("status"), "anchor": a.get("status")})
if a.get("standard_resource_key") != d.get("standard_resource_key"):
anchor_mismatches.append({"resource": key, "field": "standard_resource_key"})
if a.get("assignment_mode") != d.get("assignment_mode"):
anchor_mismatches.append({"resource": key, "field": "assignment_mode"})
if d.get("status") in {"MATCH", "PARTIAL"}:
expected_layer = (d.get("standard_layers") or [None])[0]
if a.get("standard_layer") != expected_layer:
anchor_mismatches.append({"resource": key, "field": "standard_layer"})
elif d.get("status") == "AMBIGUOUS":
if sorted(a.get("standard_layers", [])) != sorted(d.get("standard_layers", [])):
anchor_mismatches.append({"resource": key, "field": "standard_layers"})
elif d.get("status") == "COMPONENT":
if a.get("component_kind") != d.get("component_kind"):
anchor_mismatches.append({"resource": key, "field": "component_kind"})
v.check("manual_anchor_fields_match_decisions", not anchor_mismatches, anchor_mismatches[:20])
# Reference names 必须覆盖全部当前 CM95 资源。
name_keys = set(names.get("cm95", {}))
v.check(
"reference_names_cover_all_cm95_resources",
name_keys == resource_keys,
{
"missing": sorted(resource_keys - name_keys),
"extra": sorted(name_keys - resource_keys),
},
)
# 声明式语义规则:正则可编译,Layer 必须存在于 Standard Catalog。
rule_errors: list[dict[str, Any]] = []
for rule in semantic_rules.get("rules", []):
try:
re.compile(str(rule.get("pattern") or ""), re.IGNORECASE)
except re.error as exc:
rule_errors.append({"id": rule.get("id"), "error": f"regex: {exc}"})
for item in rule.get("layer_candidates", []):
if not isinstance(item, list) or len(item) < 2:
rule_errors.append({"id": rule.get("id"), "error": "invalid layer_candidates shape"})
continue
if str(item[0]) not in std_layers:
rule_errors.append({"id": rule.get("id"), "error": f"unknown standard layer: {item[0]}"})
v.check("semantic_rules_are_valid", not rule_errors, rule_errors)
# 最终 Assignment 的状态/Layer 必须逐 occurrence 服从人工资源级决定。
assignments = assignment.get("assignments", [])
expected_total = len(cm95.get("visual_objects", [])) + len(cm95.get("unresolved_visual_objects", []))
v.check("assignment_total_count", len(assignments) == expected_total, {
"expected": expected_total,
"actual": len(assignments),
})
assignment_errors: list[dict[str, Any]] = []
assignment_status_counts = Counter()
for row in assignments:
status = row.get("assignment_status")
assignment_status_counts[status] += 1
key = row.get("cm95_resource_key")
if key:
d = decision_by_key.get(key)
if not d:
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "resource_without_manual_decision"})
continue
expected_status = d.get("status")
if status != expected_status:
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "status_mismatch", "expected": expected_status, "actual": status})
continue
if status in {"MATCH", "PARTIAL"}:
expected_layer = (d.get("standard_layers") or [None])[0]
if row.get("assigned_standard_layer") != expected_layer:
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "layer_mismatch"})
elif status == "AMBIGUOUS":
if sorted(row.get("candidate_standard_layers", [])) != sorted(d.get("standard_layers", [])):
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "ambiguous_layers_mismatch"})
elif status == "COMPONENT":
if row.get("assigned_standard_layer") is not None or row.get("component_kind") != d.get("component_kind"):
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "component_assignment_mismatch"})
elif status == "UNKNOWN":
if row.get("assigned_standard_layer") is not None:
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "unknown_has_layer"})
else:
# Style-only 未展开动态对象没有对应具体资源,必须保持 UNKNOWN。
if status != "UNKNOWN":
assignment_errors.append({"visual_object_id": row.get("visual_object_id"), "error": "unresolved_dynamic_not_unknown"})
v.check("final_assignment_matches_manual_decisions", not assignment_errors, assignment_errors[:20])
summary = assignment.get("summary", {})
recomputed = {
"MATCH": assignment_status_counts["MATCH"],
"PARTIAL": assignment_status_counts["PARTIAL"],
"AMBIGUOUS": assignment_status_counts["AMBIGUOUS"],
"COMPONENT": assignment_status_counts["COMPONENT"],
"UNKNOWN": assignment_status_counts["UNKNOWN"],
}
summary_expected = {
"MATCH": summary.get("match_visual_objects"),
"PARTIAL": summary.get("partial_visual_objects"),
"AMBIGUOUS": summary.get("ambiguous_visual_objects"),
"COMPONENT": summary.get("component_visual_objects"),
"UNKNOWN": summary.get("unknown_visual_objects"),
}
v.check("assignment_summary_counts_match_rows", recomputed == summary_expected, {
"recomputed": recomputed,
"summary": summary_expected,
})
v.check(
"assignment_policy_preserves_architecture",
assignment.get("policy", {}).get("cm95_source_layer_used_for_assignment") is False
and assignment.get("policy", {}).get("provider_field_semantics_used_for_assignment") is False
and assignment.get("policy", {}).get("anonymous_catalog_modified") is False,
assignment.get("policy"),
)
# 新语义分类器与历史人工结果的冲突只作 warning。
# 这里复现最小规则匹配,只比较高置信(>=0.72)首选 Layer。
high_conflicts: list[dict[str, Any]] = []
cm_name_map = names.get("cm95", {})
rules = semantic_rules.get("rules", [])
for d in decisions.values():
if d.get("status") not in {"MATCH", "PARTIAL"}:
continue
selected = d.get("standard_layers") or []
if len(selected) != 1:
continue
key = d["resource_key_internal"]
info = cm_name_map.get(key, {})
desc = str(info.get("official_description_en") or "")
rtype = str(info.get("resource_type") or "")
top: tuple[str, float] | None = None
for rule in rules:
if rule.get("resource_type") and rule.get("resource_type") != rtype:
continue
if not re.search(str(rule.get("pattern") or ""), desc, flags=re.IGNORECASE):
continue
candidates = rule.get("layer_candidates", [])
if candidates:
top = (str(candidates[0][0]), float(candidates[0][1]))
break
if top and top[1] >= 0.72 and top[0] != selected[0]:
high_conflicts.append({
"anonymous_id": d.get("anonymous_id"),
"resource_key": key,
"display_name_zh": info.get("display_name_zh"),
"manual_layer": selected[0],
"semantic_suggestion": top[0],
"semantic_score": top[1],
})
if high_conflicts:
v.warn("manual_vs_semantic_classifier_conflicts", high_conflicts)
report = {
"schema": "cm95-layer-classification-validation/v1",
"generated_at": datetime.now(timezone.utc).isoformat(),
"ok": v.ok,
"baseline": {
"cm95_style_sha256": cm95.get("style", {}).get("sha256"),
"standard_style_sha256": std.get("style", {}).get("sha256"),
"standard_catalog_status": std.get("catalog_status"),
"unique_cm95_resources": len(resource_keys),
"manual_status_counts": dict(sorted(status_counts.items())),
"final_assignment_status_counts": recomputed,
"final_assignment_total": len(assignments),
},
"checks": v.checks,
"warnings": v.warnings,
}
report_path = Path(args.report)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print("CM95 → NavSea Layer 分类基线校验")
for row in v.checks:
print(f"[{row['status']}] {row['name']}")
for row in v.warnings:
print(f"[WARN] {row['name']}: {json.dumps(row['detail'], ensure_ascii=False)}")
print(f"report: {report_path}")
print("RESULT:", "PASS" if v.ok else "FAIL")
return 0 if v.ok else 1
if __name__ == "__main__":
sys.exit(main())