eduevidence 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
@@ -0,0 +1,617 @@
1
+ #!/usr/bin/env python3
2
+ """charts_data.py — The ONLY numeric source for Lieflat gallery charts.
3
+
4
+ Every extractor reads result.json and returns a normalized, JSON-serializable
5
+ data bundle whose values can all be traced back to result.json. When the data
6
+ is insufficient for an honest encoding, the extractor returns (None, reason)
7
+ and the caller suppresses the chart (mirrors the Meaningful Visualization Gate).
8
+
9
+ Honesty rules (per the Lieflat Charts codex):
10
+ - Never invent units or per-record values that do not exist in result.json.
11
+ - Declare derived statistics in the bundle so renderers can state them in
12
+ the subtitle (e.g. position = net direction share).
13
+ - Area encodings receive raw values; renderers apply sqrt, never here.
14
+
15
+ Bundles are language-neutral (raw keys); `lang` only affects display labels.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any, Optional
21
+
22
+ from zh_labels import label as _label, OUTCOME_ZH as _OUTCOME_ZH, OUTCOME_EN as _OUTCOME_EN, STUDY_ZH as _STUDY_ZH
23
+
24
+ # Methodology audit item display labels (local copy — charts_data must not
25
+ # import build_report, which imports the renderers that import this module).
26
+ _AUDIT_ZH = {
27
+ "control_group": "对照组", "randomization": "随机分配", "pre_test": "前测",
28
+ "post_test": "后测", "retention_test": "保持测试", "transfer_test": "迁移测试",
29
+ "sample_bias": "样本偏差", "self_selection": "自我选择偏差",
30
+ "measurement_validity": "测量效度", "confounders": "混杂因素",
31
+ "instructor_effect": "教师效应", "novelty_effect": "新奇效应",
32
+ "tool_version_effect": "工具版本效应", "ai_usage_policy": "AI 使用规则",
33
+ "dropout": "样本流失",
34
+ }
35
+ _AUDIT_EN = {
36
+ "control_group": "Control group", "randomization": "Randomization", "pre_test": "Pre-test",
37
+ "post_test": "Post-test", "retention_test": "Retention test", "transfer_test": "Transfer test",
38
+ "sample_bias": "Sample bias", "self_selection": "Self-selection",
39
+ "measurement_validity": "Measurement validity", "confounders": "Confounders",
40
+ "instructor_effect": "Instructor effect", "novelty_effect": "Novelty effect",
41
+ "tool_version_effect": "Tool version effect", "ai_usage_policy": "AI usage policy",
42
+ "dropout": "Dropout",
43
+ }
44
+
45
+ _PHASE_RANGE_EN = re.compile(r"weeks?\s*(\d+)\s*[-–—]\s*(\d+)", re.IGNORECASE)
46
+ _PHASE_RANGE_ZH = re.compile(r"第\s*(\d+)\s*[-–—]\s*(\d+)\s*周")
47
+ _SPAN_RE = re.compile(r"(\d+)\s*[-–—]?\s*week", re.IGNORECASE)
48
+
49
+
50
+ def outcome_label(value: str, lang: str) -> str:
51
+ table = _OUTCOME_ZH if lang == "zh" else _OUTCOME_EN
52
+ return table.get(value, value)
53
+
54
+
55
+ def audit_label(key: str, lang: str) -> str:
56
+ table = _AUDIT_ZH if lang == "zh" else _AUDIT_EN
57
+ return table.get(key, key.replace("_", " ").title())
58
+
59
+
60
+ def _num(value: Any) -> Optional[float]:
61
+ """Coerce a scalar to float; None for anything non-numeric."""
62
+ if value is None or isinstance(value, bool):
63
+ return None
64
+ try:
65
+ f = float(value)
66
+ except (TypeError, ValueError):
67
+ return None
68
+ if f != f or f in (float("inf"), float("-inf")): # NaN guard
69
+ return None
70
+ return f
71
+
72
+
73
+ def _effect_of(ev: dict) -> Optional[dict]:
74
+ """Return the effect-size dict of an evidence item, if numeric."""
75
+ es = ev.get("effect_size")
76
+ if isinstance(es, dict):
77
+ g = _num(es.get("value"))
78
+ if g is None:
79
+ return None
80
+ return {
81
+ "g": g,
82
+ "ci_lower": _num(es.get("ci_lower")),
83
+ "ci_upper": _num(es.get("ci_upper")),
84
+ "p_value": _num(es.get("p_value")),
85
+ "metric": es.get("metric") or "effect size",
86
+ }
87
+ g = _num(es)
88
+ if g is None:
89
+ return None
90
+ return {"g": g, "ci_lower": None, "ci_upper": None, "p_value": None, "metric": "effect size"}
91
+
92
+
93
+ def _short_label(ev: dict) -> str:
94
+ """Short human label for a study: author surname first, else ID."""
95
+ raw = ev.get("study_label") or ev.get("study_id") or ev.get("evidence_id") or "study"
96
+ text = str(raw).strip()
97
+ if "(" in text:
98
+ text = text.split("(")[0]
99
+ if "," in text and re.match(r"^[A-Za-z]", text):
100
+ text = text.split(",")[0]
101
+ return text.strip()[:24]
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # meta.forest — forest plot (Hedges' g + CI per study, optional pooled)
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def extract_meta_forest(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
109
+ studies: list[dict] = []
110
+ meta = result.get("meta") or {}
111
+ forest = meta.get("forest") or {}
112
+ fpd = forest.get("forest_plot_data") if isinstance(forest, dict) else None
113
+ if isinstance(fpd, list) and fpd:
114
+ for row in fpd:
115
+ if not isinstance(row, dict):
116
+ continue
117
+ g = _num(row.get("effect_size") if isinstance(row.get("effect_size"), (int, float))
118
+ else (row.get("effect_size") or {}).get("value"))
119
+ if g is None:
120
+ continue
121
+ studies.append({
122
+ "label": str(row.get("study_label") or "study"),
123
+ "dimension": str(row.get("outcome_dimension") or row.get("outcome_type") or "GENERAL"),
124
+ "g": g,
125
+ "ci_lower": _num(row.get("ci_lower")),
126
+ "ci_upper": _num(row.get("ci_upper")),
127
+ "n": _num(row.get("sample_size")),
128
+ "wwc": row.get("wwc_rating"),
129
+ })
130
+ else:
131
+ for ev in result.get("evidence") or []:
132
+ if not isinstance(ev, dict):
133
+ continue
134
+ fx = _effect_of(ev)
135
+ if fx is None:
136
+ continue
137
+ studies.append({
138
+ "label": _short_label(ev),
139
+ "dimension": str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL"),
140
+ "g": fx["g"],
141
+ "ci_lower": fx["ci_lower"],
142
+ "ci_upper": fx["ci_upper"],
143
+ "n": _num(ev.get("sample_size")),
144
+ "wwc": ev.get("wwc_rating"),
145
+ })
146
+ max_studies = params.get("max_studies", 10)
147
+ studies = sorted(studies, key=lambda s: abs(s["g"]), reverse=True)[:max_studies]
148
+ pooled = None
149
+ if isinstance(forest, dict):
150
+ pv = forest.get("pooled_effect") or forest.get("summary")
151
+ if isinstance(pv, dict):
152
+ g = _num(pv.get("value") if "value" in pv else pv.get("g"))
153
+ if g is not None:
154
+ pooled = {"g": g, "ci_lower": _num(pv.get("ci_lower")), "ci_upper": _num(pv.get("ci_upper"))}
155
+ if len(studies) < 3:
156
+ return None, f"fewer than 3 studies with numeric effect size (got {len(studies)})"
157
+ return {
158
+ "studies": studies,
159
+ "pooled": pooled,
160
+ "unit": "Hedges' g",
161
+ "label_zh": "效应量森林图", "label_en": "Effect-size forest plot",
162
+ }, None
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # evidence.ranked_effects — L2 Dot Cascade
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def extract_ranked_effects(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
170
+ rows = []
171
+ for ev in result.get("evidence") or []:
172
+ if not isinstance(ev, dict):
173
+ continue
174
+ fx = _effect_of(ev)
175
+ if fx is None:
176
+ continue
177
+ rows.append({"label": _short_label(ev), "g": fx["g"], "n": _num(ev.get("sample_size")),
178
+ "dimension": str(ev.get("outcome_dimension") or ev.get("outcome_type") or "")})
179
+ if len(rows) < 3:
180
+ return None, f"fewer than 3 studies with numeric effect size (got {len(rows)})"
181
+ rows = sorted(rows, key=lambda r: r["g"], reverse=True)
182
+ limit = int(params.get("limit", 12))
183
+ rows = rows[:max(3, min(limit, 20))]
184
+ return {"studies": rows, "unit": "Hedges' g",
185
+ "label_zh": "效应量梯队", "label_en": "Ranked effect sizes"}, None
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # evidence.year_x_dimension — L9 Bubble Almanac
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def extract_year_x_dimension(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
193
+ years: list[str] = []
194
+ dims: list[str] = []
195
+ cells: dict[tuple[str, str], dict] = {}
196
+ for ev in result.get("evidence") or []:
197
+ if not isinstance(ev, dict):
198
+ continue
199
+ year = ev.get("year")
200
+ if year is None:
201
+ continue
202
+ y = str(year)
203
+ dim = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
204
+ if y not in years:
205
+ years.append(y)
206
+ if dim not in dims:
207
+ dims.append(dim)
208
+ cell = cells.setdefault((y, dim), {"n": 0, "sig": 0})
209
+ cell["n"] += 1
210
+ fx = _effect_of(ev)
211
+ if fx and fx.get("p_value") is not None and fx["p_value"] < 0.05:
212
+ cell["sig"] += 1
213
+ if len(years) < 2:
214
+ return None, f"fewer than 2 distinct publication years (got {len(years)})"
215
+ if len(dims) < 2:
216
+ return None, f"fewer than 2 outcome dimensions (got {len(dims)})"
217
+ years = sorted(years)
218
+ grid = []
219
+ for y in years:
220
+ for d in dims:
221
+ c = cells.get((y, d))
222
+ if c:
223
+ grid.append({"year": y, "dim": d, "n": c["n"], "sig": c["sig"]})
224
+ if len(grid) < 3:
225
+ return None, f"fewer than 3 populated year×dimension cells (got {len(grid)})"
226
+ return {"years": years, "dimensions": dims, "cells": grid,
227
+ "unit": "1 study", "label_zh": "年份 × 维度文献年历", "label_en": "Year × dimension almanac"}, None
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # evidence.grouped_distribution — G15 Jitter Strip
232
+ # ---------------------------------------------------------------------------
233
+
234
+ def extract_grouped_distribution(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
235
+ groups: dict[str, list[float]] = {}
236
+ for ev in result.get("evidence") or []:
237
+ if not isinstance(ev, dict):
238
+ continue
239
+ fx = _effect_of(ev)
240
+ if fx is None:
241
+ continue
242
+ key = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
243
+ groups.setdefault(key, []).append(fx["g"])
244
+ keep = {k: v for k, v in groups.items() if len(v) >= 3}
245
+ if len(keep) < 2:
246
+ return None, (f"need at least 2 groups with 3+ effect sizes "
247
+ f"(got {len(keep)} groups: " + ", ".join(f"{k}={len(v)}" for k, v in keep.items()) + ")")
248
+ limit = int(params.get("limit", 60))
249
+ return {"groups": [{"label": k, "values": sorted(v)[:limit]} for k, v in keep.items()],
250
+ "unit": "Hedges' g", "label_zh": "分组效应量分布", "label_en": "Grouped effect distribution"}, None
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # evidence.multidim_top — L20 Parallel Coordinates (g / N / quality / year)
255
+ # ---------------------------------------------------------------------------
256
+
257
+ def extract_multidim_top(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
258
+ rows = []
259
+ for ev in result.get("evidence") or []:
260
+ if not isinstance(ev, dict):
261
+ continue
262
+ fx = _effect_of(ev)
263
+ q = _num(ev.get("quality_score"))
264
+ y = _num(ev.get("year"))
265
+ n = _num(ev.get("sample_size"))
266
+ if fx is None or q is None or y is None or n is None:
267
+ continue
268
+ rows.append({"label": _short_label(ev), "g": fx["g"], "n": n, "quality": q, "year": y})
269
+ if len(rows) < 3:
270
+ return None, f"fewer than 3 studies with g/N/quality/year (got {len(rows)})"
271
+ rows = sorted(rows, key=lambda r: abs(r["g"]), reverse=True)
272
+ limit = int(params.get("limit", 12))
273
+ rows = rows[:max(3, min(limit, 12))]
274
+ return {
275
+ "axes": [
276
+ {"key": "g", "label_zh": "效应量 g", "label_en": "Effect g"},
277
+ {"key": "n", "label_zh": "样本量 N", "label_en": "Sample N"},
278
+ {"key": "quality", "label_zh": "质量分", "label_en": "Quality"},
279
+ {"key": "year", "label_zh": "发表年份", "label_en": "Year"},
280
+ ],
281
+ "rows": rows,
282
+ "label_zh": "跨维度平行坐标", "label_en": "Parallel coordinates",
283
+ }, None
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # evidence.study_type_composition / evidence.wwc_composition — L14 / F4
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def _composition_counts(result: dict, measure: str) -> list[dict]:
291
+ counts: dict[str, int] = {}
292
+ for ev in result.get("evidence") or []:
293
+ if not isinstance(ev, dict):
294
+ continue
295
+ if measure == "wwc":
296
+ value = ev.get("wwc_rating")
297
+ else:
298
+ value = ev.get("study_type") or ev.get("study_design")
299
+ if value in (None, ""):
300
+ continue
301
+ counts[str(value)] = counts.get(str(value), 0) + 1
302
+ return [{"label": k, "count": v} for k, v in sorted(counts.items(), key=lambda kv: -kv[1])]
303
+
304
+
305
+ def extract_study_type_composition(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
306
+ cats = _composition_counts(result, "study_type")
307
+ if len(cats) < 2:
308
+ return None, f"fewer than 2 study types recorded (got {len(cats)})"
309
+ total = sum(c["count"] for c in cats)
310
+ if total < 3:
311
+ return None, f"fewer than 3 studies with study type (got {total})"
312
+ return {"categories": cats, "total": total, "unit": "1 study",
313
+ "label_zh": "研究类型构成", "label_en": "Study type composition"}, None
314
+
315
+
316
+ def extract_wwc_composition(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
317
+ cats = _composition_counts(result, "wwc")
318
+ if len(cats) < 2:
319
+ return None, f"fewer than 2 WWC ratings recorded (got {len(cats)})"
320
+ total = sum(c["count"] for c in cats)
321
+ if total < 3:
322
+ return None, f"fewer than 3 studies with WWC rating (got {total})"
323
+ return {"categories": cats, "total": total, "unit": "1 study",
324
+ "label_zh": "WWC 评级构成", "label_en": "WWC rating composition"}, None
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # outcomes.direction_counts — F5 Tick Rows / F1 Rung Bars
329
+ # ---------------------------------------------------------------------------
330
+
331
+ def extract_direction_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
332
+ rows = []
333
+ for o in result.get("outcomes") or []:
334
+ if not isinstance(o, dict):
335
+ continue
336
+ pos = int(o.get("positive_count") or 0)
337
+ neg = int(o.get("negative_count") or 0)
338
+ nul = int(o.get("null_count") or 0)
339
+ if pos + neg + nul <= 0:
340
+ continue
341
+ key = str(o.get("outcome_type") or f"outcome-{len(rows)}")
342
+ rows.append({"label": key,
343
+ "label_zh": outcome_label(key, "zh"), "label_en": outcome_label(key, "en"),
344
+ "positive": pos, "negative": neg, "null": nul})
345
+ wanted = params.get("outcomes")
346
+ if isinstance(wanted, list) and wanted:
347
+ rows = [r for r in rows if r["label"] in [str(w) for w in wanted]]
348
+ if len(rows) < 3:
349
+ return None, f"fewer than 3 outcomes with effect counts (got {len(rows)})"
350
+ return {"rows": rows, "unit": "1 evidence item",
351
+ "label_zh": "结果效应方向分布", "label_en": "Outcome direction counts"}, None
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # outcomes.paired_counts — F6 Paired Rungs
356
+ # ---------------------------------------------------------------------------
357
+
358
+ def extract_paired_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
359
+ bundle, reason = extract_direction_counts(result, params, lang)
360
+ if bundle is None:
361
+ return None, reason
362
+ rows = [{"label": r["label"], "label_zh": r["label_zh"], "label_en": r["label_en"],
363
+ "positive": r["positive"], "negative": r["negative"]} for r in bundle["rows"]]
364
+ if sum(1 for r in rows if r["positive"] or r["negative"]) < 2:
365
+ return None, "fewer than 2 outcomes with a positive or negative count"
366
+ return {"rows": rows, "unit": "1 evidence item",
367
+ "label_zh": "正向 vs 负向配对", "label_en": "Positive vs negative pairs"}, None
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # outcomes.bipolar_axes — L7 Brand Spectrum (position = net direction share)
372
+ # ---------------------------------------------------------------------------
373
+
374
+ def extract_bipolar_axes(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
375
+ bundle, reason = extract_direction_counts(result, params, lang)
376
+ if bundle is None:
377
+ return None, reason
378
+ axes = []
379
+ for r in bundle["rows"]:
380
+ total = r["positive"] + r["negative"]
381
+ if total <= 0:
382
+ continue
383
+ net = (r["positive"] - r["negative"]) / total # -1 .. +1
384
+ axes.append({
385
+ "label": r["label"], "label_zh": r["label_zh"], "label_en": r["label_en"],
386
+ "position": round(0.5 + net / 2.0, 4), # 0 .. 1 bipolar position
387
+ "net": round(net, 4),
388
+ "positive": r["positive"], "negative": r["negative"], "null": r["null"],
389
+ })
390
+ if len(axes) < 2:
391
+ return None, f"fewer than 2 outcomes with direction counts (got {len(axes)})"
392
+ return {"axes": axes,
393
+ "left_zh": "负向主导", "left_en": "Negative-led",
394
+ "right_zh": "正向主导", "right_en": "Positive-led",
395
+ "derived": "position = (positive − negative) ÷ total direction counts",
396
+ "label_zh": "结果双极光谱", "label_en": "Outcome bipolar spectrum"}, None
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # intervention.phase_weeks — L3 Barcode Lollipop (phase membership per week)
401
+ # ---------------------------------------------------------------------------
402
+
403
+ def _phase_ranges(result: dict) -> list[dict]:
404
+ """Parse phase_1..phase_4 week ranges from phase names (zh + en)."""
405
+ inter = result.get("intervention") or {}
406
+ phases = []
407
+ for i in range(1, 5):
408
+ ph = inter.get(f"phase_{i}")
409
+ if not isinstance(ph, dict):
410
+ continue
411
+ name = str(ph.get("name") or "")
412
+ m = _PHASE_RANGE_ZH.search(name) or _PHASE_RANGE_EN.search(name)
413
+ start, end = (int(m.group(1)), int(m.group(2))) if m else (None, None)
414
+ if start is None or end is None or end < start or end - start > 60:
415
+ continue
416
+ phases.append({"index": i, "start": start, "end": end, "name": name,
417
+ "activities": ph.get("activities") or []})
418
+ return phases
419
+
420
+
421
+ def extract_phase_weeks(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
422
+ phases = _phase_ranges(result)
423
+ if len(phases) < 3:
424
+ return None, f"fewer than 3 phases with parseable week ranges (got {len(phases)})"
425
+ first = min(p["start"] for p in phases)
426
+ last = max(p["end"] for p in phases)
427
+ span = last - first + 1
428
+ if span < 8:
429
+ return None, f"phase span too short for a barcode ({span} weeks)"
430
+ weeks = []
431
+ for week in range(first, last + 1):
432
+ owner = next((p["index"] for p in phases if p["start"] <= week <= p["end"]), 0)
433
+ weeks.append({"week": week, "phase": owner})
434
+ peaks = [{"week": p["start"], "phase": p["index"],
435
+ "label_zh": f"第{p['index']}阶段开始", "label_en": f"Phase {p['index']} starts"}
436
+ for p in phases]
437
+ phase_labels = [
438
+ {"phase": p["index"], "label_zh": f"第{p['index']}阶段", "label_en": f"Phase {p['index']}",
439
+ "start": p["start"], "end": p["end"]}
440
+ for p in phases
441
+ ]
442
+ return {
443
+ "weeks": weeks, "peaks": peaks, "phases": phase_labels, "first": first, "last": last,
444
+ "derived": "stem height = phase index (1–4); no per-week activity data exists in result.json",
445
+ "label_zh": "16 周阶段归属", "label_en": "Weekly phase membership",
446
+ }, None
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # intervention.activity_weights — L1 Launch Fan (weight = activity count)
451
+ # ---------------------------------------------------------------------------
452
+
453
+ def extract_activity_weights(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
454
+ phases = _phase_ranges(result)
455
+ if not phases:
456
+ # fall back to phase dicts without ranges (still honest: weight = activity count)
457
+ inter = result.get("intervention") or {}
458
+ phases = []
459
+ for i in range(1, 5):
460
+ ph = inter.get(f"phase_{i}")
461
+ if isinstance(ph, dict):
462
+ phases.append({"index": i, "start": None, "end": None,
463
+ "name": str(ph.get("name") or ""), "activities": ph.get("activities") or []})
464
+ items = []
465
+ for p in phases:
466
+ w = len(p.get("activities") or [])
467
+ if w <= 0:
468
+ continue
469
+ name = p["name"].split(":")[0].split("(")[0].strip()
470
+ items.append({"label": name[:16] or f"phase {p['index']}", "w": w, "index": p["index"]})
471
+ if len(items) < 3:
472
+ return None, f"fewer than 3 phases with activities (got {len(items)})"
473
+ max_items = int(params.get("max_items", 8))
474
+ items = items[:max_items]
475
+ return {"items": items, "unit": "1 activity",
476
+ "derived": "head dot area = number of activities listed for the phase",
477
+ "label_zh": "阶段活动权重扇", "label_en": "Phase activity fan"}, None
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # intervention.phase_groups — L8 Dotty Matrix (1 cell = 1 activity per phase)
482
+ # ---------------------------------------------------------------------------
483
+
484
+ def extract_phase_groups(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
485
+ phases = _phase_ranges(result)
486
+ layers = []
487
+ for p in phases:
488
+ acts = p.get("activities") or []
489
+ if len(acts) < 2:
490
+ continue
491
+ cells = [{"r": i % 6, "c": i // 6} for i in range(min(len(acts), 36))]
492
+ name = p["name"].split(":")[0].split("(")[0].strip()
493
+ layers.append({"label": name[:16] or f"phase {p['index']}", "cells": cells, "index": p["index"]})
494
+ if len(layers) < 3:
495
+ return None, f"fewer than 3 phases with 2+ activities (got {len(layers)})"
496
+ return {"layers": layers, "unit": "1 activity",
497
+ "derived": "one dot = one listed activity; intensity is uniform (no mastery data)",
498
+ "label_zh": "阶段活动点阵", "label_en": "Phase activity matrix"}, None
499
+
500
+
501
+ # ---------------------------------------------------------------------------
502
+ # decision.confidence_score — F11 Tick Gauge
503
+ # ---------------------------------------------------------------------------
504
+
505
+ def extract_confidence_score(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
506
+ decision = result.get("decision") or {}
507
+ score = _num(decision.get("confidence_score"))
508
+ if score is None:
509
+ return None, "decision.confidence_score missing or non-numeric"
510
+ score = max(0.0, min(1.0, score))
511
+ return {"score": round(score, 4), "label": str(decision.get("confidence") or ""),
512
+ "label_zh": "决策置信度", "label_en": "Decision confidence"}, None
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # methodology.flag_rates — L15 Ballot Tally (1 tick = 1 audit verdict)
517
+ # ---------------------------------------------------------------------------
518
+
519
+ def extract_flag_rates(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
520
+ # tally per audit item: how many review entries mark it below "met"
521
+ tally: dict[str, dict] = {}
522
+ for review in result.get("methodology_reviews") or []:
523
+ if not isinstance(review, dict):
524
+ continue
525
+ items = review.get("audit_items") or {}
526
+ if not isinstance(items, dict):
527
+ continue
528
+ for key, entry in items.items():
529
+ status = (entry or {}).get("status") if isinstance(entry, dict) else None
530
+ if status is None:
531
+ continue
532
+ slot = tally.setdefault(key, {"total": 0, "flagged": 0})
533
+ slot["total"] += 1
534
+ if str(status).lower() not in ("met", "pass", "passed", "satisfied"):
535
+ slot["flagged"] += 1
536
+ items = [{"key": k, "total": v["total"], "flagged": v["flagged"]} for k, v in tally.items()]
537
+ items = [i for i in items if i["total"] >= 1]
538
+ if len(items) < 3 or not any(i["flagged"] for i in items):
539
+ return None, (f"audit tally insufficient for a ballot ({len(items)} items, "
540
+ f"{sum(i['flagged'] for i in items)} flagged)")
541
+ items = sorted(items, key=lambda i: (-i["flagged"] / i["total"], -i["total"]))
542
+ out = []
543
+ for i in items:
544
+ out.append({"label": i["key"],
545
+ "label_zh": audit_label(i["key"], "zh"), "label_en": audit_label(i["key"], "en"),
546
+ "total": i["total"], "flagged": i["flagged"]})
547
+ return {"items": out, "unit": "1 audit verdict",
548
+ "derived": "one tick = one methodology-review verdict; filled = below 'met'",
549
+ "label_zh": "审计未达标计票", "label_en": "Audit flag tally"}, None
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # evidence.year_x_outcome_counts — L16 Matrix Heat
554
+ # ---------------------------------------------------------------------------
555
+
556
+ def extract_year_x_outcome_counts(result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
557
+ years: list[str] = []
558
+ outcomes: list[str] = []
559
+ counts: dict[tuple[str, str], int] = {}
560
+ for ev in result.get("evidence") or []:
561
+ if not isinstance(ev, dict):
562
+ continue
563
+ year = ev.get("year")
564
+ if year is None:
565
+ continue
566
+ y = str(year)
567
+ o = str(ev.get("outcome_dimension") or ev.get("outcome_type") or "GENERAL")
568
+ if y not in years:
569
+ years.append(y)
570
+ if o not in outcomes:
571
+ outcomes.append(o)
572
+ counts[(y, o)] = counts.get((y, o), 0) + 1
573
+ if len(years) < 2:
574
+ return None, f"fewer than 2 years (got {len(years)})"
575
+ if len(outcomes) < 2:
576
+ return None, f"fewer than 2 outcomes (got {len(outcomes)})"
577
+ years = sorted(years)
578
+ cells = [[counts.get((y, o), 0) for y in years] for o in outcomes]
579
+ return {"years": years,
580
+ "outcomes": [{"label": o, "label_zh": outcome_label(o, "zh"), "label_en": outcome_label(o, "en")}
581
+ for o in outcomes],
582
+ "cells": cells, "unit": "1 study",
583
+ "label_zh": "年份 × 结果计数矩阵", "label_en": "Year × outcome count matrix"}, None
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # Dispatcher — used by lieflat_engine.REGISTRY
588
+ # ---------------------------------------------------------------------------
589
+
590
+ EXTRACTORS = {
591
+ "meta.forest": extract_meta_forest,
592
+ "evidence.ranked_effects": extract_ranked_effects,
593
+ "evidence.year_x_dimension": extract_year_x_dimension,
594
+ "evidence.grouped_distribution": extract_grouped_distribution,
595
+ "evidence.multidim_top": extract_multidim_top,
596
+ "evidence.study_type_composition": extract_study_type_composition,
597
+ "evidence.wwc_composition": extract_wwc_composition,
598
+ "outcomes.direction_counts": extract_direction_counts,
599
+ "outcomes.paired_counts": extract_paired_counts,
600
+ "outcomes.bipolar_axes": extract_bipolar_axes,
601
+ "intervention.phase_weeks": extract_phase_weeks,
602
+ "intervention.activity_weights": extract_activity_weights,
603
+ "intervention.phase_groups": extract_phase_groups,
604
+ "decision.confidence_score": extract_confidence_score,
605
+ "methodology.flag_rates": extract_flag_rates,
606
+ "evidence.year_x_outcome_counts": extract_year_x_outcome_counts,
607
+ }
608
+
609
+
610
+ def run_extractor(source: str, result: dict, params: dict, lang: str = "en") -> tuple[Optional[dict], Optional[str]]:
611
+ fn = EXTRACTORS.get(source)
612
+ if fn is None:
613
+ return None, f"unknown extractor source {source!r}"
614
+ try:
615
+ return fn(result, params or {}, lang)
616
+ except Exception as exc: # extractors must never crash the report
617
+ return None, f"extractor error: {type(exc).__name__}: {exc}"