validate_v4_config_contract.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742
  1. """Validate V4 M0 config contract without switching V3 production rule packs."""
  2. from __future__ import annotations
  3. import json
  4. import sys
  5. from pathlib import Path
  6. from typing import Any
  7. ROOT = Path(__file__).resolve().parents[1]
  8. DATA_DIR = ROOT / "tech_documents/数据接口与来源"
  9. RULE_PACK_PATH = ROOT / "product_documents/规则包/douyin_rule_packs.v1.json"
  10. WALK_STRATEGY_PATH = ROOT / "product_documents/抖音游走策略/douyin_walk_strategy.v1.json"
  11. WALK_EDGE_CATALOG_PATH = ROOT / "tech_documents/数据接口与来源/walk_edge_catalog.json"
  12. LEGACY_FIELD_BLOCKLIST = {
  13. "fit_senior_50plus",
  14. "fit_confidence",
  15. "relevance_score",
  16. "platform_heat",
  17. "age_50_plus_level",
  18. }
  19. ENDPOINT_STATUSES = {
  20. "verified",
  21. "verified_unstable",
  22. "blocked",
  23. "source_only",
  24. "missing",
  25. }
  26. M2_PLATFORM_PROFILES = {"douyin", "kuaishou", "shipinhao"}
  27. PROFILE_EDGE_STATUSES = {"supported", "blocked", "no_interface", "planned"}
  28. PROFILE_ENDPOINT_STATUSES = {"verified", "verified_unstable", "blocked", "source_only", "missing"}
  29. OBSERVABLE_FIELDS = {
  30. "statistics.digg_count",
  31. "statistics.comment_count",
  32. "statistics.share_count",
  33. "statistics.collect_count",
  34. "statistics.play_count",
  35. }
  36. MISSING_OBSERVABLE_TYPES = {"natural_platform_missing", "runtime_missing"}
  37. M4_WALK_GATE_EDGES = {"hashtag_to_query", "author_to_works"}
  38. M4_WALK_GATE_RAW_FIELDS = {"decision_id", "allow_walk", "allow_walk_reason", "walk_gate_snapshot"}
  39. V4_BASE_ACTIVE_DIMENSIONS = {"query_relevance", "platform_performance"}
  40. V4_REQUIRED_SCORE_WEIGHT_PROFILES = {
  41. "default_two_dimension",
  42. "douyin_fifty_plus_ok",
  43. "douyin_fifty_plus_not_attempted",
  44. }
  45. def main() -> int:
  46. findings = validate_v4_config_contract(ROOT)
  47. payload = {"status": "fail" if findings else "pass", "findings": findings}
  48. print(json.dumps(payload, ensure_ascii=False, indent=2))
  49. return 1 if findings else 0
  50. def validate_v4_config_contract(root: Path = ROOT) -> list[dict[str, str]]:
  51. data_dir = root / "tech_documents/数据接口与来源"
  52. findings: list[dict[str, str]] = []
  53. walk_graph = _load_json(data_dir / "walk_graph.json", findings)
  54. graph_edges: set[str] = set()
  55. if walk_graph:
  56. _check_no_legacy_fields(findings, walk_graph, "walk_graph.json")
  57. _check_value(findings, "walk_graph_schema", walk_graph.get("schema_version"), "walk_graph.v2")
  58. _check_count(findings, "walk_graph_nodes", "walk_graph.nodes", walk_graph.get("nodes"), 8)
  59. _check_count(findings, "walk_graph_edges", "walk_graph.edges", walk_graph.get("edges"), 9)
  60. graph_edges = {
  61. edge.get("edge_id")
  62. for edge in walk_graph.get("edges", [])
  63. if isinstance(edge, dict) and edge.get("edge_id")
  64. }
  65. walk_edge_catalog = _load_json(data_dir / "walk_edge_catalog.json", findings)
  66. catalog_edges: set[str] = set()
  67. if walk_edge_catalog:
  68. _check_no_legacy_fields(findings, walk_edge_catalog, "walk_edge_catalog.json")
  69. _check_value(
  70. findings,
  71. "walk_edge_catalog_schema",
  72. walk_edge_catalog.get("schema_version"),
  73. "walk_edge_catalog.v1",
  74. )
  75. rows = walk_edge_catalog.get("walk_edge_catalog")
  76. _check_count(findings, "walk_edge_catalog_edges", "walk_edge_catalog", rows, 9)
  77. catalog_edges = {
  78. row.get("edge_id")
  79. for row in rows or []
  80. if isinstance(row, dict) and row.get("edge_id")
  81. }
  82. if walk_graph and graph_edges != catalog_edges:
  83. _fail(
  84. findings,
  85. "walk_graph_catalog_mismatch",
  86. f"walk_graph edges and walk_edge_catalog differ: graph_only={sorted(graph_edges - catalog_edges)}, catalog_only={sorted(catalog_edges - graph_edges)}",
  87. )
  88. walk_policy = _load_json(data_dir / "walk_policy.json", findings)
  89. if walk_policy:
  90. _check_no_legacy_fields(findings, walk_policy, "walk_policy.json")
  91. _check_value(findings, "walk_policy_schema", walk_policy.get("schema_version"), "walk_policy.v1")
  92. for key in ["global", "edge_budgets", "dedup", "edge_permissions", "v4_walk_gate"]:
  93. if key not in walk_policy:
  94. _fail(findings, "walk_policy_missing_key", f"walk_policy.json missing {key}")
  95. _check_v4_walk_gate_config(findings, walk_policy.get("v4_walk_gate"), "walk_policy.v4_walk_gate")
  96. if walk_graph:
  97. graph_edges = {
  98. edge.get("edge_id")
  99. for edge in walk_graph.get("edges", [])
  100. if isinstance(edge, dict)
  101. }
  102. missing = sorted(M4_WALK_GATE_EDGES - graph_edges)
  103. if missing:
  104. _fail(findings, "v4_walk_gate_graph_edges_missing", f"walk_graph missing M4 gate edges: {missing}")
  105. endpoint_registry = _load_json(data_dir / "crawler_endpoints.registry.json", findings)
  106. if endpoint_registry:
  107. _check_no_legacy_fields(findings, endpoint_registry, "crawler_endpoints.registry.json")
  108. _check_value(
  109. findings,
  110. "crawler_endpoints_schema",
  111. endpoint_registry.get("registry_version"),
  112. "crawler_endpoints.v1",
  113. )
  114. endpoints = endpoint_registry.get("endpoints")
  115. if not isinstance(endpoints, list):
  116. _fail(findings, "crawler_endpoints_invalid", "endpoints must be a list")
  117. else:
  118. _check_count(findings, "crawler_endpoints_count", "endpoints", endpoints, 27)
  119. for endpoint in endpoints:
  120. if not isinstance(endpoint, dict):
  121. _fail(findings, "crawler_endpoint_invalid", "endpoint row must be object")
  122. continue
  123. for key in [
  124. "platform",
  125. "source_id",
  126. "status",
  127. "table_or_endpoint",
  128. "input_fields",
  129. "output_fields",
  130. ]:
  131. if key not in endpoint:
  132. _fail(
  133. findings,
  134. "crawler_endpoint_missing_key",
  135. f"{endpoint.get('source_id')} missing {key}",
  136. )
  137. status = endpoint.get("status")
  138. if not isinstance(status, (str, list)):
  139. _fail(
  140. findings,
  141. "crawler_endpoint_status_invalid",
  142. f"{endpoint.get('source_id')} status must be string or list",
  143. )
  144. continue
  145. statuses = status if isinstance(status, list) else [status]
  146. invalid_statuses = [item for item in statuses if item not in ENDPOINT_STATUSES]
  147. if invalid_statuses:
  148. _fail(
  149. findings,
  150. "crawler_endpoint_status_unknown",
  151. f"{endpoint.get('source_id')} unknown status: {invalid_statuses}",
  152. )
  153. field_map = _load_json(data_dir / "跨平台字段映射.json", findings)
  154. if field_map:
  155. _check_no_legacy_fields(findings, field_map, "跨平台字段映射.json")
  156. _check_value(
  157. findings,
  158. "field_map_schema",
  159. field_map.get("schema_version"),
  160. "cross_platform_field_map.v1",
  161. )
  162. if not isinstance(field_map.get("mappings"), dict):
  163. _fail(findings, "field_map_mappings_invalid", "mappings must be an object")
  164. for profile_path in sorted((data_dir / "platform_profiles").glob("*.json")):
  165. profile = _load_json(profile_path, findings)
  166. if not profile:
  167. continue
  168. _check_no_legacy_fields(findings, profile, profile_path.name)
  169. _check_value(
  170. findings,
  171. "platform_profile_schema",
  172. profile.get("schema_version"),
  173. "platform_profile.v1",
  174. label=profile_path.name,
  175. )
  176. for key in ["platform", "status", "runtime", "endpoints", "edges"]:
  177. if key not in profile:
  178. _fail(findings, "platform_profile_missing_key", f"{profile_path.name} missing {key}")
  179. _check_platform_profile_edges(findings, profile, profile_path.name, graph_edges or catalog_edges)
  180. if profile.get("platform") in M2_PLATFORM_PROFILES:
  181. _check_m2_platform_profile(findings, profile, profile_path.name)
  182. rule_pack_pkg = _load_json(RULE_PACK_PATH, findings)
  183. if rule_pack_pkg:
  184. _check_v4_rule_pack_contract(findings, rule_pack_pkg)
  185. walk_strategy = _load_json(WALK_STRATEGY_PATH, findings)
  186. if walk_strategy:
  187. _check_no_legacy_fields(findings, walk_strategy, "douyin_walk_strategy.v1.json")
  188. _check_v4_walk_strategy_contract(findings, walk_strategy, catalog_edges or graph_edges)
  189. return findings
  190. def assert_no_v4_legacy_fields(value: Any, label: str = "v4_contract") -> list[str]:
  191. paths: list[str] = []
  192. _collect_legacy_paths(value, label, paths)
  193. return paths
  194. def _check_no_legacy_fields(
  195. findings: list[dict[str, str]],
  196. value: Any,
  197. label: str,
  198. ) -> None:
  199. paths = assert_no_v4_legacy_fields(value, label)
  200. if paths:
  201. _fail(
  202. findings,
  203. "v4_legacy_field_present",
  204. f"{label} contains legacy fields: {', '.join(paths[:5])}",
  205. )
  206. def _collect_legacy_paths(value: Any, prefix: str, paths: list[str]) -> None:
  207. if isinstance(value, dict):
  208. for key, child in value.items():
  209. child_path = f"{prefix}.{key}"
  210. if key in LEGACY_FIELD_BLOCKLIST:
  211. paths.append(child_path)
  212. _collect_legacy_paths(child, child_path, paths)
  213. elif isinstance(value, list):
  214. for index, child in enumerate(value):
  215. _collect_legacy_paths(child, f"{prefix}[{index}]", paths)
  216. elif isinstance(value, str):
  217. if _string_has_legacy_field(value):
  218. paths.append(prefix)
  219. def _string_has_legacy_field(value: str) -> bool:
  220. normalized = value.replace("[", ".").replace("]", ".").replace("/", ".")
  221. parts = [part.strip() for part in normalized.split(".")]
  222. return any(part in LEGACY_FIELD_BLOCKLIST for part in parts)
  223. def _load_json(path: Path, findings: list[dict[str, str]]) -> dict[str, Any] | None:
  224. try:
  225. return json.loads(path.read_text(encoding="utf-8"))
  226. except FileNotFoundError:
  227. _fail(findings, "file_missing", f"missing file: {path.relative_to(ROOT)}")
  228. except json.JSONDecodeError as exc:
  229. _fail(findings, "json_parse_failed", f"{path.relative_to(ROOT)} cannot parse: {exc}")
  230. return None
  231. def _check_value(
  232. findings: list[dict[str, str]],
  233. check_id: str,
  234. actual: Any,
  235. expected: Any,
  236. *,
  237. label: str = "",
  238. ) -> None:
  239. if actual != expected:
  240. target = f"{label} " if label else ""
  241. _fail(findings, check_id, f"{target}expected {expected}, got {actual}")
  242. def _check_count(
  243. findings: list[dict[str, str]],
  244. check_id: str,
  245. label: str,
  246. value: Any,
  247. expected: int,
  248. ) -> None:
  249. if not isinstance(value, list) or len(value) != expected:
  250. actual = len(value) if isinstance(value, list) else None
  251. _fail(findings, check_id, f"{label} expected {expected}, got {actual}")
  252. def _check_platform_profile_edges(
  253. findings: list[dict[str, str]],
  254. profile: dict[str, Any],
  255. label: str,
  256. edge_ids: set[str],
  257. ) -> None:
  258. edges = profile.get("edges")
  259. if not isinstance(edges, dict):
  260. _fail(findings, "platform_profile_edges_invalid", f"{label} edges must be an object")
  261. return
  262. for edge_id, edge in edges.items():
  263. if edge_ids and edge_id not in edge_ids:
  264. _fail(findings, "platform_profile_edge_unknown", f"{label}.{edge_id} unknown edge")
  265. if not isinstance(edge, dict):
  266. _fail(findings, "platform_profile_edge_invalid", f"{label}.{edge_id} must be object")
  267. continue
  268. status = edge.get("status")
  269. if status not in PROFILE_EDGE_STATUSES:
  270. _fail(
  271. findings,
  272. "platform_profile_edge_status_unknown",
  273. f"{label}.{edge_id} status must be supported/blocked/no_interface/planned, got {status}",
  274. )
  275. def _check_m2_platform_profile(
  276. findings: list[dict[str, str]],
  277. profile: dict[str, Any],
  278. label: str,
  279. ) -> None:
  280. platform = str(profile.get("platform") or "")
  281. endpoints = profile.get("endpoints")
  282. if not isinstance(endpoints, dict):
  283. _fail(findings, "platform_profile_endpoints_invalid", f"{label} endpoints must be an object")
  284. else:
  285. for endpoint_id, endpoint in endpoints.items():
  286. if not isinstance(endpoint, dict):
  287. _fail(
  288. findings,
  289. "platform_profile_endpoint_invalid",
  290. f"{label}.{endpoint_id} must be object",
  291. )
  292. continue
  293. status = endpoint.get("status")
  294. if status is not None and status not in PROFILE_ENDPOINT_STATUSES:
  295. _fail(
  296. findings,
  297. "platform_profile_endpoint_status_unknown",
  298. f"{label}.{endpoint_id} endpoint status must be stable enum, got {status}",
  299. )
  300. _check_observable_contract(findings, profile, label)
  301. _check_m2_platform_specifics(findings, profile, platform, label)
  302. def _check_observable_contract(
  303. findings: list[dict[str, str]],
  304. profile: dict[str, Any],
  305. label: str,
  306. ) -> None:
  307. observable_fields = profile.get("observable_fields")
  308. missing_fields = profile.get("missing_observable_fields")
  309. if not isinstance(observable_fields, list) or not observable_fields:
  310. _fail(findings, "observable_fields_invalid", f"{label} observable_fields must be non-empty list")
  311. else:
  312. for item in observable_fields:
  313. if not isinstance(item, dict):
  314. _fail(findings, "observable_field_invalid", f"{label} observable field must be object")
  315. continue
  316. field = item.get("field")
  317. if field not in OBSERVABLE_FIELDS:
  318. _fail(
  319. findings,
  320. "observable_field_unknown",
  321. f"{label} observable field unknown: {field}",
  322. )
  323. if item.get("availability") != "supported":
  324. _fail(
  325. findings,
  326. "observable_field_availability_invalid",
  327. f"{label} {field} availability must be supported",
  328. )
  329. if not isinstance(missing_fields, list):
  330. _fail(
  331. findings,
  332. "missing_observable_fields_invalid",
  333. f"{label} missing_observable_fields must be a list",
  334. )
  335. return
  336. seen_observable = {
  337. item.get("field")
  338. for item in observable_fields
  339. if isinstance(item, dict)
  340. } if isinstance(observable_fields, list) else set()
  341. seen_missing = set()
  342. for item in missing_fields:
  343. if not isinstance(item, dict):
  344. _fail(findings, "missing_observable_field_invalid", f"{label} missing field must be object")
  345. continue
  346. field = item.get("field")
  347. seen_missing.add(field)
  348. if field not in OBSERVABLE_FIELDS:
  349. _fail(findings, "missing_observable_field_unknown", f"{label} missing field unknown: {field}")
  350. missing_type = item.get("missing_type")
  351. if missing_type not in MISSING_OBSERVABLE_TYPES:
  352. _fail(
  353. findings,
  354. "missing_observable_type_unknown",
  355. f"{label} {field} missing_type must be natural_platform_missing/runtime_missing",
  356. )
  357. overlap = sorted(seen_observable & seen_missing)
  358. if overlap:
  359. _fail(
  360. findings,
  361. "observable_field_conflict",
  362. f"{label} fields cannot be both observable and missing: {overlap}",
  363. )
  364. uncovered = sorted(OBSERVABLE_FIELDS - seen_observable - seen_missing)
  365. if uncovered:
  366. _fail(
  367. findings,
  368. "observable_field_uncovered",
  369. f"{label} observable contract missing fields: {uncovered}",
  370. )
  371. def _check_m2_platform_specifics(
  372. findings: list[dict[str, str]],
  373. profile: dict[str, Any],
  374. platform: str,
  375. label: str,
  376. ) -> None:
  377. edges = profile.get("edges") if isinstance(profile.get("edges"), dict) else {}
  378. endpoints = profile.get("endpoints") if isinstance(profile.get("endpoints"), dict) else {}
  379. if platform == "kuaishou":
  380. _check_nested_status(findings, label, edges, "author_to_works", "supported")
  381. _check_nested_status(findings, label, edges, "author_work_to_content", "supported")
  382. if platform == "shipinhao":
  383. _check_nested_status(findings, label, endpoints, "account_info", "blocked")
  384. _check_nested_status(findings, label, edges, "author_to_works", "blocked")
  385. _check_nested_status(findings, label, edges, "author_work_to_content", "blocked")
  386. def _check_v4_walk_gate_config(
  387. findings: list[dict[str, str]],
  388. gate: Any,
  389. label: str,
  390. ) -> None:
  391. if not isinstance(gate, dict):
  392. _fail(findings, "v4_walk_gate_invalid", f"{label} must be an object")
  393. return
  394. if gate.get("requires_allow_walk") is not True:
  395. _fail(findings, "v4_walk_gate_requires_allow_walk", f"{label}.requires_allow_walk must be true")
  396. if gate.get("source_field") != "rule_decisions.jsonl[].decision_replay_data.allow_walk":
  397. _fail(findings, "v4_walk_gate_source_field", f"{label}.source_field is invalid")
  398. if gate.get("deny_reason_code") != "v4_allow_walk_denied":
  399. _fail(findings, "v4_walk_gate_deny_reason", f"{label}.deny_reason_code is invalid")
  400. if set(gate.get("applies_to_edges") or []) != M4_WALK_GATE_EDGES:
  401. _fail(findings, "v4_walk_gate_edges", f"{label}.applies_to_edges must cover M4 expansion edges")
  402. raw_fields = set(gate.get("raw_payload_fields") or [])
  403. if not M4_WALK_GATE_RAW_FIELDS <= raw_fields:
  404. _fail(findings, "v4_walk_gate_raw_fields", f"{label}.raw_payload_fields missing required fields")
  405. def _check_v4_walk_strategy_contract(
  406. findings: list[dict[str, str]],
  407. strategy: dict[str, Any],
  408. edge_ids: set[str],
  409. ) -> None:
  410. for binding in strategy.get("walk_rule_pack_binding") or []:
  411. if isinstance(binding, dict) and edge_ids and binding.get("edge_id") not in edge_ids:
  412. _fail(
  413. findings,
  414. "v4_walk_strategy_edge_unknown",
  415. f"walk_rule_pack_binding unknown edge_id: {binding.get('edge_id')}",
  416. )
  417. rows = strategy.get("v4_walk_gate")
  418. if not isinstance(rows, list) or not rows:
  419. _fail(findings, "v4_walk_strategy_gate_missing", "douyin_walk_strategy.v1.json missing v4_walk_gate")
  420. return
  421. by_id = {row.get("gate_id"): row for row in rows if isinstance(row, dict)}
  422. gate = by_id.get("allow_walk_required")
  423. if not gate:
  424. _fail(findings, "v4_walk_strategy_gate_missing", "allow_walk_required gate missing")
  425. return
  426. _check_v4_walk_gate_config(findings, gate, "douyin_walk_strategy.v4_walk_gate.allow_walk_required")
  427. for edge_id in gate.get("applies_to_edges") or []:
  428. if edge_ids and edge_id not in edge_ids:
  429. _fail(findings, "v4_walk_strategy_gate_edge_unknown", f"v4_walk_gate unknown edge_id: {edge_id}")
  430. def _check_nested_status(
  431. findings: list[dict[str, str]],
  432. label: str,
  433. section: dict[str, Any],
  434. key: str,
  435. expected: str,
  436. ) -> None:
  437. value = section.get(key)
  438. actual = value.get("status") if isinstance(value, dict) else None
  439. if actual != expected:
  440. _fail(
  441. findings,
  442. "platform_profile_status_mismatch",
  443. f"{label}.{key} expected status {expected}, got {actual}",
  444. )
  445. def _check_v4_rule_pack_contract(
  446. findings: list[dict[str, str]],
  447. pkg: dict[str, Any],
  448. ) -> None:
  449. strategy_version = (pkg.get("strategy_binding") or {}).get("strategy_version")
  450. for dispatch in pkg.get("rule_pack_dispatch", []):
  451. if dispatch.get("dispatch_enabled") and dispatch.get("strategy_version") == "V4":
  452. if dispatch.get("rule_pack_version") != "4.0.0":
  453. _fail(
  454. findings,
  455. "v4_rule_pack_version_invalid",
  456. f"{dispatch.get('dispatch_id')} V4 dispatch must use rule_pack_version 4.0.0",
  457. )
  458. for pack in pkg.get("rule_packs", []):
  459. scorecard = pack.get("scorecard") or {}
  460. is_v4 = (
  461. strategy_version == "V4"
  462. or pack.get("version") == "4.0.0"
  463. or scorecard.get("schema_version") == "v4_scorecard.v1"
  464. )
  465. if not is_v4:
  466. continue
  467. _check_no_legacy_fields(findings, pack, f"rule_pack:{pack.get('rule_pack_id')}")
  468. if scorecard.get("schema_version") != "v4_scorecard.v1":
  469. _fail(
  470. findings,
  471. "v4_scorecard_schema_invalid",
  472. f"{pack.get('rule_pack_id')} scorecard.schema_version must be v4_scorecard.v1",
  473. )
  474. _check_v4_scorecard_dimensions(findings, pack.get("rule_pack_id"), scorecard)
  475. _check_v4_score_weight_profiles(findings, pack.get("rule_pack_id"), scorecard)
  476. _check_v4_scorecard_rule_refs(findings, pack.get("rule_pack_id"), scorecard)
  477. required_fields = set((pack.get("input_contract") or {}).get("required_fields") or [])
  478. for field in [
  479. "pattern_match_result.query_relevance_score",
  480. "content_engagement_metrics.platform_performance.platform_performance_score",
  481. "content_engagement_metrics.platform_performance.missing_observable_fields",
  482. ]:
  483. if field not in required_fields:
  484. _fail(
  485. findings,
  486. "v4_rule_pack_required_field_missing",
  487. f"{pack.get('rule_pack_id')} missing required field {field}",
  488. )
  489. def _check_v4_scorecard_dimensions(
  490. findings: list[dict[str, str]],
  491. rule_pack_id: Any,
  492. scorecard: dict[str, Any],
  493. ) -> None:
  494. dimensions = scorecard.get("dimensions")
  495. if not isinstance(dimensions, list):
  496. _fail(findings, "v4_scorecard_dimensions_invalid", f"{rule_pack_id} dimensions must be a list")
  497. return
  498. keys: list[Any] = []
  499. for row in dimensions:
  500. if not isinstance(row, dict):
  501. _fail(findings, "v4_scorecard_dimension_invalid", f"{rule_pack_id} dimension row must be object")
  502. continue
  503. key = row.get("key")
  504. if not key:
  505. _fail(findings, "v4_scorecard_dimension_key_invalid", f"{rule_pack_id} dimension key must be non-empty")
  506. keys.append(key)
  507. if row.get("runtime_status") != "active":
  508. continue
  509. active = row.get("active")
  510. if active is not None and active is not True:
  511. _fail(
  512. findings,
  513. "v4_scorecard_dimension_active_invalid",
  514. f"{rule_pack_id}/{key} active must match runtime_status=active",
  515. )
  516. platform_scope = row.get("platform_scope")
  517. if platform_scope is not None and (
  518. not isinstance(platform_scope, list)
  519. or not platform_scope
  520. or any(not isinstance(item, str) or not item for item in platform_scope)
  521. ):
  522. _fail(
  523. findings,
  524. "v4_scorecard_dimension_platform_scope_invalid",
  525. f"{rule_pack_id}/{key} platform_scope must be a non-empty string list",
  526. )
  527. score_source_path = row.get("score_source_path")
  528. if score_source_path is not None and (not isinstance(score_source_path, str) or not score_source_path):
  529. _fail(
  530. findings,
  531. "v4_scorecard_dimension_score_source_path_invalid",
  532. f"{rule_pack_id}/{key} score_source_path must be non-empty string",
  533. )
  534. if not _positive_number(row.get("max_score")):
  535. _fail(findings, "v4_scorecard_dimension_score_invalid", f"{rule_pack_id}/{key} max_score must be positive")
  536. if not _positive_number(row.get("weight_percent")):
  537. _fail(findings, "v4_scorecard_dimension_weight_invalid", f"{rule_pack_id}/{key} weight_percent must be positive")
  538. duplicates = sorted({key for key in keys if key and keys.count(key) > 1})
  539. if duplicates:
  540. _fail(findings, "v4_scorecard_dimension_duplicate", f"{rule_pack_id} duplicate dimensions: {duplicates}")
  541. active_keys = {row.get("key") for row in dimensions if isinstance(row, dict) and row.get("runtime_status") == "active"}
  542. missing = sorted(V4_BASE_ACTIVE_DIMENSIONS - active_keys)
  543. if missing:
  544. _fail(findings, "v4_scorecard_dimensions_invalid", f"{rule_pack_id} active dimensions missing base keys: {missing}")
  545. def _check_v4_score_weight_profiles(
  546. findings: list[dict[str, str]],
  547. rule_pack_id: Any,
  548. scorecard: dict[str, Any],
  549. ) -> None:
  550. profiles = scorecard.get("score_weight_profiles")
  551. if not isinstance(profiles, dict):
  552. _fail(findings, "v4_score_weight_profiles_invalid", f"{rule_pack_id} score_weight_profiles must be an object")
  553. return
  554. dimension_keys = {
  555. row.get("key")
  556. for row in scorecard.get("dimensions", [])
  557. if isinstance(row, dict) and row.get("key")
  558. }
  559. if isinstance(profiles.get("profiles"), list):
  560. _check_v4_generic_score_weight_profiles(findings, rule_pack_id, profiles["profiles"], dimension_keys)
  561. return
  562. missing = sorted(V4_REQUIRED_SCORE_WEIGHT_PROFILES - set(profiles))
  563. if missing:
  564. _fail(findings, "v4_score_weight_profiles_invalid", f"{rule_pack_id} missing score weight profiles: {missing}")
  565. for profile_name, weights in profiles.items():
  566. if not isinstance(weights, dict) or not weights:
  567. _fail(findings, "v4_score_weight_profile_invalid", f"{rule_pack_id}/{profile_name} must be a non-empty object")
  568. continue
  569. for key, weight in weights.items():
  570. if key not in dimension_keys:
  571. _fail(
  572. findings,
  573. "v4_score_weight_profile_dimension_unknown",
  574. f"{rule_pack_id}/{profile_name} unknown dimension: {key}",
  575. )
  576. if not _positive_number(weight):
  577. _fail(
  578. findings,
  579. "v4_score_weight_profile_weight_invalid",
  580. f"{rule_pack_id}/{profile_name}/{key} weight must be positive",
  581. )
  582. def _check_v4_generic_score_weight_profiles(
  583. findings: list[dict[str, str]],
  584. rule_pack_id: Any,
  585. profiles: list[Any],
  586. dimension_keys: set[Any],
  587. ) -> None:
  588. aliases: set[str] = set()
  589. priority_groups: dict[tuple[Any, Any, Any, int], list[str]] = {}
  590. for profile in profiles:
  591. if not isinstance(profile, dict):
  592. _fail(findings, "v4_score_weight_profile_invalid", f"{rule_pack_id} profile row must be object")
  593. continue
  594. profile_id = profile.get("profile_id")
  595. if not profile_id:
  596. _fail(findings, "v4_score_weight_profile_invalid", f"{rule_pack_id} profile_id must be non-empty")
  597. continue
  598. aliases.update(profile.get("historical_alias_ids") or [])
  599. if profile.get("approval_status") != "approved":
  600. _fail(findings, "v4_score_weight_profile_approval_invalid", f"{rule_pack_id}/{profile_id} must be approved")
  601. if profile.get("runtime_enabled") is not True:
  602. _fail(findings, "v4_score_weight_profile_runtime_invalid", f"{rule_pack_id}/{profile_id} runtime_enabled must be true")
  603. weights = profile.get("weights")
  604. if not isinstance(weights, dict) or not weights:
  605. _fail(findings, "v4_score_weight_profile_invalid", f"{rule_pack_id}/{profile_id} weights must be non-empty object")
  606. continue
  607. total = 0.0
  608. for key, weight in weights.items():
  609. if key not in dimension_keys:
  610. _fail(
  611. findings,
  612. "v4_score_weight_profile_dimension_unknown",
  613. f"{rule_pack_id}/{profile_id} unknown dimension: {key}",
  614. )
  615. if not _positive_number(weight):
  616. _fail(
  617. findings,
  618. "v4_score_weight_profile_weight_invalid",
  619. f"{rule_pack_id}/{profile_id}/{key} weight must be positive",
  620. )
  621. else:
  622. total += float(weight)
  623. normalization = profile.get("normalization_policy") or "sum_100"
  624. if normalization == "raw_sum":
  625. if not profile.get("normalization_reason"):
  626. _fail(
  627. findings,
  628. "v4_score_weight_profile_normalization_invalid",
  629. f"{rule_pack_id}/{profile_id} raw_sum requires normalization_reason",
  630. )
  631. elif round(total, 6) != 100:
  632. _fail(
  633. findings,
  634. "v4_score_weight_profile_total_invalid",
  635. f"{rule_pack_id}/{profile_id} weights must sum to 100",
  636. )
  637. group = (
  638. tuple(profile.get("platform_scope") or []),
  639. profile.get("content_format") or "*",
  640. profile.get("applies_when") or "always",
  641. int(profile.get("priority") or 0),
  642. )
  643. priority_groups.setdefault(group, []).append(str(profile_id))
  644. missing = sorted(V4_REQUIRED_SCORE_WEIGHT_PROFILES - aliases)
  645. if missing:
  646. _fail(findings, "v4_score_weight_profiles_invalid", f"{rule_pack_id} missing historical aliases: {missing}")
  647. for group, ids in priority_groups.items():
  648. if len(ids) > 1:
  649. _fail(findings, "v4_score_weight_profile_priority_invalid", f"{rule_pack_id} priority conflict {group}: {ids}")
  650. def _check_v4_scorecard_rule_refs(
  651. findings: list[dict[str, str]],
  652. rule_pack_id: Any,
  653. scorecard: dict[str, Any],
  654. ) -> None:
  655. dimension_keys = {
  656. row.get("key")
  657. for row in scorecard.get("dimensions", [])
  658. if isinstance(row, dict) and row.get("key")
  659. }
  660. for rule in scorecard.get("scoring_rules", []):
  661. if not isinstance(rule, dict):
  662. _fail(findings, "v4_scoring_rule_invalid", f"{rule_pack_id} scoring rule must be object")
  663. continue
  664. if rule.get("dimension_key") not in dimension_keys:
  665. _fail(
  666. findings,
  667. "v4_scoring_rule_dimension_unknown",
  668. f"{rule_pack_id}/{rule.get('scoring_rule_id')} unknown dimension_key: {rule.get('dimension_key')}",
  669. )
  670. def _positive_number(value: Any) -> bool:
  671. return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
  672. def _fail(findings: list[dict[str, str]], check_id: str, message: str) -> None:
  673. findings.append({"level": "fail", "check_id": check_id, "message": message})
  674. if __name__ == "__main__":
  675. sys.exit(main())