427 lines
18 KiB
Python
427 lines
18 KiB
Python
#!/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())
|