feat(provider-pipeline): add full catalog render evidence to mapping v2
This commit is contained in:
@@ -2400,3 +2400,10 @@
|
||||
- 已明确当前 Chart Profile Tools 尚无 ZIP Import 入口,第一轮应增加统一 ProviderPackageImporter / import-package 薄层,再进入现有 Profile Validator / ProviderCompiler。
|
||||
- 文档包含两个当前 ZIP 的路径与 SHA-256、9 文件固定结构、安全校验、Contract 校验、Package→Profile 映射、NavSea/CM95 双 Provider 试跑顺序、第一轮验收清单及禁止事项。
|
||||
- 第一轮明确不覆盖正式 Profile,不同时实施 UI Provider 切换或 Runtime 多 Provider 原子切换;先证明 ZIP → Profile → ProviderCompiler → PLAN_READY。
|
||||
|
||||
## 2026-10-07 NAVSEA-PBF-MULTISOURCE-004A Provider Mapping v2
|
||||
|
||||
- Provider Pipeline 为声明 `executionEvidenceCatalog` 的 manifest 生成 Mapping v2;CM95 1.1.0 从 Japan 全量 Actual Catalog 附加 Style Layer、z-order、角色和资源证据,不重新分类人工 assignment。
|
||||
- 正式 CM95 102 条 assignment 均有 renderEvidence,status/standardLayer 与旧 1.0.0 逐条一致;Style 原文件、intake 与 Package SHA-256 对齐。
|
||||
- 新 ZIP `dist/cm95/s52-full/cm95-s52-full-1.1.0.navsea-provider.zip` 固定 9 文件,重复运行 SHA-256 稳定为 `fb5bc2606ab61662c16eccd22225532779830779e931f623bac4cf049e6b737f`;旧 1.0.0 保留。
|
||||
- Validation 为 PASS_WITH_WARNINGS,警告为原有 unresolved 和 limitations;审计记录在 `docs/报告/NAVSEA-PBF-MULTISOURCE-004A_CM95_MappingV2审计_v1.json`。本任务止于 004A,不进入 004B。
|
||||
|
||||
BIN
dist/cm95/s52-full/cm95-s52-full-1.1.0.navsea-provider.zip
vendored
Normal file
BIN
dist/cm95/s52-full/cm95-s52-full-1.1.0.navsea-provider.zip
vendored
Normal file
Binary file not shown.
71
docs/报告/NAVSEA-PBF-MULTISOURCE-004A_CM95_MappingV2审计_v1.json
Normal file
71
docs/报告/NAVSEA-PBF-MULTISOURCE-004A_CM95_MappingV2审计_v1.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"任务": "NAVSEA-PBF-MULTISOURCE-004A",
|
||||
"packageFilename": "cm95-s52-full-1.1.0.navsea-provider.zip",
|
||||
"packageSha256": "fb5bc2606ab61662c16eccd22225532779830779e931f623bac4cf049e6b737f",
|
||||
"mappingSchemaVersion": "navsea-standard-layer-mapping/v2",
|
||||
"assignmentCount": 102,
|
||||
"statusCounts": {
|
||||
"AMBIGUOUS": 2,
|
||||
"LAYER_ONLY": 47,
|
||||
"UNKNOWN": 44,
|
||||
"VISUAL_OBJECT_MATCH": 9
|
||||
},
|
||||
"assignmentsWithRenderEvidence": 102,
|
||||
"assignmentsMissingRenderEvidence": 0,
|
||||
"fullActualCatalogSummary": {
|
||||
"tileCount": 177383,
|
||||
"decoded_feature_count_in_styled_layers": 2699258,
|
||||
"matched_feature_occurrence_count": 976534,
|
||||
"distinct_visual_object_count": 475,
|
||||
"group_count": 26,
|
||||
"unsupported_component_value_count": 0,
|
||||
"missing_sprite_component_count": 0
|
||||
},
|
||||
"styleSha256": "b1d3d0060012a8e4b55a6053231231d742ec415b3a8398f9ae0867bbdfa5290b",
|
||||
"originalStyleSha256": "b1d3d0060012a8e4b55a6053231231d742ec415b3a8398f9ae0867bbdfa5290b",
|
||||
"distinctReferencedStyleLayerCount": 72,
|
||||
"componentRoleCounts": {
|
||||
"fill": 15,
|
||||
"icon": 72,
|
||||
"line": 39,
|
||||
"text": 19
|
||||
},
|
||||
"navigationMarksAssignmentCount": 20,
|
||||
"navigationMarksReferencedStyleLayerIds": [
|
||||
"s52-p03-sy-facility-boundary-point",
|
||||
"s52-p03-text1-facility-boundary-point",
|
||||
"s52-p08-lights05-sy-navigation-marks",
|
||||
"s52-p08-sy-navigation-marks",
|
||||
"s52-p08-sy-s52-points",
|
||||
"s52-p08-text1-navigation-marks",
|
||||
"s52-p08-text1-s52-points"
|
||||
],
|
||||
"knownConflictCount": 5,
|
||||
"knownConflictResources": [
|
||||
"BRTHNO01",
|
||||
"FSHHAV01",
|
||||
"MORFAC03",
|
||||
"PILPNT02",
|
||||
"ap-FSHHAV02"
|
||||
],
|
||||
"manualStatusAndLayerUnchanged": true,
|
||||
"mappingPolicyPackageAgree": true,
|
||||
"zipFileCount": 9,
|
||||
"zipFiles": [
|
||||
"cm95-s52-full-1.1.0/provider-package.json",
|
||||
"cm95-s52-full-1.1.0/provider-policy.json",
|
||||
"cm95-s52-full-1.1.0/mapping-layer-assignment.json",
|
||||
"cm95-s52-full-1.1.0/resources-style.json",
|
||||
"cm95-s52-full-1.1.0/resources-sprite.json",
|
||||
"cm95-s52-full-1.1.0/resources-sprite.png",
|
||||
"cm95-s52-full-1.1.0/validation.json",
|
||||
"cm95-s52-full-1.1.0/validation.md",
|
||||
"cm95-s52-full-1.1.0/MANIFEST.json"
|
||||
],
|
||||
"warnings": [
|
||||
"limitations:present",
|
||||
"mapping:unresolved_present"
|
||||
],
|
||||
"errors": [],
|
||||
"finalResult": "PASS"
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": "pbf-provider-source-manifest/v1",
|
||||
"provider": {"providerId": "cm95", "name": "CM95", "version": "full-style"},
|
||||
"dataset": {"datasetId": "s52-full", "name": "S-52 Full Style"},
|
||||
"package": {"packageId": "cm95-s52-full", "version": "1.0.0"},
|
||||
"package": {"packageId": "cm95-s52-full", "version": "1.1.0"},
|
||||
"resources": {
|
||||
"style": {"uri": "http://192.168.200.184:18889/s52/full-style.json"},
|
||||
"sprite": {"uri": "http://192.168.200.184:18889/s52/sprite"}
|
||||
@@ -10,7 +10,7 @@
|
||||
"tileSource": {"template": "http://192.168.200.184:18889/pbf/{z}/{x}/{y}.pbf"},
|
||||
"sampleTiles": ["12/3531/1641", "12/3526/1643", "12/3638/1613"],
|
||||
"probeGlyph": false,
|
||||
"mapping": {"mode": "manual-assignment", "assignmentFile": "../../tools/style-visual-object/output/cm95-manual-review/assignment/cm95-layer-assignment-v1.json"},
|
||||
"mapping": {"mode": "manual-assignment", "assignmentFile": "../../tools/style-visual-object/output/cm95-manual-review/assignment/cm95-layer-assignment-v1.json", "executionEvidenceCatalog": "../../tools/visual-object-instantiation/output/cm95-current/actual-anonymous-catalog.json"},
|
||||
"policy": {
|
||||
"display": {"enabled": true, "source": "provider-style"},
|
||||
"pickup": {"enabled": false, "rules": []},
|
||||
@@ -18,5 +18,5 @@
|
||||
"danger": {"enabled": false, "rules": []},
|
||||
"capabilities": {"vectorTiles": true, "sprite": true, "glyphs": false, "display": true, "pickup": false, "planning": false, "danger": false}
|
||||
},
|
||||
"limitations": ["人工 mapping 依 CM95 resource key 与 occurrence 视觉组件对照;本次实例化仅覆盖 manifest 样本瓦片。"]
|
||||
"limitations": ["人工 mapping 依资源键与视觉组件的已确认 review;occurrenceIds 仅覆盖 manifest 样本瓦片,全量覆盖计数以 renderEvidenceSource Catalog 为准。"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -11,8 +13,84 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/provider-policy-compiler"))
|
||||
from provider_policy import DELIVERY_FILES, build_policy, create_delivery_zip, export_package, fingerprint, validate_package, validate_policy
|
||||
|
||||
spec = importlib.util.spec_from_file_location("pipeline", ROOT / "tools/provider-pipeline/run_provider_pipeline.py")
|
||||
pipeline = importlib.util.module_from_spec(spec)
|
||||
assert spec and spec.loader
|
||||
spec.loader.exec_module(pipeline)
|
||||
|
||||
|
||||
class ProviderPipelineContractTests(unittest.TestCase):
|
||||
def test_v2_full_catalog_evidence_and_fail_closed(self):
|
||||
style = {"layers": [{"id": "layer-a", "source-layer": "points"}, {"id": "layer-b", "source-layer": "points"}]}
|
||||
components = [
|
||||
{"style_layer_id": "layer-a", "style_index": 0, "role": "icon", "resource_key": "ICON", "resource_visual_hash": "a" * 64},
|
||||
{"style_layer_id": "layer-b", "style_index": 1, "role": "text", "resource_key": None, "resource_visual_hash": None},
|
||||
]
|
||||
catalog = {"visual_objects": [{"visual_object_id": "VO-a", "provenance": {"source_layer": "points"}, "geometry_role": "Point", "occurrence_count": 123, "components": components}]}
|
||||
rows = [{"visualObjectId": "VO-a", "status": "LAYER_ONLY", "standardLayer": "navigation_marks"}, {"visualObjectId": "VO-missing", "status": "UNKNOWN", "standardLayer": None}]
|
||||
self.assertEqual(pipeline.attach_render_evidence(rows, catalog), ["VO-missing"])
|
||||
self.assertIsNone(rows[1]["renderEvidence"])
|
||||
self.assertEqual([x["role"] for x in rows[0]["renderEvidence"]["components"]], ["icon", "text"])
|
||||
self.assertIsNone(rows[0]["renderEvidence"]["components"][1]["resourceKey"])
|
||||
self.assertEqual(rows[0]["renderEvidence"]["occurrenceCount"], 123)
|
||||
from provider_policy import validate_mapping
|
||||
mapping = {"schemaVersion": "navsea-standard-layer-mapping/v2", "renderEvidenceSource": {"schema": "actual-visual-object-catalog/v1"}, "assignments": rows}
|
||||
self.assertEqual(validate_mapping(mapping, style), [])
|
||||
for key, value, expected in (("styleLayerId", "missing", "styleLayerId:missing"), ("styleIndex", 9, "styleIndex:mismatch")):
|
||||
rows[0]["renderEvidence"]["components"][0][key] = value
|
||||
self.assertTrue(any(expected in x for x in validate_mapping(mapping, style)))
|
||||
rows[0]["renderEvidence"]["components"][0][key] = components[0][{"styleLayerId": "style_layer_id", "styleIndex": "style_index"}[key]]
|
||||
rows[0]["renderEvidence"]["sourceLayer"] = "wrong"
|
||||
self.assertTrue(any("sourceLayer:mismatch" in x for x in validate_mapping(mapping, style)))
|
||||
|
||||
def test_execution_catalog_style_hash_and_schema(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
style = base / "style.json"
|
||||
style.write_bytes(b"{}")
|
||||
catalog = base / "catalog.json"
|
||||
catalog.write_text(json.dumps({"schema": "actual-visual-object-catalog/v1", "inputs": {"style": str(style)}, "summary": {}, "visual_objects": []}))
|
||||
manifest = base / "manifest.json"
|
||||
expected = hashlib.sha256(b"{}").hexdigest()
|
||||
self.assertEqual(pipeline.load_execution_catalog(manifest, "catalog.json", expected)[1]["styleSha256"], expected)
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
pipeline.load_execution_catalog(manifest, "catalog.json", "0" * 64)
|
||||
|
||||
def test_cm95_existing_semantics_and_specific_visual_objects(self):
|
||||
base = ROOT / "dist/cm95/s52-full/1.0.0/mapping-layer-assignment.json"
|
||||
catalog_file = ROOT / "tools/visual-object-instantiation/output/cm95-current/actual-anonymous-catalog.json"
|
||||
if not base.exists() or not catalog_file.exists():
|
||||
self.skipTest("本机未提供 CM95 样本与全量 Catalog")
|
||||
previous = json.loads(base.read_text())["assignments"]
|
||||
rows = json.loads(json.dumps(previous))
|
||||
catalog = json.loads(catalog_file.read_text())
|
||||
self.assertEqual(len(rows), 102)
|
||||
self.assertEqual(pipeline.attach_render_evidence(rows, catalog), [])
|
||||
self.assertEqual([(x["visualObjectId"], x["status"], x["standardLayer"]) for x in rows], [(x["visualObjectId"], x["status"], x["standardLayer"]) for x in previous])
|
||||
boy = next(x for x in rows if x["visualObjectId"] == "VO-2329594761200ffd")
|
||||
self.assertEqual(boy["standardLayer"], "navigation_marks")
|
||||
self.assertEqual(boy["renderEvidence"]["sourceLayer"], "s52_points")
|
||||
self.assertTrue(any(c["styleLayerId"] == "s52-p08-sy-s52-points" and c["role"] == "icon" and c["resourceKey"] == "BOYCON61" for c in boy["renderEvidence"]["components"]))
|
||||
self.assertTrue(any(x["standardLayer"] == "navigation_marks" and len(x["renderEvidence"]["components"]) > 1 and any((c["resourceKey"] or "").startswith("LIGHTS") or c["role"] == "text" for c in x["renderEvidence"]["components"]) for x in rows))
|
||||
|
||||
def test_generated_mapping_policy_package_and_zip_agree(self):
|
||||
base = ROOT / "dist/cm95/s52-full/1.1.0"
|
||||
if not base.exists():
|
||||
self.skipTest("正式 CM95 1.1.0 Package 尚未生成")
|
||||
mapping = json.loads((base / "mapping-layer-assignment.json").read_text())
|
||||
policy = json.loads((base / "provider-policy.json").read_text())
|
||||
package = json.loads((base / "provider-package.json").read_text())
|
||||
self.assertEqual(mapping["schemaVersion"], "navsea-standard-layer-mapping/v2")
|
||||
self.assertEqual({k: v for k, v in mapping.items() if k != "source"}, policy["mapping"])
|
||||
self.assertEqual(policy["mapping"], package["standardLayerMapping"])
|
||||
z = base.parent / "cm95-s52-full-1.1.0.navsea-provider.zip"
|
||||
with zipfile.ZipFile(z) as archive:
|
||||
self.assertEqual(len(archive.namelist()), 9)
|
||||
self.assertIsNone(archive.testzip())
|
||||
first = fingerprint(mapping)
|
||||
second = fingerprint(json.loads((base / "mapping-layer-assignment.json").read_text()))
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def data(self, provider_id: str) -> dict:
|
||||
return {
|
||||
"provider": {"providerId": provider_id, "name": provider_id, "version": "1"},
|
||||
|
||||
@@ -140,6 +140,52 @@ def manual_assignment(actual: dict[str, Any], assignment_doc: dict[str, Any]) ->
|
||||
return rows
|
||||
|
||||
|
||||
def load_execution_catalog(manifest_path: Path, relative: str, style_hash: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
catalog_path = (manifest_path.parent / relative).resolve()
|
||||
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
||||
if catalog.get("schema") != "actual-visual-object-catalog/v1" or not isinstance(catalog.get("visual_objects"), list):
|
||||
raise ValueError("executionEvidenceCatalog schema 无效")
|
||||
style_path = Path(catalog.get("inputs", {}).get("style", ""))
|
||||
if not style_path.is_file():
|
||||
raise ValueError("executionEvidenceCatalog Style 输入不存在")
|
||||
catalog_style_hash = sha(style_path.read_bytes())
|
||||
if catalog_style_hash != style_hash:
|
||||
raise ValueError("executionEvidenceCatalog Style SHA-256 与 intake Style 不一致")
|
||||
summary = catalog.get("summary", {})
|
||||
source = {
|
||||
"schema": catalog["schema"], "catalog": relative,
|
||||
"styleSha256": catalog_style_hash,
|
||||
"tileCount": catalog.get("inputs", {}).get("tile_count"),
|
||||
"matchedOccurrenceCount": summary.get("matched_feature_occurrence_count"),
|
||||
"distinctVisualObjectCount": summary.get("distinct_visual_object_count"),
|
||||
}
|
||||
return catalog, source
|
||||
|
||||
|
||||
def attach_render_evidence(assignments: list[dict[str, Any]], catalog: dict[str, Any]) -> list[str]:
|
||||
objects = {obj["visual_object_id"]: obj for obj in catalog["visual_objects"]}
|
||||
missing = []
|
||||
for row in assignments:
|
||||
obj = objects.get(row["visualObjectId"])
|
||||
if obj is None:
|
||||
row["renderEvidence"] = None
|
||||
missing.append(row["visualObjectId"])
|
||||
continue
|
||||
row["renderEvidence"] = {
|
||||
"sourceLayer": obj.get("provenance", {}).get("source_layer"),
|
||||
"geometryRole": obj.get("geometry_role"),
|
||||
"occurrenceCount": obj.get("occurrence_count"),
|
||||
"components": [{
|
||||
"styleLayerId": component.get("style_layer_id"),
|
||||
"styleIndex": component.get("style_index"),
|
||||
"role": component.get("role"),
|
||||
"resourceKey": component.get("resource_key"),
|
||||
"resourceVisualHash": component.get("resource_visual_hash"),
|
||||
} for component in obj.get("components", [])],
|
||||
}
|
||||
return missing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--manifest", required=True, help="Provider manifest JSON")
|
||||
@@ -250,12 +296,22 @@ def main() -> int:
|
||||
occurrence_lookup.setdefault(occ["visual_object_id"], []).append(occ["occurrence_id"])
|
||||
for row in assignments:
|
||||
row["occurrenceIds"] = occurrence_lookup.get(row["visualObjectId"], [])
|
||||
mapping_doc = {"schemaVersion": "navsea-standard-layer-mapping/v1", "source": mapping_source, "assignments": assignments}
|
||||
evidence_path = manifest.get("mapping", {}).get("executionEvidenceCatalog")
|
||||
evidence_source = None
|
||||
evidence_missing: list[str] = []
|
||||
if evidence_path:
|
||||
execution_catalog, evidence_source = load_execution_catalog(manifest_path, evidence_path, style_info["sha256"])
|
||||
evidence_missing = attach_render_evidence(assignments, execution_catalog)
|
||||
mapping_doc = {"schemaVersion": "navsea-standard-layer-mapping/v2" if evidence_path else "navsea-standard-layer-mapping/v1", "source": mapping_source, "assignments": assignments}
|
||||
if evidence_source:
|
||||
mapping_doc["renderEvidenceSource"] = evidence_source
|
||||
mapping_doc["diagnostics"] = [{"code": "renderEvidence:catalog_missing_visual_object", "visualObjectId": x} for x in evidence_missing]
|
||||
mapping_path = work / "pbf-04-mapping" / "provider-layer-assignment.json"
|
||||
mapping_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mapping_path.write_text(json.dumps(mapping_doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
unresolved = [{"visualObjectId": x["visualObjectId"], "status": x["status"], "candidateStandardLayers": x["candidateStandardLayers"]} for x in assignments if x["status"] in {"AMBIGUOUS", "UNKNOWN", "COMPONENT_ONLY"}]
|
||||
record["stages"]["PBF-04"] = {"status": "PASS", "mappingMode": mapping_mode, "assignments": len(assignments), "unresolved": len(unresolved), "source": mapping_source}
|
||||
unresolved += [{"visualObjectId": x, "status": "MISSING_RENDER_EVIDENCE"} for x in evidence_missing]
|
||||
record["stages"]["PBF-04"] = {"status": "PASS_WITH_WARNINGS" if evidence_missing else "PASS", "mappingMode": mapping_mode, "assignments": len(assignments), "unresolved": len(unresolved), "missingRenderEvidence": evidence_missing, "source": mapping_source}
|
||||
|
||||
fingerprints = {
|
||||
"source": source_manifest["sourceFingerprint"], "style": style_info["sha256"],
|
||||
@@ -273,24 +329,24 @@ def main() -> int:
|
||||
"sprite": {"uri": sprite_base, "jsonSha256": sprite_info.get("sha256"), "pngSha256": sprite_info.get("png", {}).get("sha256")},
|
||||
"glyphs": glyph_info,
|
||||
},
|
||||
"mapping": assignments,
|
||||
"mapping": assignments, "mappingSchemaVersion": mapping_doc["schemaVersion"], "renderEvidenceSource": evidence_source, "mappingDiagnostics": mapping_doc.get("diagnostics", []),
|
||||
"display": manifest.get("policy", {}).get("display", {}), "pickup": manifest.get("policy", {}).get("pickup", {}),
|
||||
"planning": manifest.get("policy", {}).get("planning", {}), "danger": manifest.get("policy", {}).get("danger", {}),
|
||||
"capabilities": manifest.get("policy", {}).get("capabilities", {}),
|
||||
"validationRequirements": {"requireStyle": True, "requirePbfSample": True, "requireSprite": True, "minimumMappingCount": 1},
|
||||
"unresolved": unresolved, "limitations": manifest.get("limitations", []),
|
||||
"provenance": {"manifest": str(manifest_path), "mappingSource": mapping_source, "pipelineVersion": "1.1.0", "generatedAt": datetime.now(timezone.utc).isoformat()},
|
||||
"provenance": {"manifest": str(manifest_path), "mappingSource": mapping_source, "pipelineVersion": "1.1.0", "generatedAt": None if evidence_path else datetime.now(timezone.utc).isoformat()},
|
||||
"fingerprints": fingerprints,
|
||||
}
|
||||
record["stages"]["PBF-05"] = {"status": "PASS", "schemaVersion": "navsea-provider-policy/v1"}
|
||||
policy = build_policy(data)
|
||||
policy_validation = validate_policy(policy)
|
||||
policy_validation = validate_policy(policy, style)
|
||||
if policy_validation["result"] == "FAIL":
|
||||
raise RuntimeError(f"Provider Policy 校验 FAIL: {policy_validation['errors']}")
|
||||
(work / "pbf-05-policy.json").write_text(json.dumps(policy, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
record["stages"]["PBF-05"]["validation"] = policy_validation
|
||||
package_doc = build_package(data, policy, policy_validation)
|
||||
validation = validate_package(package_doc)
|
||||
validation = validate_package(package_doc, style)
|
||||
package_doc["validation"] = validation
|
||||
record["stages"]["PBF-06"] = {"status": validation["result"], "schemaVersion": "navsea-provider-package/v1", "validation": validation}
|
||||
record["validationResult"] = validation["result"]
|
||||
|
||||
@@ -51,7 +51,52 @@ def file_fingerprint(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def validate_policy(policy: dict[str, Any]) -> dict[str, Any]:
|
||||
def validate_mapping(mapping: dict[str, Any], style: dict[str, Any] | None = None) -> list[str]:
|
||||
errors: list[str] = []
|
||||
version = mapping.get("schemaVersion")
|
||||
if version not in {"navsea-standard-layer-mapping/v1", "navsea-standard-layer-mapping/v2"}:
|
||||
return ["mapping:schemaVersion:unsupported"]
|
||||
if version.endswith("/v1"):
|
||||
return errors
|
||||
source = mapping.get("renderEvidenceSource")
|
||||
if not isinstance(source, dict) or source.get("schema") != "actual-visual-object-catalog/v1":
|
||||
errors.append("mapping:renderEvidenceSource:invalid")
|
||||
layers = style.get("layers", []) if style is not None else None
|
||||
by_id = {layer.get("id"): (index, layer) for index, layer in enumerate(layers or [])}
|
||||
for i, row in enumerate(mapping.get("assignments", [])):
|
||||
evidence = row.get("renderEvidence")
|
||||
if evidence is None:
|
||||
continue
|
||||
if not isinstance(evidence, dict) or not isinstance(evidence.get("sourceLayer"), str) or not isinstance(evidence.get("geometryRole"), str) or not isinstance(evidence.get("occurrenceCount"), int) or evidence["occurrenceCount"] < 0 or not isinstance(evidence.get("components"), list):
|
||||
errors.append(f"mapping[{i}]:renderEvidence:invalid")
|
||||
continue
|
||||
previous = -1
|
||||
for j, component in enumerate(evidence["components"]):
|
||||
if not isinstance(component, dict):
|
||||
errors.append(f"mapping[{i}]:component[{j}]:invalid")
|
||||
continue
|
||||
index = component.get("styleIndex")
|
||||
layer_id = component.get("styleLayerId")
|
||||
if not isinstance(layer_id, str) or not isinstance(index, int) or isinstance(index, bool) or not isinstance(component.get("role"), str) or not component["role"] or (component.get("resourceKey") is not None and not isinstance(component["resourceKey"], str)) or (component.get("resourceVisualHash") is not None and not isinstance(component["resourceVisualHash"], str)):
|
||||
errors.append(f"mapping[{i}]:component[{j}]:invalid")
|
||||
continue
|
||||
if any(token in value.lower() for value in (layer_id, component["role"], component.get("resourceKey") or "") for token in ("<script", "javascript:", "eval(", "function(", "=>")):
|
||||
errors.append(f"mapping[{i}]:component[{j}]:script_forbidden")
|
||||
if index < previous:
|
||||
errors.append(f"mapping[{i}]:component_order:invalid")
|
||||
previous = index
|
||||
if layers is not None:
|
||||
entry = by_id.get(layer_id)
|
||||
if entry is None:
|
||||
errors.append(f"mapping[{i}]:component[{j}]:styleLayerId:missing")
|
||||
elif entry[0] != index:
|
||||
errors.append(f"mapping[{i}]:component[{j}]:styleIndex:mismatch")
|
||||
elif entry[1].get("source-layer") != evidence["sourceLayer"]:
|
||||
errors.append(f"mapping[{i}]:component[{j}]:sourceLayer:mismatch")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_policy(policy: dict[str, Any], style: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
required = ("schemaVersion", "provider", "dataset", "package", "standardCatalog", "resources", "mapping", "display", "pickup", "planning", "danger", "capabilities", "validationRequirements", "unresolved", "limitations", "provenance", "fingerprints")
|
||||
errors += [f"missing:{key}" for key in required if key not in policy]
|
||||
@@ -65,6 +110,8 @@ def validate_policy(policy: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not isinstance(value.get(field), str) or not value[field].strip():
|
||||
errors.append(f"identity:{section}.{field}:required")
|
||||
mapping = policy.get("mapping", {}).get("assignments", []) if isinstance(policy.get("mapping"), dict) else []
|
||||
if isinstance(policy.get("mapping"), dict):
|
||||
errors += validate_mapping(policy["mapping"], style)
|
||||
if not isinstance(mapping, list):
|
||||
errors.append("type:mapping.assignments:array_required")
|
||||
else:
|
||||
@@ -83,7 +130,7 @@ def validate_policy(policy: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"schemaVersion": "navsea-provider-validation-report/v1", "result": "FAIL" if errors else "PASS", "errors": errors, "warnings": []}
|
||||
|
||||
|
||||
def validate_package(package: dict[str, Any]) -> dict[str, Any]:
|
||||
def validate_package(package: dict[str, Any], style: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
required = ("schemaVersion", "provider", "dataset", "package", "resources", "standardLayerMapping", "providerPolicy", "display", "pickup", "planning", "danger", "capabilities", "validationRequirements", "fingerprints", "unresolved", "limitations", "provenance")
|
||||
@@ -97,7 +144,7 @@ def validate_package(package: dict[str, Any]) -> dict[str, Any]:
|
||||
package_identity = package.get("package", {})
|
||||
if not package_identity.get("version"):
|
||||
errors.append("identity:package.version:required")
|
||||
policy_result = validate_policy(package.get("providerPolicy", {}))
|
||||
policy_result = validate_policy(package.get("providerPolicy", {}), style)
|
||||
if policy_result["result"] == "FAIL":
|
||||
errors.append("providerPolicy:invalid")
|
||||
requirements = package.get("validationRequirements", {})
|
||||
@@ -113,6 +160,14 @@ def validate_package(package: dict[str, Any]) -> dict[str, Any]:
|
||||
if not resource.get("sha256"):
|
||||
warnings.append("resource:pbfSample:hash_missing")
|
||||
mapping = package.get("standardLayerMapping", {}).get("assignments", [])
|
||||
if package.get("standardLayerMapping") != package.get("providerPolicy", {}).get("mapping"):
|
||||
errors.append("mapping:package_policy:mismatch")
|
||||
if package.get("standardLayerMapping", {}).get("schemaVersion") == "navsea-standard-layer-mapping/v2":
|
||||
source = package["standardLayerMapping"].get("renderEvidenceSource", {})
|
||||
if source.get("styleSha256") != package.get("resources", {}).get("style", {}).get("sha256"):
|
||||
errors.append("mapping:styleSha256:mismatch")
|
||||
if any(row.get("renderEvidence") is None for row in mapping):
|
||||
warnings.append("mapping:renderEvidence_missing")
|
||||
if not mapping:
|
||||
warnings.append("mapping:empty")
|
||||
if package.get("unresolved"):
|
||||
@@ -127,7 +182,7 @@ def build_policy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"schemaVersion": POLICY_SCHEMA,
|
||||
"provider": data["provider"], "dataset": data["dataset"], "package": data["package"],
|
||||
"standardCatalog": data["standardCatalog"], "resources": data["resources"],
|
||||
"mapping": {"schemaVersion": "navsea-standard-layer-mapping/v1", "assignments": data["mapping"]},
|
||||
"mapping": {"schemaVersion": data.get("mappingSchemaVersion", "navsea-standard-layer-mapping/v1"), "assignments": data["mapping"]},
|
||||
"display": data.get("display", {}), "pickup": data.get("pickup", {}),
|
||||
"planning": data.get("planning", {}), "danger": data.get("danger", {}),
|
||||
"capabilities": data.get("capabilities", {}),
|
||||
@@ -135,6 +190,9 @@ def build_policy(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"unresolved": data.get("unresolved", []), "limitations": data.get("limitations", []),
|
||||
"provenance": data.get("provenance", {}), "fingerprints": data["fingerprints"],
|
||||
}
|
||||
if data.get("renderEvidenceSource"):
|
||||
policy["mapping"]["renderEvidenceSource"] = data["renderEvidenceSource"]
|
||||
policy["mapping"]["diagnostics"] = data.get("mappingDiagnostics", [])
|
||||
policy["fingerprints"]["policy"] = fingerprint({k: v for k, v in policy.items() if k != "fingerprints"})
|
||||
return policy
|
||||
|
||||
@@ -168,7 +226,7 @@ def export_package(output: Path, package: dict[str, Any], policy: dict[str, Any]
|
||||
manifest = {
|
||||
"manifestSchemaVersion": "navsea-provider-package-manifest/v1",
|
||||
"provider": package["provider"], "dataset": package["dataset"], "package": package["package"],
|
||||
"createdAt": datetime.now(timezone.utc).isoformat(), "fingerprints": package["fingerprints"],
|
||||
"createdAt": None if policy["mapping"]["schemaVersion"].endswith("/v2") else datetime.now(timezone.utc).isoformat(), "fingerprints": package["fingerprints"],
|
||||
"validationResult": validation["result"], "knownLimitations": package["limitations"],
|
||||
"files": {name: file_fingerprint(output / name) for name in ("provider-package.json", "provider-policy.json", "validation.json", "validation.md")},
|
||||
}
|
||||
@@ -200,7 +258,10 @@ def create_delivery_zip(export_dir: Path, provider_id: str, dataset_id: str, ver
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
||||
for name in DELIVERY_FILES:
|
||||
archive.write(export_dir / name, arcname=f"{root_name}/{name}")
|
||||
info = zipfile.ZipInfo(f"{root_name}/{name}", date_time=(2026, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o644 << 16
|
||||
archive.writestr(info, (export_dir / name).read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as archive:
|
||||
actual = archive.namelist()
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dataset": {"type": "object", "required": ["datasetId"], "properties": {"datasetId": {"type": "string", "minLength": 1}}},
|
||||
"package": {"type": "object", "required": ["packageId", "version"], "properties": {"packageId": {"type": "string"}, "version": {"type": "string"}}},
|
||||
"standardCatalog": {"type": "object"}, "resources": {"type": "object"},
|
||||
"mapping": {"type": "object", "required": ["schemaVersion", "assignments"], "properties": {"schemaVersion": {"const": "navsea-standard-layer-mapping/v1"}, "assignments": {"type": "array"}}},
|
||||
"mapping": {"type": "object", "required": ["schemaVersion", "assignments"], "properties": {"schemaVersion": {"enum": ["navsea-standard-layer-mapping/v1", "navsea-standard-layer-mapping/v2"]}, "assignments": {"type": "array"}, "renderEvidenceSource": {"type": "object"}, "diagnostics": {"type": "array"}}},
|
||||
"display": {"type": "object"}, "pickup": {"type": "object"}, "planning": {"type": "object"}, "danger": {"type": "object"},
|
||||
"capabilities": {"type": "object"}, "validationRequirements": {"type": "object"}, "unresolved": {"type": "array"}, "limitations": {"type": "array"}, "provenance": {"type": "object"}, "fingerprints": {"type": "object"}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user