validate_rule_pack_config.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Validate referential integrity inside douyin_rule_packs.v1.json (V2-M1D).
  2. Mirrors the walk-side checks in walk_strategy_json (which already cover walk FKs).
  3. Here we close the rule-pack side: every decision_action / decision_reason_code /
  4. scoring_rule dimension_key / dispatch rule_pack_id must resolve to an
  5. authoritative catalog entry within the same file.
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import sys
  11. from pathlib import Path
  12. from typing import Any
  13. ROOT = Path(__file__).resolve().parents[1]
  14. RULE_PACK_PATH = Path("product_documents/规则包/douyin_rule_packs.v1.json")
  15. LEGACY_FIELD_BLOCKLIST = {
  16. "fit_senior_50plus",
  17. "fit_confidence",
  18. "relevance_score",
  19. "platform_heat",
  20. "age_50_plus_level",
  21. }
  22. V4_BASE_ACTIVE_DIMENSIONS = {"query_relevance", "platform_performance"}
  23. V4_REQUIRED_SCORE_WEIGHT_PROFILES = {
  24. "default_two_dimension",
  25. "douyin_fifty_plus_ok",
  26. "douyin_fifty_plus_not_attempted",
  27. }
  28. V4_SCORE_WEIGHT_ALIAS_TARGETS = {
  29. "base_video_two_dimension_v1": "default_two_dimension",
  30. "audience_50plus_available_v1": "douyin_fifty_plus_ok",
  31. "audience_50plus_skipped_by_query_gate_v1": "douyin_fifty_plus_not_attempted",
  32. }
  33. def _fail(findings: list[dict[str, Any]], check_id: str, message: str) -> None:
  34. findings.append({"level": "fail", "check_id": check_id, "message": message})
  35. def _warn(findings: list[dict[str, Any]], check_id: str, message: str) -> None:
  36. findings.append({"level": "warn", "check_id": check_id, "message": message})
  37. def validate_rule_pack_config(pkg: dict[str, Any]) -> list[dict[str, Any]]:
  38. findings: list[dict[str, Any]] = []
  39. rule_packs = pkg.get("rule_packs", [])
  40. rule_pack_ids = {p.get("rule_pack_id") for p in rule_packs}
  41. actions: set[str] = set()
  42. for entry in pkg.get("decision_action_catalog", []):
  43. actions.update(entry.get("allowed_actions", []))
  44. reason_codes = {r.get("decision_reason_code") for r in pkg.get("decision_reason_codes", [])}
  45. enabled_by_group: dict[tuple[Any, ...], list[str]] = {}
  46. for dispatch in pkg.get("rule_pack_dispatch", []):
  47. if dispatch.get("rule_pack_id") not in rule_pack_ids:
  48. _fail(findings, "dispatch_rule_pack_id",
  49. f"{dispatch.get('dispatch_id')} references unknown rule_pack_id: {dispatch.get('rule_pack_id')}")
  50. if dispatch.get("dispatch_enabled"):
  51. group = (dispatch.get("platform"), dispatch.get("strategy_version"), dispatch.get("runtime_stage"),
  52. dispatch.get("target_entity"), dispatch.get("content_format"))
  53. enabled_by_group.setdefault(group, []).append(dispatch.get("rule_pack_id"))
  54. for group, pack_ids in enabled_by_group.items():
  55. if len(pack_ids) > 1:
  56. _fail(findings, "dispatch_conflict",
  57. f"CONFIG_RULE_PACK_DISPATCH_CONFLICT: multiple enabled dispatches for group {group}: {pack_ids}")
  58. for pack in rule_packs:
  59. pid = pack.get("rule_pack_id")
  60. dimension_keys = {d.get("key") for d in pack.get("scorecard", {}).get("dimensions", [])}
  61. for gate in pack.get("hard_gates", []):
  62. if gate.get("decision_action") not in actions:
  63. _fail(findings, "hard_gate_action", f"{pid}/{gate.get('gate_id')} unknown decision_action: {gate.get('decision_action')}")
  64. if gate.get("decision_reason_code") not in reason_codes:
  65. # decision_reason_codes is a curated subset, not an exhaustive enum -> warn only.
  66. _warn(findings, "hard_gate_reason", f"{pid}/{gate.get('gate_id')} reason_code not in catalog: {gate.get('decision_reason_code')}")
  67. for rule in pack.get("scorecard", {}).get("scoring_rules", []):
  68. if rule.get("dimension_key") not in dimension_keys:
  69. _fail(findings, "scoring_rule_dimension", f"{pid}/{rule.get('scoring_rule_id')} unknown dimension_key: {rule.get('dimension_key')}")
  70. for i, threshold in enumerate(pack.get("thresholds", [])):
  71. if threshold.get("decision_action") not in actions:
  72. _fail(findings, "threshold_action", f"{pid}/threshold[{i}] unknown decision_action: {threshold.get('decision_action')}")
  73. if threshold.get("decision_reason_code") not in reason_codes:
  74. _warn(findings, "threshold_reason", f"{pid}/threshold[{i}] reason_code not in catalog: {threshold.get('decision_reason_code')}")
  75. if _is_v4_pack(pack, pkg):
  76. _check_v4_rule_pack(findings, pack)
  77. return findings
  78. def _is_v4_pack(pack: dict[str, Any], pkg: dict[str, Any]) -> bool:
  79. return (
  80. (pkg.get("strategy_binding") or {}).get("strategy_version") == "V4"
  81. or (pack.get("scorecard") or {}).get("schema_version") == "v4_scorecard.v1"
  82. or pack.get("version") == "4.0.0"
  83. )
  84. def _check_v4_rule_pack(findings: list[dict[str, Any]], pack: dict[str, Any]) -> None:
  85. pid = pack.get("rule_pack_id")
  86. legacy_paths = _legacy_paths(pack, f"rule_pack:{pid}")
  87. if legacy_paths:
  88. _fail(findings, "v4_rule_pack_legacy_field", f"{pid} contains legacy fields: {legacy_paths[:5]}")
  89. scorecard = pack.get("scorecard") or {}
  90. if scorecard.get("schema_version") != "v4_scorecard.v1":
  91. _fail(findings, "v4_scorecard_schema", f"{pid} scorecard.schema_version must be v4_scorecard.v1")
  92. _check_scorecard_dimensions(findings, pid, scorecard)
  93. _check_score_weight_profiles(findings, pid, scorecard)
  94. thresholds = pack.get("thresholds", [])
  95. expected_reasons = {
  96. "v4_query_and_platform_pass",
  97. "v4_score_review_needed",
  98. "v4_query_or_score_below_threshold",
  99. }
  100. if {row.get("decision_reason_code") for row in thresholds} != expected_reasons:
  101. _fail(findings, "v4_threshold_reasons", f"{pid} thresholds must use V4 reason codes")
  102. pool = [row for row in thresholds if row.get("decision_action") == "ADD_TO_CONTENT_POOL"]
  103. review = [row for row in thresholds if row.get("decision_action") == "KEEP_CONTENT_FOR_REVIEW"]
  104. reject = [row for row in thresholds if row.get("decision_action") == "REJECT_CONTENT"]
  105. if not pool or pool[0].get("min_score") != 70:
  106. _fail(findings, "v4_pool_threshold", f"{pid} pool threshold min_score must be 70")
  107. if not review or review[0].get("min_score") != 55:
  108. _fail(findings, "v4_review_threshold", f"{pid} review threshold min_score must be 55")
  109. if not reject or reject[0].get("max_score", 0) > 55:
  110. _fail(findings, "v4_reject_threshold", f"{pid} reject threshold must stay below 55")
  111. def _legacy_paths(value: Any, prefix: str) -> list[str]:
  112. paths: list[str] = []
  113. if isinstance(value, dict):
  114. for key, child in value.items():
  115. child_path = f"{prefix}.{key}"
  116. if key in LEGACY_FIELD_BLOCKLIST:
  117. paths.append(child_path)
  118. paths.extend(_legacy_paths(child, child_path))
  119. elif isinstance(value, list):
  120. for index, child in enumerate(value):
  121. paths.extend(_legacy_paths(child, f"{prefix}[{index}]"))
  122. elif isinstance(value, str):
  123. if _string_has_legacy_field(value):
  124. paths.append(prefix)
  125. return paths
  126. def _check_scorecard_dimensions(
  127. findings: list[dict[str, Any]],
  128. pid: Any,
  129. scorecard: dict[str, Any],
  130. ) -> None:
  131. dimensions = scorecard.get("dimensions")
  132. if not isinstance(dimensions, list):
  133. _fail(findings, "v4_scorecard_dimensions", f"{pid} scorecard.dimensions must be a list")
  134. return
  135. keys: list[Any] = []
  136. for row in dimensions:
  137. if not isinstance(row, dict):
  138. _fail(findings, "v4_scorecard_dimension", f"{pid} dimension row must be object")
  139. continue
  140. key = row.get("key")
  141. if not key:
  142. _fail(findings, "v4_scorecard_dimension_key", f"{pid} dimension key must be non-empty")
  143. keys.append(key)
  144. if row.get("runtime_status") != "active":
  145. continue
  146. active = row.get("active")
  147. if active is not None and active is not True:
  148. _fail(findings, "v4_scorecard_dimension_active", f"{pid}/{key} active must match runtime_status=active")
  149. platform_scope = row.get("platform_scope")
  150. if platform_scope is not None and (
  151. not isinstance(platform_scope, list)
  152. or not platform_scope
  153. or any(not isinstance(item, str) or not item for item in platform_scope)
  154. ):
  155. _fail(findings, "v4_scorecard_dimension_platform_scope", f"{pid}/{key} platform_scope must be a non-empty string list")
  156. score_source_path = row.get("score_source_path")
  157. if score_source_path is not None and (not isinstance(score_source_path, str) or not score_source_path):
  158. _fail(findings, "v4_scorecard_dimension_score_source_path", f"{pid}/{key} score_source_path must be non-empty string")
  159. if not _positive_number(row.get("max_score")):
  160. _fail(findings, "v4_scorecard_dimension_score", f"{pid}/{key} max_score must be positive")
  161. if not _positive_number(row.get("weight_percent")):
  162. _fail(findings, "v4_scorecard_dimension_weight", f"{pid}/{key} weight_percent must be positive")
  163. duplicate_keys = sorted({key for key in keys if key and keys.count(key) > 1})
  164. if duplicate_keys:
  165. _fail(findings, "v4_scorecard_dimension_duplicate", f"{pid} duplicate dimensions: {duplicate_keys}")
  166. active_keys = {row.get("key") for row in dimensions if isinstance(row, dict) and row.get("runtime_status") == "active"}
  167. missing = sorted(V4_BASE_ACTIVE_DIMENSIONS - active_keys)
  168. if missing:
  169. _fail(findings, "v4_scorecard_dimensions", f"{pid} active dimensions missing base keys: {missing}")
  170. def _check_score_weight_profiles(
  171. findings: list[dict[str, Any]],
  172. pid: Any,
  173. scorecard: dict[str, Any],
  174. ) -> None:
  175. profiles = scorecard.get("score_weight_profiles")
  176. if not isinstance(profiles, dict):
  177. _fail(findings, "v4_score_weight_profiles", f"{pid} score_weight_profiles must be an object")
  178. return
  179. dimension_keys = {
  180. row.get("key")
  181. for row in scorecard.get("dimensions", [])
  182. if isinstance(row, dict) and row.get("key")
  183. }
  184. if isinstance(profiles.get("profiles"), list):
  185. _check_generic_score_weight_profiles(findings, pid, profiles["profiles"], dimension_keys)
  186. return
  187. missing_profiles = sorted(V4_REQUIRED_SCORE_WEIGHT_PROFILES - set(profiles))
  188. if missing_profiles:
  189. _fail(findings, "v4_score_weight_profiles", f"{pid} missing score weight profiles: {missing_profiles}")
  190. for profile_name, weights in profiles.items():
  191. if not isinstance(weights, dict) or not weights:
  192. _fail(findings, "v4_score_weight_profile", f"{pid}/{profile_name} must be a non-empty object")
  193. continue
  194. for key, weight in weights.items():
  195. if key not in dimension_keys:
  196. _fail(findings, "v4_score_weight_profile_dimension", f"{pid}/{profile_name} unknown dimension: {key}")
  197. if not _positive_number(weight):
  198. _fail(findings, "v4_score_weight_profile_weight", f"{pid}/{profile_name}/{key} weight must be positive")
  199. def _check_generic_score_weight_profiles(
  200. findings: list[dict[str, Any]],
  201. pid: Any,
  202. profiles: list[Any],
  203. dimension_keys: set[Any],
  204. ) -> None:
  205. aliases: set[str] = set()
  206. priority_groups: dict[tuple[Any, Any, Any, int], list[str]] = {}
  207. for profile in profiles:
  208. if not isinstance(profile, dict):
  209. _fail(findings, "v4_score_weight_profile", f"{pid} profile row must be object")
  210. continue
  211. profile_id = profile.get("profile_id")
  212. if not profile_id:
  213. _fail(findings, "v4_score_weight_profile", f"{pid} profile_id must be non-empty")
  214. continue
  215. aliases.update(profile.get("historical_alias_ids") or [])
  216. if profile.get("approval_status") != "approved":
  217. _fail(findings, "v4_score_weight_profile_approval", f"{pid}/{profile_id} runtime profile must be approved")
  218. if profile.get("runtime_enabled") is not True:
  219. _fail(findings, "v4_score_weight_profile_runtime", f"{pid}/{profile_id} runtime_enabled must be true")
  220. weights = profile.get("weights")
  221. if not isinstance(weights, dict) or not weights:
  222. _fail(findings, "v4_score_weight_profile", f"{pid}/{profile_id} weights must be non-empty object")
  223. continue
  224. total = 0.0
  225. for key, weight in weights.items():
  226. if key not in dimension_keys:
  227. _fail(findings, "v4_score_weight_profile_dimension", f"{pid}/{profile_id} unknown dimension: {key}")
  228. if not _positive_number(weight):
  229. _fail(findings, "v4_score_weight_profile_weight", f"{pid}/{profile_id}/{key} weight must be positive")
  230. else:
  231. total += float(weight)
  232. normalization = profile.get("normalization_policy") or "sum_100"
  233. if normalization == "raw_sum":
  234. if not profile.get("normalization_reason"):
  235. _fail(findings, "v4_score_weight_profile_normalization", f"{pid}/{profile_id} raw_sum requires normalization_reason")
  236. elif round(total, 6) != 100:
  237. _fail(findings, "v4_score_weight_profile_total", f"{pid}/{profile_id} weights must sum to 100")
  238. group = (
  239. tuple(profile.get("platform_scope") or []),
  240. profile.get("content_format") or "*",
  241. profile.get("applies_when") or "always",
  242. int(profile.get("priority") or 0),
  243. )
  244. priority_groups.setdefault(group, []).append(str(profile_id))
  245. missing = sorted(V4_REQUIRED_SCORE_WEIGHT_PROFILES - aliases)
  246. if missing:
  247. _fail(findings, "v4_score_weight_profiles", f"{pid} missing historical aliases: {missing}")
  248. for group, ids in priority_groups.items():
  249. if len(ids) > 1:
  250. _fail(findings, "v4_score_weight_profile_priority", f"{pid} priority conflict {group}: {ids}")
  251. def _positive_number(value: Any) -> bool:
  252. return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
  253. def _string_has_legacy_field(value: str) -> bool:
  254. normalized = value.replace("[", ".").replace("]", ".").replace("/", ".")
  255. parts = [part.strip() for part in normalized.split(".")]
  256. return any(part in LEGACY_FIELD_BLOCKLIST for part in parts)
  257. def main() -> int:
  258. args = _parse_args()
  259. path = args.config_path if args.config_path.is_absolute() else ROOT / args.config_path
  260. pkg = json.loads(path.read_text(encoding="utf-8"))
  261. findings = validate_rule_pack_config(pkg)
  262. result = {
  263. "status": "fail" if any(f["level"] == "fail" for f in findings) else "pass",
  264. "config_path": str(path.relative_to(ROOT)),
  265. "findings": findings,
  266. }
  267. print(json.dumps(result, ensure_ascii=False, indent=2))
  268. return 1 if result["status"] == "fail" else 0
  269. def _parse_args() -> argparse.Namespace:
  270. parser = argparse.ArgumentParser(description=__doc__)
  271. parser.add_argument("config_path", nargs="?", type=Path, default=RULE_PACK_PATH)
  272. return parser.parse_args()
  273. if __name__ == "__main__":
  274. sys.exit(main())