validation.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392
  1. from __future__ import annotations
  2. import json
  3. from collections import Counter
  4. from typing import Any
  5. from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION, RUNTIME_SCHEMA_VERSION
  6. from content_agent.findings import fail as _fail
  7. from content_agent.integrations.runtime_files import RUNTIME_FILENAMES
  8. from content_agent.interfaces import RuntimeFileStore
  9. JSON_FILES = {
  10. "source_context.json",
  11. "pattern_seed_pack.json",
  12. "final_output.json",
  13. "strategy_review.json",
  14. }
  15. JSONL_FILES = set(RUNTIME_FILENAMES) - JSON_FILES
  16. POLICY_RUN_FILES = {
  17. "pattern_seed_pack.json",
  18. "search_queries.jsonl",
  19. "discovered_content_items.jsonl",
  20. "content_media_records.jsonl",
  21. "pattern_recall_evidence.jsonl",
  22. "rule_decisions.jsonl",
  23. "walk_actions.jsonl",
  24. "run_events.jsonl",
  25. "source_path_records.jsonl",
  26. "search_clues.jsonl",
  27. "final_output.json",
  28. "strategy_review.json",
  29. }
  30. RAW_PAYLOAD_FILES = {
  31. "search_queries.jsonl",
  32. "discovered_content_items.jsonl",
  33. "content_media_records.jsonl",
  34. "pattern_recall_evidence.jsonl",
  35. "rule_decisions.jsonl",
  36. "walk_actions.jsonl",
  37. "run_events.jsonl",
  38. "source_path_records.jsonl",
  39. "search_clues.jsonl",
  40. "strategy_review.json",
  41. }
  42. FORBIDDEN_PAYLOAD_KEYS = {
  43. "password",
  44. "token",
  45. "access_token",
  46. "refresh_token",
  47. "api_key",
  48. "apikey",
  49. "secret",
  50. "dsn",
  51. "authorization",
  52. "cookie",
  53. "session",
  54. "credential",
  55. }
  56. DECISION_ACTIONS = {
  57. "ADD_TO_CONTENT_POOL",
  58. "KEEP_CONTENT_FOR_REVIEW",
  59. "REJECT_CONTENT",
  60. "TECHNICAL_RETRY_REQUIRED",
  61. }
  62. EFFECT_STATUSES = {"success", "pending", "failed", "rule_blocked"}
  63. WALK_STATUSES = {"success", "pending", "failed", "skipped", "rule_blocked"}
  64. V4_SCORECARD_SCHEMA_VERSION = "v4_scorecard.v1"
  65. V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION = "v4_gemini_query_relevance.v1"
  66. V4_SCORE_THRESHOLD_FALLBACK = {
  67. "pool_total": 70,
  68. "pool_query": 70,
  69. "review_total": 55,
  70. "review_query": 55,
  71. "walk_query": 70,
  72. "walk_platform": 65,
  73. "walk_total": 70,
  74. }
  75. V4_LEGACY_FIELD_BLOCKLIST = {
  76. "fit_senior_50plus",
  77. "fit_confidence",
  78. "relevance_score",
  79. "platform_heat",
  80. "age_50_plus_level",
  81. }
  82. DECISION_REPLAY_REQUIRED_FIELDS = {
  83. "policy_bundle_hash",
  84. "rule_pack_id",
  85. "rule_pack_version",
  86. "dispatch_id",
  87. "strategy_version",
  88. }
  89. SOURCE_EVIDENCE_FIELDS = {
  90. "source_kind",
  91. "pattern_source_system",
  92. "case_id_type",
  93. "source_post_id",
  94. "pattern_execution_id",
  95. "mining_config_id",
  96. "itemset_ids",
  97. "itemset_items",
  98. "support",
  99. "absolute_support",
  100. "matched_post_ids",
  101. "video_ids",
  102. "case_ids",
  103. "seed_terms",
  104. "discovery_start_source",
  105. "previous_discovery_step",
  106. "origin_path_id",
  107. "run_id",
  108. "policy_run_id",
  109. "discovered_platform_content_id",
  110. "source_certainty",
  111. "validation_status",
  112. }
  113. def validate_run(run_id: str, runtime: RuntimeFileStore) -> dict[str, Any]:
  114. findings: list[dict[str, Any]] = []
  115. data = _load_files(run_id, runtime, findings)
  116. if any(finding["level"] == "fail" for finding in findings):
  117. return _result(run_id, findings)
  118. _check_run_ids(run_id, data, findings)
  119. _check_schema_versions(data, findings)
  120. _check_policy_run_ids(data, findings)
  121. _check_raw_payloads(data, findings)
  122. _check_unique_ids(data, findings)
  123. _check_references(data, findings)
  124. _check_pattern_recall_evidence(data, findings)
  125. _check_source_evidence(data, findings)
  126. _check_source_paths(data, findings)
  127. _check_summary(data, findings)
  128. _check_completeness(data, findings)
  129. _check_v4_score_contract(data, findings)
  130. _check_v4_walk_gate_contract(data, findings)
  131. _check_v4_walk_action_consumption(data, findings)
  132. _check_v4_final_output_explanation(data, findings)
  133. _check_v4_strategy_review_explanation(data, findings)
  134. _check_v4_action_thresholds(data, findings)
  135. _check_v4_gemini_failure_contract(data, findings)
  136. _check_v4_legacy_field_blocklist(data, findings)
  137. return _result(run_id, findings)
  138. def compute_final_output_completeness(
  139. final_output: dict[str, Any],
  140. decisions: list[dict[str, Any]],
  141. source_path_records: list[dict[str, Any]],
  142. ) -> dict[str, Any]:
  143. findings: list[str] = []
  144. decision_ids = {decision.get("decision_id") for decision in decisions}
  145. path_ids = {path.get("source_path_record_id") for path in source_path_records}
  146. decision_asset_paths = {
  147. (path.get("decision_id"), path.get("to_node_id")): path
  148. for path in source_path_records
  149. if path.get("source_path_type") == "decision_to_asset"
  150. }
  151. for asset in final_output.get("content_assets", []):
  152. decision_id = asset.get("decision_id")
  153. content_id = asset.get("platform_content_id")
  154. asset_path_ids = set(asset.get("source_path_record_ids", []))
  155. if decision_id not in decision_ids:
  156. findings.append(f"content_asset missing decision: {content_id}")
  157. missing_paths = sorted(asset_path_ids - path_ids)
  158. if missing_paths:
  159. findings.append(f"content_asset missing paths: {content_id}")
  160. decision_asset = decision_asset_paths.get((decision_id, content_id))
  161. if not decision_asset:
  162. findings.append(f"content_asset missing decision_to_asset: {content_id}")
  163. elif decision_asset.get("source_path_record_id") not in asset_path_ids:
  164. findings.append(f"content_asset omits decision_to_asset: {content_id}")
  165. for record in final_output.get("review_records", []):
  166. if record.get("decision_id") not in decision_ids:
  167. findings.append(f"review_record missing decision: {record.get('platform_content_id')}")
  168. if set(record.get("source_path_record_ids", [])) - path_ids:
  169. findings.append(f"review_record missing paths: {record.get('platform_content_id')}")
  170. for record in final_output.get("reject_records", []):
  171. if record.get("decision_id") not in decision_ids:
  172. findings.append(f"reject_record missing decision: {record.get('decision_target_id')}")
  173. if set(record.get("source_path_record_ids", [])) - path_ids:
  174. findings.append(f"reject_record missing paths: {record.get('decision_target_id')}")
  175. for record in final_output.get("technical_retry_records", []):
  176. if record.get("decision_id") not in decision_ids:
  177. findings.append(
  178. f"technical_retry_record missing decision: {record.get('decision_target_id')}"
  179. )
  180. if set(record.get("source_path_record_ids", [])) - path_ids:
  181. findings.append(
  182. f"technical_retry_record missing paths: {record.get('decision_target_id')}"
  183. )
  184. final_decision_ids = {
  185. record.get("decision_id") for record in final_output.get("decision_records", [])
  186. }
  187. if decision_ids - final_decision_ids:
  188. findings.append("decision_records incomplete")
  189. for author_asset in final_output.get("author_assets", []):
  190. if set(author_asset.get("decision_ids", [])) - decision_ids:
  191. findings.append(f"author_asset missing decisions: {author_asset.get('author_asset_id')}")
  192. if set(author_asset.get("source_path_record_ids", [])) - path_ids:
  193. findings.append(f"author_asset missing paths: {author_asset.get('author_asset_id')}")
  194. evidence_refs = author_asset.get("evidence_refs") or {}
  195. if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
  196. findings.append(f"author_asset evidence incomplete: {author_asset.get('author_asset_id')}")
  197. required_sections = [
  198. "content_assets",
  199. "author_assets",
  200. "review_records",
  201. "decision_records",
  202. "search_clues",
  203. "reject_records",
  204. "summary",
  205. ]
  206. missing_sections = [section for section in required_sections if section not in final_output]
  207. findings.extend(f"final_output missing section: {section}" for section in missing_sections)
  208. complete = not findings
  209. return {
  210. "validation_status": "pass" if complete else "fail",
  211. "run_path_complete": complete,
  212. "trace_complete": complete,
  213. "findings_summary": findings,
  214. }
  215. def _load_files(
  216. run_id: str,
  217. runtime: RuntimeFileStore,
  218. findings: list[dict[str, Any]],
  219. ) -> dict[str, Any]:
  220. run_dir = runtime.run_dir(run_id)
  221. data: dict[str, Any] = {}
  222. for filename in RUNTIME_FILENAMES:
  223. path = run_dir / filename
  224. if not path.exists():
  225. _fail(findings, "file_missing", f"missing runtime file: {filename}")
  226. continue
  227. try:
  228. if filename in JSON_FILES:
  229. data[filename] = json.loads(path.read_text(encoding="utf-8"))
  230. else:
  231. data[filename] = [
  232. json.loads(line)
  233. for line in path.read_text(encoding="utf-8").splitlines()
  234. if line.strip()
  235. ]
  236. except json.JSONDecodeError as exc:
  237. _fail(findings, "json_parse_failed", f"{filename} cannot parse: {exc}")
  238. return data
  239. def _check_run_ids(run_id: str, data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  240. for filename, value in data.items():
  241. rows = value if isinstance(value, list) else [value]
  242. for row in rows:
  243. if isinstance(row, dict) and row.get("run_id") != run_id:
  244. _fail(findings, "run_id_mismatch", f"{filename} has mismatched run_id")
  245. def _check_schema_versions(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  246. for filename, value in data.items():
  247. if filename in JSON_FILES:
  248. if isinstance(value, dict) and value.get("schema_version") != RUNTIME_SCHEMA_VERSION:
  249. _fail(findings, "schema_version_missing", f"{filename} has bad schema_version")
  250. continue
  251. rows = value if isinstance(value, list) else []
  252. for row in rows:
  253. if row.get("record_schema_version") != RUNTIME_RECORD_SCHEMA_VERSION:
  254. _fail(
  255. findings,
  256. "record_schema_version_missing",
  257. f"{filename} has bad record_schema_version",
  258. )
  259. def _check_policy_run_ids(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  260. if "policy_run_id" in data.get("source_context.json", {}):
  261. _fail(findings, "policy_run_id_unexpected", "source_context.json must stay run-level")
  262. policy_run_id = data.get("pattern_seed_pack.json", {}).get("policy_run_id")
  263. if not policy_run_id:
  264. _fail(findings, "policy_run_id_missing", "pattern_seed_pack.json missing policy_run_id")
  265. return
  266. for filename in POLICY_RUN_FILES:
  267. value = data.get(filename)
  268. rows = value if isinstance(value, list) else [value]
  269. for row in rows:
  270. if isinstance(row, dict) and row.get("policy_run_id") != policy_run_id:
  271. _fail(
  272. findings,
  273. "policy_run_id_mismatch",
  274. f"{filename} has mismatched policy_run_id",
  275. )
  276. def _check_raw_payloads(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  277. for filename in RAW_PAYLOAD_FILES:
  278. value = data.get(filename)
  279. rows = value if isinstance(value, list) else [value]
  280. for row in rows:
  281. if not isinstance(row, dict):
  282. continue
  283. raw_payload = row.get("raw_payload")
  284. if not isinstance(raw_payload, dict) or not raw_payload:
  285. _fail(findings, "raw_payload_missing", f"{filename} missing raw_payload")
  286. continue
  287. forbidden_paths = _find_forbidden_payload_keys(raw_payload)
  288. if forbidden_paths:
  289. _fail(
  290. findings,
  291. "raw_payload_forbidden_key",
  292. f"{filename} raw_payload contains forbidden keys: {forbidden_paths}",
  293. )
  294. def _find_forbidden_payload_keys(value: Any, prefix: str = "raw_payload") -> list[str]:
  295. if isinstance(value, dict):
  296. paths: list[str] = []
  297. for key, child in value.items():
  298. child_path = f"{prefix}.{key}"
  299. if str(key).lower() in FORBIDDEN_PAYLOAD_KEYS:
  300. paths.append(child_path)
  301. paths.extend(_find_forbidden_payload_keys(child, child_path))
  302. return paths
  303. if isinstance(value, list):
  304. paths = []
  305. for index, child in enumerate(value):
  306. paths.extend(_find_forbidden_payload_keys(child, f"{prefix}[{index}]"))
  307. return paths
  308. return []
  309. def _check_unique_ids(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  310. checks = [
  311. ("search_queries.jsonl", "search_query_id"),
  312. ("discovered_content_items.jsonl", "content_discovery_id"),
  313. ("discovered_content_items.jsonl", "platform_content_id"),
  314. ("pattern_recall_evidence.jsonl", "recall_evidence_id"),
  315. ("rule_decisions.jsonl", "decision_id"),
  316. ("rule_decisions.jsonl", "decision_target_id"),
  317. ("walk_actions.jsonl", "walk_action_id"),
  318. ("source_path_records.jsonl", "source_path_record_id"),
  319. ]
  320. for filename, field in checks:
  321. values = [row.get(field) for row in data.get(filename, [])]
  322. duplicates = [value for value, count in Counter(values).items() if value and count > 1]
  323. if duplicates:
  324. _fail(findings, "duplicate_id", f"{filename}.{field} duplicates: {duplicates}")
  325. def _check_references(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  326. search_query_ids = {row["search_query_id"] for row in data.get("search_queries.jsonl", [])}
  327. items = data.get("discovered_content_items.jsonl", [])
  328. content_ids = {row["platform_content_id"] for row in items}
  329. decisions = data.get("rule_decisions.jsonl", [])
  330. decision_ids = {row["decision_id"] for row in decisions}
  331. walk_action_ids = {row["walk_action_id"] for row in data.get("walk_actions.jsonl", [])}
  332. path_ids = {row["source_path_record_id"] for row in data.get("source_path_records.jsonl", [])}
  333. for item in items:
  334. if item.get("search_query_id") not in search_query_ids:
  335. _fail(
  336. findings,
  337. "missing_search_query_ref",
  338. f"content item has unknown search_query_id: {item.get('search_query_id')}",
  339. )
  340. for media in data.get("content_media_records.jsonl", []):
  341. if not media.get("platform"):
  342. _fail(findings, "platform_missing", "content_media_records.jsonl missing platform")
  343. if media.get("platform_content_id") not in content_ids:
  344. _fail(
  345. findings,
  346. "missing_content_ref",
  347. f"media has unknown platform_content_id: {media.get('platform_content_id')}",
  348. )
  349. for evidence in data.get("pattern_recall_evidence.jsonl", []):
  350. if evidence.get("platform_content_id") not in content_ids:
  351. _fail(
  352. findings,
  353. "missing_content_ref",
  354. f"recall evidence has unknown platform_content_id: {evidence.get('platform_content_id')}",
  355. )
  356. for decision in decisions:
  357. if decision.get("decision_target_id") not in content_ids:
  358. _fail(
  359. findings,
  360. "missing_content_ref",
  361. f"decision has unknown decision_target_id: {decision.get('decision_target_id')}",
  362. )
  363. if decision.get("decision_action") not in DECISION_ACTIONS:
  364. _fail(
  365. findings,
  366. "bad_decision_action",
  367. f"unsupported decision_action: {decision.get('decision_action')}",
  368. )
  369. if decision.get("search_query_effect_status") not in EFFECT_STATUSES:
  370. _fail(
  371. findings,
  372. "bad_effect_status",
  373. f"unsupported decision effect status: {decision.get('search_query_effect_status')}",
  374. )
  375. replay_data = decision.get("decision_replay_data") or {}
  376. missing_replay_fields = [
  377. field for field in DECISION_REPLAY_REQUIRED_FIELDS if not replay_data.get(field)
  378. ]
  379. if missing_replay_fields:
  380. _fail(
  381. findings,
  382. "decision_replay_incomplete",
  383. f"decision {decision.get('decision_id')} missing replay fields: {missing_replay_fields}",
  384. )
  385. for clue in data.get("search_clues.jsonl", []):
  386. if clue.get("search_query_effect_status") not in EFFECT_STATUSES:
  387. _fail(
  388. findings,
  389. "bad_effect_status",
  390. f"unsupported query effect status: {clue.get('search_query_effect_status')}",
  391. )
  392. if not clue.get("query_aggregation_id"):
  393. _fail(
  394. findings,
  395. "missing_query_aggregation_id",
  396. f"search clue missing query_aggregation_id: {clue.get('clue_id')}",
  397. )
  398. for action in data.get("walk_actions.jsonl", []):
  399. missing_action_fields = [
  400. field
  401. for field in ["walk_action_id", "edge_id", "walk_action", "walk_status"]
  402. if not action.get(field)
  403. ]
  404. if missing_action_fields:
  405. _fail(
  406. findings,
  407. "walk_action_incomplete",
  408. f"walk action missing fields: {missing_action_fields}",
  409. )
  410. if action.get("walk_status") not in WALK_STATUSES:
  411. _fail(
  412. findings,
  413. "bad_walk_status",
  414. f"unsupported walk_status: {action.get('walk_status')}",
  415. )
  416. decision_id = action.get("decision_id")
  417. if decision_id and decision_id not in decision_ids:
  418. _fail(findings, "missing_decision_ref", f"walk action has unknown decision_id: {decision_id}")
  419. for path in data.get("source_path_records.jsonl", []):
  420. decision_id = path.get("decision_id")
  421. if decision_id and decision_id not in decision_ids:
  422. _fail(findings, "missing_decision_ref", f"path has unknown decision_id: {decision_id}")
  423. walk_action_id = (path.get("raw_payload") or {}).get("walk_action_id") or path.get("walk_action_id")
  424. if walk_action_id and walk_action_id not in walk_action_ids:
  425. _fail(
  426. findings,
  427. "missing_walk_action_ref",
  428. f"path has unknown walk_action_id: {walk_action_id}",
  429. )
  430. final_output = data.get("final_output.json", {})
  431. for asset in final_output.get("content_assets", []):
  432. if asset.get("decision_id") not in decision_ids:
  433. _fail(
  434. findings,
  435. "missing_decision_ref",
  436. f"asset has unknown decision_id: {asset.get('decision_id')}",
  437. )
  438. for path_id in asset.get("source_path_record_ids", []):
  439. if path_id not in path_ids:
  440. _fail(findings, "missing_path_ref", f"asset has unknown path_id: {path_id}")
  441. for record in final_output.get("review_records", []):
  442. if record.get("decision_id") not in decision_ids:
  443. _fail(
  444. findings,
  445. "missing_decision_ref",
  446. f"review_records has unknown decision_id: {record.get('decision_id')}",
  447. )
  448. for path_id in record.get("source_path_record_ids", []):
  449. if path_id not in path_ids:
  450. _fail(
  451. findings,
  452. "missing_path_ref",
  453. f"review_records has unknown path_id: {path_id}",
  454. )
  455. for section in ["reject_records", "technical_retry_records", "decision_records"]:
  456. for row in final_output.get(section, []):
  457. if row.get("decision_id") not in decision_ids:
  458. _fail(
  459. findings,
  460. "missing_decision_ref",
  461. f"{section} has unknown decision_id: {row.get('decision_id')}",
  462. )
  463. for path_id in row.get("source_path_record_ids", []):
  464. if path_id not in path_ids:
  465. _fail(findings, "missing_path_ref", f"{section} has unknown path_id: {path_id}")
  466. for author_asset in final_output.get("author_assets", []):
  467. for decision_id in author_asset.get("decision_ids", []):
  468. if decision_id not in decision_ids:
  469. _fail(
  470. findings,
  471. "missing_decision_ref",
  472. f"author_assets has unknown decision_id: {decision_id}",
  473. )
  474. for path_id in author_asset.get("source_path_record_ids", []):
  475. if path_id not in path_ids:
  476. _fail(
  477. findings,
  478. "missing_path_ref",
  479. f"author_assets has unknown path_id: {path_id}",
  480. )
  481. evidence_refs = author_asset.get("evidence_refs") or {}
  482. if evidence_refs.get("decision_ids") != author_asset.get("decision_ids"):
  483. _fail(
  484. findings,
  485. "author_asset_evidence_incomplete",
  486. f"author asset evidence refs do not match decisions: {author_asset.get('author_asset_id')}",
  487. )
  488. def _check_pattern_recall_evidence(
  489. data: dict[str, Any],
  490. findings: list[dict[str, Any]],
  491. ) -> None:
  492. evidence_rows = data.get("pattern_recall_evidence.jsonl", [])
  493. evidence_by_id = {row.get("recall_evidence_id"): row for row in evidence_rows}
  494. for item in data.get("discovered_content_items.jsonl", []):
  495. pattern_match = item.get("pattern_match_result") or {}
  496. # V3(M3):桥接键 pattern_recall 已退役;改以"是否被判定过"(judge_status 存在)为准——
  497. # 每条经 Gemini 判定的内容必须能解析到真实 evidence 行,否则视为血缘损坏。
  498. if not pattern_match.get("judge_status"):
  499. continue
  500. evidence_id = pattern_match.get("pattern_recall_evidence_id")
  501. evidence = evidence_by_id.get(evidence_id)
  502. if not evidence:
  503. _fail(
  504. findings,
  505. "pattern_recall_evidence_missing",
  506. f"matched item cannot find recall evidence: {evidence_id}",
  507. )
  508. # V3(M2):判定改为 Gemini 直读,不再有 decode 强证据词/分类树路径;
  509. # matched_terms/matched_category_paths 不再适用,故不再强制。
  510. def _check_source_evidence(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  511. source_context = data.get("source_context.json", {})
  512. evidence_pack = source_context.get("ext_data", {}).get("evidence_pack", {})
  513. for decision in data.get("rule_decisions.jsonl", []):
  514. _check_one_source_evidence(
  515. findings,
  516. decision.get("source_evidence") or {},
  517. evidence_pack,
  518. f"decision {decision.get('decision_id')}",
  519. )
  520. _check_final_output_source_evidence_refs(data, findings)
  521. _check_final_decision_coverage(data, findings)
  522. def _check_final_output_source_evidence_refs(
  523. data: dict[str, Any],
  524. findings: list[dict[str, Any]],
  525. ) -> None:
  526. decisions_by_id = {
  527. decision.get("decision_id"): decision
  528. for decision in data.get("rule_decisions.jsonl", [])
  529. if decision.get("decision_id")
  530. }
  531. final_output = data.get("final_output.json", {})
  532. for section in [
  533. "content_assets",
  534. "reject_records",
  535. "review_records",
  536. "technical_retry_records",
  537. "decision_records",
  538. ]:
  539. for record in final_output.get(section, []):
  540. decision_id = record.get("decision_id")
  541. decision = decisions_by_id.get(decision_id)
  542. if not decision:
  543. continue
  544. ref = record.get("source_evidence_ref")
  545. if ref is None:
  546. if "source_evidence" in record:
  547. continue
  548. _fail(
  549. findings,
  550. "source_evidence_ref_missing",
  551. f"{section} {decision_id} missing source_evidence_ref",
  552. )
  553. continue
  554. if not isinstance(ref, dict):
  555. _fail(
  556. findings,
  557. "source_evidence_ref_invalid",
  558. f"{section} {decision_id} has invalid source_evidence_ref",
  559. )
  560. continue
  561. if ref.get("decision_id") != decision_id:
  562. _fail(
  563. findings,
  564. "source_evidence_ref_mismatch",
  565. f"{section} {decision_id} ref points to wrong decision",
  566. )
  567. target_id = record.get("platform_content_id") or record.get("decision_target_id")
  568. expected_target = decision.get("decision_target_id")
  569. if target_id and expected_target and ref.get("decision_target_id") != expected_target:
  570. _fail(
  571. findings,
  572. "source_evidence_ref_mismatch",
  573. f"{section} {decision_id} ref points to wrong target",
  574. )
  575. def _check_one_source_evidence(
  576. findings: list[dict[str, Any]],
  577. source_evidence: dict[str, Any],
  578. evidence_pack: dict[str, Any],
  579. label: str,
  580. ) -> None:
  581. missing_fields = sorted(field for field in SOURCE_EVIDENCE_FIELDS if field not in source_evidence)
  582. if missing_fields:
  583. _fail(findings, "source_evidence_missing_fields", f"{label} missing {missing_fields}")
  584. # V3(M4):分类树 category/element binding 已随 decode 链路退役,不再作为血缘门槛。
  585. scalar_fields = [
  586. "pattern_execution_id",
  587. "source_post_id",
  588. "pattern_source_system",
  589. "case_id_type",
  590. "mining_config_id",
  591. "support",
  592. "absolute_support",
  593. "source_certainty",
  594. "validation_status",
  595. ]
  596. list_fields = [
  597. "itemset_ids",
  598. "itemset_items",
  599. "matched_post_ids",
  600. "video_ids",
  601. "case_ids",
  602. "seed_terms",
  603. ]
  604. for field in scalar_fields + list_fields:
  605. if source_evidence.get(field) != evidence_pack.get(field):
  606. _fail(findings, "source_evidence_mismatch", f"{label} mismatched {field}")
  607. if (
  608. "decode_case_ids" in source_evidence
  609. and source_evidence.get("decode_case_ids") != evidence_pack.get("decode_case_ids")
  610. ):
  611. _fail(findings, "source_evidence_mismatch", f"{label} mismatched decode_case_ids")
  612. platform_content_id = source_evidence.get("discovered_platform_content_id")
  613. if platform_content_id:
  614. if platform_content_id == source_evidence.get("source_post_id"):
  615. _fail(findings, "source_evidence_content_pollution", f"{label} rewrites source_post_id")
  616. if platform_content_id in (source_evidence.get("matched_post_ids") or []):
  617. _fail(
  618. findings,
  619. "source_evidence_content_pollution",
  620. f"{label} rewrites matched_post_ids",
  621. )
  622. def _check_final_decision_coverage(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  623. decision_ids = {decision["decision_id"] for decision in data.get("rule_decisions.jsonl", [])}
  624. final_decision_ids = {
  625. record.get("decision_id")
  626. for record in data.get("final_output.json", {}).get("decision_records", [])
  627. }
  628. missing = sorted(decision_ids - final_decision_ids)
  629. if missing:
  630. _fail(findings, "final_decision_missing", f"final_output decision_records missing {missing}")
  631. def _check_source_paths(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  632. evidence_pack = data.get("source_context.json", {}).get("ext_data", {}).get("evidence_pack", {})
  633. pattern_execution_id = evidence_pack.get("pattern_execution_id")
  634. paths = data.get("source_path_records.jsonl", [])
  635. path_by_id = {path["source_path_record_id"]: path for path in paths}
  636. pattern_query_paths = {
  637. path["to_node_id"]: path
  638. for path in paths
  639. if path.get("source_path_type") == "pattern_to_search_query"
  640. }
  641. query_content_paths = {
  642. path["to_node_id"]: path
  643. for path in paths
  644. if path.get("source_path_type") == "search_query_to_content"
  645. }
  646. decision_asset_paths = {
  647. (path.get("decision_id"), path.get("to_node_id")): path
  648. for path in paths
  649. if path.get("source_path_type") == "decision_to_asset"
  650. }
  651. for decision in data.get("rule_decisions.jsonl", []):
  652. _check_content_source_path(
  653. findings,
  654. label=f"decision {decision.get('decision_id')}",
  655. platform_content_id=decision.get("decision_target_id"),
  656. pattern_execution_id=pattern_execution_id,
  657. pattern_query_paths=pattern_query_paths,
  658. query_content_paths=query_content_paths,
  659. )
  660. for asset in data.get("final_output.json", {}).get("content_assets", []):
  661. platform_content_id = asset.get("platform_content_id")
  662. path_ids = set(asset.get("source_path_record_ids", []))
  663. query_content = query_content_paths.get(platform_content_id)
  664. if not query_content or query_content.get("source_path_record_id") not in path_ids:
  665. _fail(
  666. findings,
  667. "source_path_broken",
  668. f"asset lacks search_query_to_content path: {platform_content_id}",
  669. )
  670. continue
  671. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  672. if not pattern_query or pattern_query.get("source_path_record_id") not in path_ids:
  673. _fail(
  674. findings,
  675. "source_path_broken",
  676. f"asset lacks pattern_to_search_query path: {platform_content_id}",
  677. )
  678. continue
  679. if pattern_query.get("from_node_id") != pattern_execution_id:
  680. _fail(
  681. findings,
  682. "source_path_broken",
  683. f"asset path starts from wrong pattern: {platform_content_id}",
  684. )
  685. for path_id in path_ids:
  686. if path_id not in path_by_id:
  687. _fail(findings, "source_path_broken", f"asset path missing: {path_id}")
  688. decision_asset = decision_asset_paths.get(
  689. (asset.get("decision_id"), platform_content_id)
  690. )
  691. if not decision_asset:
  692. _fail(
  693. findings,
  694. "decision_to_asset_missing",
  695. f"asset lacks decision_to_asset path: {platform_content_id}",
  696. )
  697. continue
  698. if decision_asset.get("source_path_record_id") not in path_ids:
  699. _fail(
  700. findings,
  701. "decision_to_asset_missing",
  702. f"asset source paths omit decision_to_asset: {platform_content_id}",
  703. )
  704. if decision_asset.get("from_node_type") != "RuleDecision":
  705. _fail(
  706. findings,
  707. "decision_to_asset_broken",
  708. f"asset decision_to_asset starts from wrong node: {platform_content_id}",
  709. )
  710. if decision_asset.get("from_node_id") != asset.get("decision_id"):
  711. _fail(
  712. findings,
  713. "decision_to_asset_broken",
  714. f"asset decision_to_asset has wrong decision id: {platform_content_id}",
  715. )
  716. if decision_asset.get("to_node_type") != "ContentAsset":
  717. _fail(
  718. findings,
  719. "decision_to_asset_broken",
  720. f"asset decision_to_asset ends at wrong node: {platform_content_id}",
  721. )
  722. for record in data.get("final_output.json", {}).get("review_records", []):
  723. platform_content_id = record.get("platform_content_id")
  724. path_ids = set(record.get("source_path_record_ids", []))
  725. query_content = query_content_paths.get(platform_content_id)
  726. if not query_content or query_content.get("source_path_record_id") not in path_ids:
  727. _fail(
  728. findings,
  729. "source_path_broken",
  730. f"review record lacks search_query_to_content path: {platform_content_id}",
  731. )
  732. continue
  733. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  734. if not pattern_query or pattern_query.get("source_path_record_id") not in path_ids:
  735. _fail(
  736. findings,
  737. "source_path_broken",
  738. f"review record lacks pattern_to_search_query path: {platform_content_id}",
  739. )
  740. def _check_content_source_path(
  741. findings: list[dict[str, Any]],
  742. label: str,
  743. platform_content_id: Any,
  744. pattern_execution_id: Any,
  745. pattern_query_paths: dict[str, dict[str, Any]],
  746. query_content_paths: dict[str, dict[str, Any]],
  747. ) -> None:
  748. query_content = query_content_paths.get(platform_content_id)
  749. if not query_content:
  750. _fail(
  751. findings,
  752. "source_path_broken",
  753. f"{label} lacks search_query_to_content path: {platform_content_id}",
  754. )
  755. return
  756. pattern_query = pattern_query_paths.get(query_content.get("from_node_id"))
  757. if not pattern_query:
  758. _fail(
  759. findings,
  760. "source_path_broken",
  761. f"{label} lacks pattern_to_search_query path: {platform_content_id}",
  762. )
  763. return
  764. if pattern_query.get("from_node_id") != pattern_execution_id:
  765. _fail(
  766. findings,
  767. "source_path_broken",
  768. f"{label} path starts from wrong pattern: {platform_content_id}",
  769. )
  770. def _check_summary(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  771. decisions = data.get("rule_decisions.jsonl", [])
  772. action_counts = Counter(decision.get("decision_action") for decision in decisions)
  773. summary = data.get("final_output.json", {}).get("summary", {})
  774. expected = {
  775. "pooled_content_count": action_counts["ADD_TO_CONTENT_POOL"],
  776. "review_content_count": action_counts["KEEP_CONTENT_FOR_REVIEW"],
  777. "pending_content_count": 0,
  778. "rejected_content_count": action_counts["REJECT_CONTENT"],
  779. }
  780. if (
  781. action_counts["TECHNICAL_RETRY_REQUIRED"]
  782. or "technical_retry_content_count" in summary
  783. ):
  784. expected["technical_retry_content_count"] = action_counts["TECHNICAL_RETRY_REQUIRED"]
  785. for field, value in expected.items():
  786. if summary.get(field) != value:
  787. _fail(
  788. findings,
  789. "summary_mismatch",
  790. f"summary.{field} expected {value}, got {summary.get(field)}",
  791. )
  792. clue_counts = Counter()
  793. for clue in data.get("search_clues.jsonl", []):
  794. clue_counts["ADD_TO_CONTENT_POOL"] += clue.get("pooled_content_count", 0)
  795. clue_counts["KEEP_CONTENT_FOR_REVIEW"] += clue.get("review_content_count", 0)
  796. clue_counts["REJECT_CONTENT"] += clue.get("rejected_content_count", 0)
  797. clue_counts["TECHNICAL_RETRY_REQUIRED"] += clue.get("technical_retry_content_count", 0)
  798. for action, count in action_counts.items():
  799. if action == "TECHNICAL_RETRY_REQUIRED" and not count:
  800. continue
  801. if clue_counts[action] < count:
  802. _fail(
  803. findings,
  804. "search_clue_mismatch",
  805. f"search_clues {action} expected at least {count}, got {clue_counts[action]}",
  806. )
  807. def _check_completeness(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  808. final_output = data.get("final_output.json", {})
  809. expected = compute_final_output_completeness(
  810. final_output,
  811. data.get("rule_decisions.jsonl", []),
  812. data.get("source_path_records.jsonl", []),
  813. )
  814. summary = final_output.get("summary", {})
  815. for field in ["run_path_complete", "trace_complete"]:
  816. if summary.get(field) != expected[field]:
  817. _fail(
  818. findings,
  819. "completeness_mismatch",
  820. f"summary.{field} expected {expected[field]}, got {summary.get(field)}",
  821. )
  822. if final_output.get("validation_status") != expected["validation_status"]:
  823. _fail(
  824. findings,
  825. "completeness_mismatch",
  826. f"validation_status expected {expected['validation_status']}, got {final_output.get('validation_status')}",
  827. )
  828. def _is_v4_contract_record(decision: dict[str, Any]) -> bool:
  829. scorecard = decision.get("scorecard") or {}
  830. return isinstance(scorecard, dict) and scorecard.get("schema_version") == V4_SCORECARD_SCHEMA_VERSION
  831. def _v4_score_values(decision: dict[str, Any]) -> tuple[Any, Any, Any]:
  832. scorecard = decision.get("scorecard") or {}
  833. return (
  834. scorecard.get("query_relevance_score"),
  835. scorecard.get("platform_performance_score"),
  836. decision.get("score"),
  837. )
  838. def _v4_score_thresholds(decision: dict[str, Any]) -> dict[str, Any]:
  839. scorecard = decision.get("scorecard") or {}
  840. thresholds = scorecard.get("score_thresholds")
  841. if not isinstance(thresholds, dict):
  842. return dict(V4_SCORE_THRESHOLD_FALLBACK)
  843. return {**V4_SCORE_THRESHOLD_FALLBACK, **thresholds}
  844. def _v4_walk_threshold_message(thresholds: dict[str, Any]) -> str:
  845. return (
  846. f"query>={thresholds['walk_query']}/"
  847. f"platform>={thresholds['walk_platform']}/"
  848. f"score>={thresholds['walk_total']}"
  849. )
  850. def _check_v4_score_contract(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  851. for decision in data.get("rule_decisions.jsonl", []):
  852. if not _is_v4_contract_record(decision):
  853. continue
  854. scorecard = decision.get("scorecard") or {}
  855. query_score, platform_score, total_score = _v4_score_values(decision)
  856. score_missing = bool(scorecard.get("score_missing"))
  857. technical_retry = decision.get("decision_reason_code") in {
  858. "v4_technical_retry_needed",
  859. "portrait_unavailable",
  860. "portrait_incomplete",
  861. }
  862. if score_missing and technical_retry:
  863. if not isinstance(scorecard.get("missing_observable_fields"), list):
  864. _fail(
  865. findings,
  866. "v4_missing_observable_fields_invalid",
  867. f"decision {decision.get('decision_id')} missing_observable_fields must be a list",
  868. )
  869. continue
  870. for field_name, value in [
  871. ("query_relevance_score", query_score),
  872. ("platform_performance_score", platform_score),
  873. ("score", total_score),
  874. ]:
  875. if not _is_number(value) or not 0 <= value <= 100:
  876. _fail(
  877. findings,
  878. "v4_score_contract_invalid",
  879. f"decision {decision.get('decision_id')} has invalid {field_name}: {value}",
  880. )
  881. if not isinstance(scorecard.get("missing_observable_fields"), list):
  882. _fail(
  883. findings,
  884. "v4_missing_observable_fields_invalid",
  885. f"decision {decision.get('decision_id')} missing_observable_fields must be a list",
  886. )
  887. if _is_number(query_score) and _is_number(platform_score) and _is_number(total_score):
  888. weights = _v4_score_weights_from_scorecard(scorecard)
  889. scores = {
  890. "query_relevance": query_score,
  891. "platform_performance": platform_score,
  892. "fifty_plus": scorecard.get("fifty_plus_score"),
  893. }
  894. expected = _weighted_score(scores, weights)
  895. if not _is_number(expected) or abs(total_score - expected) > 0.01:
  896. _fail(
  897. findings,
  898. "v4_score_total_mismatch",
  899. f"decision {decision.get('decision_id')} score expected {expected}, got {total_score}",
  900. )
  901. def _v4_score_weights_from_scorecard(scorecard: dict[str, Any]) -> dict[str, float]:
  902. weights = scorecard.get("score_weights")
  903. if isinstance(weights, dict) and weights:
  904. return {
  905. str(key): float(value)
  906. for key, value in weights.items()
  907. if _is_number(value)
  908. }
  909. fifty_status = scorecard.get("fifty_plus_status")
  910. if fifty_status == "ok" and _is_number(scorecard.get("fifty_plus_score")):
  911. return {"query_relevance": 0.35, "platform_performance": 0.35, "fifty_plus": 0.30}
  912. if fifty_status == "not_attempted":
  913. return {"query_relevance": 0.35, "platform_performance": 0.35}
  914. return {"query_relevance": 0.5, "platform_performance": 0.5}
  915. def _weighted_score(scores: dict[str, Any], weights: dict[str, float]) -> float | None:
  916. total = 0.0
  917. for key, weight in weights.items():
  918. score = scores.get(key)
  919. if not _is_number(score):
  920. return None
  921. total += float(score) * weight
  922. return total
  923. def _check_v4_walk_gate_contract(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  924. for decision in data.get("rule_decisions.jsonl", []):
  925. if not _is_v4_contract_record(decision):
  926. continue
  927. replay_data = decision.get("decision_replay_data") or {}
  928. if "allow_walk" not in replay_data:
  929. _fail(
  930. findings,
  931. "v4_allow_walk_missing",
  932. f"decision {decision.get('decision_id')} missing decision_replay_data.allow_walk",
  933. )
  934. continue
  935. if not isinstance(replay_data.get("allow_walk"), bool):
  936. _fail(
  937. findings,
  938. "v4_allow_walk_invalid",
  939. f"decision {decision.get('decision_id')} allow_walk must be boolean",
  940. )
  941. continue
  942. query_score, platform_score, total_score = _v4_score_values(decision)
  943. thresholds = _v4_score_thresholds(decision)
  944. expected_allow_walk = (
  945. _is_number(query_score)
  946. and _is_number(platform_score)
  947. and _is_number(total_score)
  948. and query_score >= thresholds["walk_query"]
  949. and platform_score >= thresholds["walk_platform"]
  950. and total_score >= thresholds["walk_total"]
  951. )
  952. if replay_data["allow_walk"] != expected_allow_walk:
  953. _fail(
  954. findings,
  955. "v4_allow_walk_threshold_mismatch",
  956. f"decision {decision.get('decision_id')} allow_walk does not match {_v4_walk_threshold_message(thresholds)}",
  957. )
  958. def _check_v4_walk_action_consumption(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  959. v4_decisions_by_id = {
  960. decision.get("decision_id"): decision
  961. for decision in data.get("rule_decisions.jsonl", [])
  962. if _is_v4_contract_record(decision) and decision.get("decision_id")
  963. }
  964. if not v4_decisions_by_id:
  965. return
  966. for action in data.get("walk_actions.jsonl", []):
  967. edge_id = action.get("edge_id")
  968. if edge_id not in {"hashtag_to_query", "author_to_works"}:
  969. continue
  970. raw_payload = action.get("raw_payload") or {}
  971. decision_id = raw_payload.get("decision_id") or action.get("decision_id")
  972. decision = v4_decisions_by_id.get(decision_id)
  973. if not decision:
  974. continue
  975. missing_fields = [
  976. field
  977. for field in ["decision_id", "allow_walk", "allow_walk_reason", "walk_gate_snapshot"]
  978. if field not in raw_payload
  979. ]
  980. if missing_fields:
  981. _fail(
  982. findings,
  983. "v4_walk_action_gate_context_missing",
  984. f"walk action {action.get('walk_action_id')} missing V4 gate context fields: {missing_fields}",
  985. )
  986. continue
  987. expected_allow_walk = (decision.get("decision_replay_data") or {}).get("allow_walk")
  988. if raw_payload.get("allow_walk") != expected_allow_walk:
  989. _fail(
  990. findings,
  991. "v4_walk_action_allow_walk_mismatch",
  992. f"walk action {action.get('walk_action_id')} allow_walk does not match decision {decision_id}",
  993. )
  994. if action.get("walk_status") == "success" and expected_allow_walk is not True:
  995. _fail(
  996. findings,
  997. "v4_walk_action_allow_walk_denied",
  998. f"walk action {action.get('walk_action_id')} succeeded for allow_walk=false decision {decision_id}",
  999. )
  1000. if action.get("reason_code") == "v4_allow_walk_denied" and expected_allow_walk is not False:
  1001. _fail(
  1002. findings,
  1003. "v4_walk_action_deny_mismatch",
  1004. f"walk action {action.get('walk_action_id')} denies walk but decision {decision_id} is not allow_walk=false",
  1005. )
  1006. def _check_v4_final_output_explanation(
  1007. data: dict[str, Any],
  1008. findings: list[dict[str, Any]],
  1009. ) -> None:
  1010. v4_decisions_by_id = {
  1011. decision.get("decision_id"): decision
  1012. for decision in data.get("rule_decisions.jsonl", [])
  1013. if _is_v4_contract_record(decision) and decision.get("decision_id")
  1014. }
  1015. if not v4_decisions_by_id:
  1016. return
  1017. records_by_decision_id = _final_output_records_by_decision_id(
  1018. data.get("final_output.json", {})
  1019. )
  1020. for decision_id, decision in v4_decisions_by_id.items():
  1021. section_records = records_by_decision_id.get(decision_id) or {}
  1022. decision_records = section_records.get("decision_records") or []
  1023. if not decision_records:
  1024. _fail(
  1025. findings,
  1026. "v4_final_output_explanation_missing",
  1027. f"final_output decision_records missing V4 explanation record for decision {decision_id}",
  1028. )
  1029. continue
  1030. for section, records in section_records.items():
  1031. for record in records:
  1032. _check_v4_explanation_record(
  1033. decision,
  1034. record,
  1035. f"final_output.{section}",
  1036. findings,
  1037. )
  1038. def _final_output_records_by_decision_id(
  1039. final_output: dict[str, Any],
  1040. ) -> dict[str, dict[str, list[dict[str, Any]]]]:
  1041. by_decision_id: dict[str, dict[str, list[dict[str, Any]]]] = {}
  1042. for section in [
  1043. "content_assets",
  1044. "review_records",
  1045. "reject_records",
  1046. "technical_retry_records",
  1047. "decision_records",
  1048. ]:
  1049. for record in final_output.get(section, []) or []:
  1050. decision_id = record.get("decision_id")
  1051. if not decision_id:
  1052. for candidate in record.get("decision_ids") or []:
  1053. by_decision_id.setdefault(candidate, {}).setdefault(section, []).append(record)
  1054. continue
  1055. by_decision_id.setdefault(decision_id, {}).setdefault(section, []).append(record)
  1056. return by_decision_id
  1057. def _check_v4_explanation_record(
  1058. decision: dict[str, Any],
  1059. record: dict[str, Any],
  1060. section: str,
  1061. findings: list[dict[str, Any]],
  1062. ) -> None:
  1063. explanation = record.get("v4_explanation")
  1064. decision_id = decision.get("decision_id")
  1065. if not isinstance(explanation, dict) or not explanation:
  1066. _fail(
  1067. findings,
  1068. "v4_final_output_explanation_missing",
  1069. f"{section} record for decision {decision_id} missing v4_explanation",
  1070. )
  1071. return
  1072. if explanation.get("schema_version") != "v4_decision_explanation.v1":
  1073. _fail(
  1074. findings,
  1075. "v4_final_output_explanation_mismatch",
  1076. f"{section} record for decision {decision_id} has invalid V4 explanation schema",
  1077. )
  1078. scorecard = decision.get("scorecard") or {}
  1079. replay_data = decision.get("decision_replay_data") or {}
  1080. expected_values = {
  1081. "scorecard_schema_version": scorecard.get("schema_version"),
  1082. "query_relevance_score": scorecard.get("query_relevance_score"),
  1083. "platform_performance_score": scorecard.get("platform_performance_score"),
  1084. "score": decision.get("score"),
  1085. "decision_reason_code": decision.get("decision_reason_code"),
  1086. "allow_walk": replay_data.get("allow_walk"),
  1087. "allow_walk_reason": replay_data.get("allow_walk_reason"),
  1088. }
  1089. for field_name, expected in expected_values.items():
  1090. if expected is None and field_name not in explanation:
  1091. continue
  1092. if not _v4_values_equal(explanation.get(field_name), expected):
  1093. _fail(
  1094. findings,
  1095. "v4_final_output_explanation_mismatch",
  1096. f"{section} record for decision {decision_id} has mismatched {field_name}",
  1097. )
  1098. if not isinstance(explanation.get("missing_observable_fields"), list):
  1099. _fail(
  1100. findings,
  1101. "v4_final_output_explanation_mismatch",
  1102. f"{section} record for decision {decision_id} missing_observable_fields must be a list",
  1103. )
  1104. def _check_v4_strategy_review_explanation(
  1105. data: dict[str, Any],
  1106. findings: list[dict[str, Any]],
  1107. ) -> None:
  1108. if not any(_is_v4_contract_record(decision) for decision in data.get("rule_decisions.jsonl", [])):
  1109. return
  1110. strategy_review = data.get("strategy_review.json") or {}
  1111. if not strategy_review:
  1112. return
  1113. v4_summary = strategy_review.get("v4_summary")
  1114. if not isinstance(v4_summary, dict):
  1115. _fail(
  1116. findings,
  1117. "v4_strategy_review_explanation_missing",
  1118. "strategy_review missing v4_summary for V4 decisions",
  1119. )
  1120. return
  1121. required_fields = [
  1122. "schema_version",
  1123. "score_buckets",
  1124. "allow_walk_distribution",
  1125. "walk_gate_review",
  1126. ]
  1127. missing = [field for field in required_fields if field not in v4_summary]
  1128. if missing:
  1129. _fail(
  1130. findings,
  1131. "v4_strategy_review_explanation_missing",
  1132. f"strategy_review.v4_summary missing fields: {missing}",
  1133. )
  1134. return
  1135. if v4_summary.get("schema_version") != "v4_strategy_review_summary.v1":
  1136. _fail(
  1137. findings,
  1138. "v4_strategy_review_explanation_invalid",
  1139. "strategy_review.v4_summary has invalid schema_version",
  1140. )
  1141. if not isinstance(v4_summary.get("score_buckets"), dict):
  1142. _fail(
  1143. findings,
  1144. "v4_strategy_review_explanation_invalid",
  1145. "strategy_review.v4_summary.score_buckets must be an object",
  1146. )
  1147. allow_walk_distribution = v4_summary.get("allow_walk_distribution")
  1148. if not isinstance(allow_walk_distribution, dict) or not {
  1149. "allowed",
  1150. "denied",
  1151. "missing",
  1152. } <= set(allow_walk_distribution):
  1153. _fail(
  1154. findings,
  1155. "v4_strategy_review_explanation_invalid",
  1156. "strategy_review.v4_summary.allow_walk_distribution is incomplete",
  1157. )
  1158. walk_gate_review = v4_summary.get("walk_gate_review")
  1159. if not isinstance(walk_gate_review, dict) or "v4_gate_distribution" not in walk_gate_review:
  1160. _fail(
  1161. findings,
  1162. "v4_strategy_review_explanation_invalid",
  1163. "strategy_review.v4_summary.walk_gate_review is incomplete",
  1164. )
  1165. def _check_v4_action_thresholds(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  1166. for decision in data.get("rule_decisions.jsonl", []):
  1167. if not _is_v4_contract_record(decision):
  1168. continue
  1169. if decision.get("decision_reason_code") == "v4_technical_retry_needed":
  1170. continue
  1171. query_score, _platform_score, total_score = _v4_score_values(decision)
  1172. if not (_is_number(query_score) and _is_number(total_score)):
  1173. continue
  1174. action = decision.get("decision_action")
  1175. thresholds = _v4_score_thresholds(decision)
  1176. if action == "ADD_TO_CONTENT_POOL":
  1177. ok = query_score >= thresholds["pool_query"] and total_score >= thresholds["pool_total"]
  1178. elif action == "KEEP_CONTENT_FOR_REVIEW":
  1179. ok = (
  1180. query_score >= thresholds["review_query"]
  1181. and total_score >= thresholds["review_total"]
  1182. and not (
  1183. query_score >= thresholds["pool_query"]
  1184. and total_score >= thresholds["pool_total"]
  1185. )
  1186. )
  1187. elif action == "REJECT_CONTENT":
  1188. ok = query_score < thresholds["review_query"] or total_score < thresholds["review_total"]
  1189. else:
  1190. continue
  1191. if not ok:
  1192. _fail(
  1193. findings,
  1194. "v4_action_threshold_mismatch",
  1195. f"decision {decision.get('decision_id')} action {action} conflicts with query/score thresholds",
  1196. )
  1197. def _check_v4_gemini_failure_contract(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  1198. for evidence in data.get("pattern_recall_evidence.jsonl", []):
  1199. summary = evidence.get("evidence_summary") or {}
  1200. if not (
  1201. isinstance(summary, dict)
  1202. and summary.get("schema_version") == V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION
  1203. ):
  1204. continue
  1205. if summary.get("final_status") != "failed":
  1206. continue
  1207. missing = [
  1208. field
  1209. for field in ["failure_type", "retry_count", "http_status_code", "final_status"]
  1210. if field not in summary
  1211. ]
  1212. if missing:
  1213. _fail(
  1214. findings,
  1215. "v4_gemini_failure_incomplete",
  1216. f"recall evidence {evidence.get('recall_evidence_id')} missing failure fields: {missing}",
  1217. )
  1218. if not isinstance(summary.get("retry_count"), int) or summary.get("retry_count", 0) < 1:
  1219. _fail(
  1220. findings,
  1221. "v4_gemini_failure_retry_invalid",
  1222. f"recall evidence {evidence.get('recall_evidence_id')} retry_count must be >=1",
  1223. )
  1224. def _check_v4_legacy_field_blocklist(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
  1225. for decision in data.get("rule_decisions.jsonl", []):
  1226. if not _is_v4_contract_record(decision):
  1227. continue
  1228. legacy_paths = _find_legacy_field_paths(
  1229. {
  1230. "scorecard": decision.get("scorecard"),
  1231. "decision_replay_data": decision.get("decision_replay_data"),
  1232. "raw_payload": decision.get("raw_payload"),
  1233. },
  1234. f"decision {decision.get('decision_id')}",
  1235. )
  1236. if legacy_paths:
  1237. _fail(
  1238. findings,
  1239. "v4_legacy_field_present",
  1240. f"V4 decision contains legacy fields: {legacy_paths}",
  1241. )
  1242. for evidence in data.get("pattern_recall_evidence.jsonl", []):
  1243. summary = evidence.get("evidence_summary") or {}
  1244. if not (
  1245. isinstance(summary, dict)
  1246. and summary.get("schema_version") == V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION
  1247. ):
  1248. continue
  1249. legacy_paths = _find_legacy_field_paths(
  1250. {"evidence_summary": summary, "raw_payload": evidence.get("raw_payload")},
  1251. f"recall evidence {evidence.get('recall_evidence_id')}",
  1252. )
  1253. if legacy_paths:
  1254. _fail(
  1255. findings,
  1256. "v4_legacy_field_present",
  1257. f"V4 recall evidence contains legacy fields: {legacy_paths}",
  1258. )
  1259. def _find_legacy_field_paths(value: Any, prefix: str) -> list[str]:
  1260. if isinstance(value, dict):
  1261. paths: list[str] = []
  1262. for key, child in value.items():
  1263. child_path = f"{prefix}.{key}"
  1264. if key in V4_LEGACY_FIELD_BLOCKLIST:
  1265. paths.append(child_path)
  1266. paths.extend(_find_legacy_field_paths(child, child_path))
  1267. return paths
  1268. if isinstance(value, list):
  1269. paths = []
  1270. for index, child in enumerate(value):
  1271. paths.extend(_find_legacy_field_paths(child, f"{prefix}[{index}]"))
  1272. return paths
  1273. return []
  1274. def _is_number(value: Any) -> bool:
  1275. return isinstance(value, (int, float)) and not isinstance(value, bool)
  1276. def _v4_values_equal(left: Any, right: Any) -> bool:
  1277. if _is_number(left) and _is_number(right):
  1278. return abs(float(left) - float(right)) <= 0.01
  1279. return left == right
  1280. def _result(run_id: str, findings: list[dict[str, Any]]) -> dict[str, Any]:
  1281. return {
  1282. "run_id": run_id,
  1283. "status": "fail" if any(finding["level"] == "fail" for finding in findings) else "pass",
  1284. "findings": findings,
  1285. }