eduevidence 5.2.0 → 6.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 (386) hide show
  1. package/CONTRIBUTING.md +105 -0
  2. package/README.md +142 -75
  3. package/README.zh-CN.md +73 -30
  4. package/SKILL.md +397 -131
  5. package/agents/openai.yaml +4 -0
  6. package/assets/readme/controlled-execution.svg +34 -0
  7. package/assets/readme/landing-tour.gif +0 -0
  8. package/assets/readme/logo.png +0 -0
  9. package/assets/readme/research-workflow.svg +56 -0
  10. package/assets/readme/studio-graph.png +0 -0
  11. package/assets/readme/studio-overview.png +0 -0
  12. package/assets/readme/studio-reports.png +0 -0
  13. package/assets/readme/studio-tour.gif +0 -0
  14. package/autoevolve/config.yaml +17 -0
  15. package/autoevolve/program.md +25 -0
  16. package/autoevolve/protected.manifest.yaml +34 -0
  17. package/benchmarks/adversarial/cases.jsonl +7 -0
  18. package/benchmarks/evidence-library.json +5268 -0
  19. package/benchmarks/partitions.json +8 -0
  20. package/bin/eduevidence.js +2 -1
  21. package/docs/architecture.md +496 -0
  22. package/docs/autoresearch-evolution-plan.md +2903 -0
  23. package/docs/autoresearch-implementation-status.md +101 -0
  24. package/docs/demo-storyboard.md +20 -0
  25. package/docs/demo-workplace-ai.md +92 -0
  26. package/docs/demo.md +32 -0
  27. package/docs/install-guide.md +150 -0
  28. package/docs/orchestration-role-model.md +1254 -0
  29. package/docs/release-closeout/README.md +17 -0
  30. package/docs/release-closeout/frontend-acceptance.md +23 -0
  31. package/docs/release-closeout/issues.md +19 -0
  32. package/docs/release-closeout/verification.md +28 -0
  33. package/docs/release-contract.md +108 -0
  34. package/docs/research-studio-guide.zh-CN.md +166 -0
  35. package/docs/sciverse-api.md +125 -0
  36. package/eduevidence_cli.py +29 -13
  37. package/engine/_resources.py +13 -0
  38. package/engine/autoevolve/__init__.py +3 -0
  39. package/engine/autoevolve/agent_view.py +167 -0
  40. package/engine/autoevolve/core.py +357 -0
  41. package/engine/autoevolve/events.py +11 -0
  42. package/engine/autoevolve/git_workspace.py +77 -0
  43. package/engine/autoevolve/projection.py +23 -0
  44. package/engine/autoevolve/runner.py +413 -0
  45. package/engine/autoevolve/trust.py +146 -0
  46. package/engine/autoresearch/__init__.py +6 -0
  47. package/engine/autoresearch/commit.py +132 -0
  48. package/engine/autoresearch/contracts.py +126 -0
  49. package/engine/autoresearch/controller.py +207 -0
  50. package/engine/autoresearch/events.py +12 -0
  51. package/engine/autoresearch/gap_priority.py +168 -0
  52. package/engine/autoresearch/projection.py +30 -0
  53. package/engine/autoresearch/research_memory.py +59 -0
  54. package/engine/autoresearch/saturation.py +91 -0
  55. package/engine/briefs.py +2 -1
  56. package/engine/capabilities.py +1 -0
  57. package/engine/contracts.py +3 -1
  58. package/engine/decision_policy.py +96 -0
  59. package/engine/evidence_graph.py +14 -10
  60. package/engine/evidencecore.py +7 -5
  61. package/engine/gaps.py +132 -73
  62. package/engine/ids.py +2 -0
  63. package/engine/judge_pack.py +65 -0
  64. package/engine/library.py +6 -2
  65. package/engine/library_builtin.py +3 -1
  66. package/engine/living.py +36 -5
  67. package/engine/meta_synthesis.py +3 -1
  68. package/engine/migration.py +88 -3
  69. package/engine/orchestration.py +460 -0
  70. package/engine/paths.py +2 -0
  71. package/engine/pilot.py +36 -33
  72. package/engine/project.py +2 -2
  73. package/engine/research_service.py +113 -0
  74. package/engine/studio_read_model.py +400 -0
  75. package/engine/taxonomy.py +211 -0
  76. package/engine/tribunal.py +44 -33
  77. package/engine/update.py +1 -0
  78. package/engine/versions.py +1 -1
  79. package/engine/worker_result.py +109 -0
  80. package/engine/workflows.py +70 -0
  81. package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +2934 -0
  82. package/examples/ai-coding-assistant-evidence/artifact_manifest.json +15 -0
  83. package/examples/ai-coding-assistant-evidence/citation_check.json +79 -0
  84. package/examples/ai-coding-assistant-evidence/claims.jsonl +12 -0
  85. package/examples/ai-coding-assistant-evidence/evaluation.json +35 -0
  86. package/examples/ai-coding-assistant-evidence/evidence.jsonl +12 -0
  87. package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
  88. package/examples/ai-coding-assistant-evidence/frame.json +48 -0
  89. package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
  90. package/examples/ai-coding-assistant-evidence/intervention.json +51 -0
  91. package/examples/ai-coding-assistant-evidence/methodology.json +36 -0
  92. package/examples/ai-coding-assistant-evidence/raw_verdict.json +86 -0
  93. package/examples/ai-coding-assistant-evidence/report_spec.json +230 -0
  94. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +2934 -0
  95. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +2934 -0
  96. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +2934 -0
  97. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +2934 -0
  98. package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +2934 -0
  99. package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +2934 -0
  100. package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +2934 -0
  101. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +2934 -0
  102. package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +2934 -0
  103. package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +2934 -0
  104. package/examples/ai-coding-assistant-evidence/result.json +1457 -0
  105. package/examples/ai-coding-assistant-evidence/result.zh.json +1457 -0
  106. package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
  107. package/examples/ai-coding-assistant-evidence/sources.jsonl +8 -0
  108. package/examples/ai-coding-assistant-evidence/verdict.json +107 -0
  109. package/examples/spaced-retrieval-practice/applicability.json +14 -0
  110. package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
  111. package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
  112. package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
  113. package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
  114. package/examples/spaced-retrieval-practice/frame.json +58 -0
  115. package/examples/spaced-retrieval-practice/gate_report.json +101 -0
  116. package/examples/spaced-retrieval-practice/methodology.json +78 -0
  117. package/examples/spaced-retrieval-practice/report_spec.json +212 -0
  118. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
  119. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
  120. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
  121. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
  122. package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
  123. package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
  124. package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
  125. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
  126. package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
  127. package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
  128. package/examples/spaced-retrieval-practice/result.json +942 -0
  129. package/examples/spaced-retrieval-practice/result.zh.json +942 -0
  130. package/examples/spaced-retrieval-practice/skeptic.json +70 -0
  131. package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
  132. package/examples/spaced-retrieval-practice/verdict.json +93 -0
  133. package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
  134. package/examples/workplace-ai-assistant/claims.jsonl +4 -0
  135. package/examples/workplace-ai-assistant/evaluation.json +19 -0
  136. package/examples/workplace-ai-assistant/evidence.jsonl +4 -0
  137. package/examples/workplace-ai-assistant/evidence_graph.json +444 -0
  138. package/examples/workplace-ai-assistant/final_verdict.json +78 -0
  139. package/examples/workplace-ai-assistant/frame.json +41 -0
  140. package/examples/workplace-ai-assistant/gate_report.json +101 -0
  141. package/examples/workplace-ai-assistant/intervention.json +27 -0
  142. package/examples/workplace-ai-assistant/legacy-link-check.json +16 -0
  143. package/examples/workplace-ai-assistant/methodology.json +60 -0
  144. package/examples/workplace-ai-assistant/report_spec.json +224 -0
  145. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +2814 -0
  146. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +2814 -0
  147. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +2814 -0
  148. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +2814 -0
  149. package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +2814 -0
  150. package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
  151. package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
  152. package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
  153. package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
  154. package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
  155. package/examples/workplace-ai-assistant/result.json +615 -0
  156. package/examples/workplace-ai-assistant/result.zh.json +615 -0
  157. package/examples/workplace-ai-assistant/search_log.json +19 -0
  158. package/examples/workplace-ai-assistant/skeptic.json +72 -0
  159. package/examples/workplace-ai-assistant/sources.jsonl +3 -0
  160. package/examples/workplace-ai-assistant/validation_result.json +9 -0
  161. package/examples/workplace-ai-assistant/verdict.json +78 -0
  162. package/install.sh +7 -7
  163. package/integrations/agent_mcp.py +2 -2
  164. package/integrations/orchestration_dispatch.py +146 -0
  165. package/package.json +46 -3
  166. package/pyproject.toml +14 -22
  167. package/references/autoresearch.md +30 -0
  168. package/references/evaluation-policy.md +24 -0
  169. package/references/orchestration.md +22 -0
  170. package/references/report-copy-style.md +67 -0
  171. package/references/retrieval-compliance.md +75 -0
  172. package/references/retrieval-protocol.md +20 -0
  173. package/references/scientific-invariants.md +19 -0
  174. package/retrieval/audit.py +178 -0
  175. package/retrieval/fetch.py +96 -0
  176. package/retrieval/sciverse.py +398 -0
  177. package/retrieval/search.py +47 -7
  178. package/schemas/applicability.schema.json +94 -0
  179. package/schemas/chart-spec.schema.json +10 -3
  180. package/schemas/evidence.schema.json +316 -43
  181. package/schemas/fetch-result.schema.json +2 -1
  182. package/schemas/intervention.schema.json +106 -21
  183. package/schemas/report-result.schema.json +12 -4
  184. package/schemas/report-spec.schema.json +98 -100
  185. package/schemas/skeptic.schema.json +86 -0
  186. package/schemas/source.schema.json +21 -2
  187. package/schemas/v2/finding.schema.json +5 -1
  188. package/schemas/v2/methodology-audit.schema.json +5 -1
  189. package/schemas/v2/outcome.schema.json +28 -5
  190. package/schemas/v2/project.schema.json +2 -2
  191. package/schemas/v2/run.schema.json +1 -1
  192. package/schemas/v2/study.schema.json +5 -1
  193. package/schemas/vNext/autoevolve-session.schema.json +34 -0
  194. package/schemas/vNext/eval-snapshot.schema.json +77 -0
  195. package/schemas/vNext/execution-plan.schema.json +50 -0
  196. package/schemas/vNext/gap-priority.schema.json +54 -0
  197. package/schemas/vNext/negative-search-record.schema.json +68 -0
  198. package/schemas/vNext/research-iteration.schema.json +87 -0
  199. package/schemas/vNext/research-strategy.schema.json +62 -0
  200. package/schemas/vNext/skill-experiment.schema.json +90 -0
  201. package/schemas/vNext/task-spec.schema.json +156 -0
  202. package/schemas/vNext/worker-result.schema.json +60 -0
  203. package/schemas/verdict.schema.json +164 -28
  204. package/scripts/benchmark_judge.py +2 -2
  205. package/scripts/benchmark_v3.py +26 -43
  206. package/scripts/build_esl_artifacts.py +4 -4
  207. package/scripts/build_evidence_library.py +2 -2
  208. package/scripts/build_gh_pages.py +98 -0
  209. package/scripts/build_readme_diagrams.py +72 -0
  210. package/scripts/build_report_variants.py +101 -0
  211. package/scripts/build_result.py +74 -9
  212. package/scripts/check_autoresearch_invariants.py +95 -0
  213. package/scripts/check_package_parity.py +85 -0
  214. package/scripts/check_protocol_alignment.py +375 -0
  215. package/scripts/check_versioned_schemas.py +254 -0
  216. package/scripts/claim_audit.py +13 -8
  217. package/scripts/compute_confidence.py +10 -0
  218. package/scripts/daily_evolve.py +30 -0
  219. package/scripts/dashboard_server.py +130 -101
  220. package/scripts/did_regression.py +17 -32
  221. package/scripts/enrich_projects_human_and_lieflat.py +1 -1
  222. package/scripts/evidence_score.py +5 -2
  223. package/scripts/generate_metrics.py +4 -3
  224. package/scripts/generate_new_projects.py +5 -5
  225. package/scripts/orchestrator.py +286 -36
  226. package/scripts/pre_verdict_gate.py +224 -26
  227. package/scripts/quickstart.py +18 -2
  228. package/scripts/rebake_all_5themes.py +1 -2
  229. package/scripts/research_auto_cli.py +475 -0
  230. package/scripts/run_workspace.py +24 -8
  231. package/scripts/search_provenance.py +64 -0
  232. package/scripts/serve_web.py +9 -10
  233. package/scripts/skill_lint.py +1 -1
  234. package/scripts/skill_payload.py +81 -0
  235. package/scripts/test_adversarial_empirical.py +26 -19
  236. package/scripts/validate_schema.py +46 -2
  237. package/scripts/vnext_cli.py +133 -0
  238. package/setup.py +12 -0
  239. package/skill/agents/evaluation-designer.md +20 -4
  240. package/skill/agents/evidence-analyst.md +19 -3
  241. package/skill/agents/evidence-judge.md +50 -2
  242. package/skill/agents/evidence-retriever.md +20 -3
  243. package/skill/agents/intervention-designer.md +20 -4
  244. package/skill/agents/method-reviewer.md +18 -2
  245. package/skill/agents/{education-planner.md → research-planner.md} +19 -3
  246. package/skill/agents/skeptic.md +18 -2
  247. package/skill/roles/registry.yaml +45 -0
  248. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
  249. package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
  250. package/skill/sub-skills/data-analysis/SKILL.md +34 -15
  251. package/skill/sub-skills/ethics-review/SKILL.md +33 -10
  252. package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
  253. package/skill/sub-skills/evidence-review/SKILL.md +31 -12
  254. package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
  255. package/skill/sub-skills/literature-review/SKILL.md +35 -14
  256. package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
  257. package/skill/sub-skills/report-generation/SKILL.md +40 -6
  258. package/skill/sub-skills/research-planning/SKILL.md +41 -14
  259. package/skill/sub-skills/study-design/SKILL.md +30 -9
  260. package/skill/task-briefs/adjudicate.md +32 -7
  261. package/skill/task-briefs/applicability.md +38 -0
  262. package/skill/task-briefs/audit.md +32 -7
  263. package/skill/task-briefs/challenge.md +34 -5
  264. package/skill/task-briefs/evaluate.md +30 -5
  265. package/skill/task-briefs/extract.md +31 -8
  266. package/skill/task-briefs/frame.md +39 -10
  267. package/skill/task-briefs/intervene.md +32 -6
  268. package/skill/task-briefs/present.md +32 -8
  269. package/skill/task-briefs/projection.md +37 -0
  270. package/skill/task-briefs/retrieve.md +36 -6
  271. package/skill/workflows/decision-and-pilot.md +85 -0
  272. package/skill/workflows/evaluate-and-update.md +93 -0
  273. package/skill/workflows/evidence-review.md +117 -0
  274. package/visualization/eduevidence-report/assets/base.css +2 -2
  275. package/visualization/eduevidence-report/assets/reader.css +752 -0
  276. package/visualization/eduevidence-report/assets/reader.js +132 -0
  277. package/visualization/eduevidence-report/references/chart-selection-catalog.md +109 -0
  278. package/visualization/eduevidence-report/references/lieflat-composition.md +3 -1
  279. package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
  280. package/visualization/eduevidence-report/scripts/build_infographics.py +5 -1
  281. package/visualization/eduevidence-report/scripts/build_report.py +561 -121
  282. package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
  283. package/visualization/eduevidence-report/scripts/lieflat_engine.py +371 -136
  284. package/visualization/eduevidence-report/scripts/zh_labels.py +80 -1
  285. package/visualization/eduevidence-report/themes/academic.css +1 -1
  286. package/visualization/eduevidence-report/themes/claude.css +1 -1
  287. package/visualization/eduevidence-report/themes/datalab-dark.css +2 -2
  288. package/visualization/eduevidence-report/themes/datalab.css +2 -2
  289. package/visualization/eduevidence-report/themes/presentation.css +2 -2
  290. package/web/README.md +18 -0
  291. package/web/architecture.html +14885 -0
  292. package/web/index.html +53 -0
  293. package/web/studio/THIRD_PARTY_LICENSES.txt +146 -0
  294. package/web/studio/assets/index-B8tkF44Q.css +1 -0
  295. package/web/studio/assets/index-CQ6Keoyc.js +230 -0
  296. package/web/studio/config.json +1 -0
  297. package/web/studio/index.html +14 -0
  298. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  299. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  300. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  301. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  302. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  303. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  304. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  305. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  306. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  307. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  308. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  309. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  310. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  311. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  312. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  313. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  314. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  315. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  316. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  317. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  318. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  319. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  320. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  321. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  322. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  323. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  324. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  325. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  326. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  327. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  328. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  329. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  330. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  331. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  332. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  333. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  334. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  335. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  336. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  337. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  338. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  339. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  340. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  341. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  342. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  343. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  344. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  345. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  346. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  347. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  348. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  349. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  350. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  351. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  352. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  353. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  354. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  355. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  356. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  357. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  358. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  359. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  360. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  361. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  362. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  363. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  364. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  365. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  366. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  367. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  368. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  369. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  370. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  371. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  372. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  373. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  374. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  375. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  376. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  377. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  378. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  379. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  380. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  381. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  382. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  383. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  384. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  385. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  386. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import html
24
24
  import math
25
+ import re
25
26
  from typing import Any, Dict, List, Optional, Tuple
26
27
 
27
28
  import charts_data as CD
@@ -34,9 +35,235 @@ THEME_PALETTES = {
34
35
  "presentation": {"bg": "#140A08", "card_bg": "#1C0D0A", "text": "#FBBF24", "subtext": "#D97706", "muted": "#9A3412", "grid": "#2D120B", "border": "#4A2218", "primary": "#F59E0B", "secondary": "#F24D29", "accent": "#10B981", "ribbon": "#2A140E"},
35
36
  }
36
37
 
37
- STAGGER_DOT = 12 # ms per dot (token range 8–15)
38
- STAGGER_BAR = 100 # ms per bar (token range 80–130)
38
+ STAGGER_DOT = 12
39
+ STAGGER_BAR = 100
39
40
  MIN_FONT = 6.5
41
+ LANE_MIN_H = 15.0 # a data-driven row lane never renders thinner than this
42
+ ROT45 = 0.7071067811865476 # cos/sin 45°, used by rotated tick labels
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Geometry guard — every renderer's output is measured against its own viewBox
47
+ # ---------------------------------------------------------------------------
48
+ #
49
+ # A fixed canvas plus data-driven row counts silently clips a figure: shapes get
50
+ # drawn outside the viewBox and the browser cuts them off. Renderers therefore
51
+ # size their canvas from the content (see _svg_open) and every figure is measured
52
+ # once more after rendering, so a renderer that forgets fails loudly instead of
53
+ # shipping a clipped figure into the HTML report and its PNG exports.
54
+ #
55
+ # The measurement is a pure-stdlib, deterministic parse of the emitted markup: no
56
+ # DOM, no font metrics, no I/O. Text boxes use a conservative advance-width model
57
+ # calibrated against the shipped UI stack ("Helvetica Neue", "PingFang SC",
58
+ # Arial) measured in Chrome via canvas measureText:
59
+ # CJK/full-width glyphs = 1.00 em (measured exactly 1.0)
60
+ # latin average ≈ 0.62 em ("Independent problem solving" at 11px)
61
+ # latin worst case ≈ 0.94 em (all-caps/like "WWWWWWWWWWWW" at 9px)
62
+ # so latin is charged at a cap-height upper bound rather than an average, making
63
+ # every measured box an over-estimate for ordinary labels.
64
+
65
+ CHAR_W = 0.75 # latin advance width, in em (cap-height upper bound)
66
+ CHAR_W_WIDE = 1.0 # CJK / full-width advance width, in em
67
+ LATIN_WIDE_ABOVE = 18 # font sizes above this are display numbers, not words
68
+ CHAR_W_BIG = 0.85 # wider cap-only glyphs at display sizes
69
+ ASCENT = 0.85 # baseline to glyph top, in em
70
+ DESCENT = 0.30 # baseline to glyph bottom, in em
71
+
72
+ _NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
73
+ _ATTR_RE = re.compile(r'([\w:-]+)="([^"]*)"')
74
+ _ELEM_RE = re.compile(
75
+ r"<(rect|circle|ellipse|line|polyline|polygon|path|text)\b([^>]*?)"
76
+ r"(?:/>|>(.*?)</\1>)",
77
+ re.S,
78
+ )
79
+ _TAG_RE = re.compile(r"<[^>]*>")
80
+ _TRANSLATE_RE = re.compile(r"translate\(([^)]*)\)")
81
+ _ROTATE_RE = re.compile(r"rotate\(([^)]*)\)")
82
+ _VIEWBOX_RE = re.compile(r'viewBox="([^"]+)"')
83
+
84
+
85
+ class GeometryOverflowError(ValueError):
86
+ """A renderer drew outside its own viewBox, so content would be clipped."""
87
+
88
+
89
+ def _enum(raw: str) -> List[float]:
90
+ return [float(x) for x in _NUM_RE.findall(raw or "")]
91
+
92
+
93
+ def _pairs(values: List[float]) -> List[Tuple[float, float]]:
94
+ return list(zip(values[0::2], values[1::2]))
95
+
96
+
97
+ def _unescape(raw: str) -> str:
98
+ return (raw.replace("&lt;", "<").replace("&gt;", ">")
99
+ .replace("&quot;", '"').replace("&#39;", "'").replace("&#x27;", "'")
100
+ .replace("&amp;", "&").replace("&nbsp;", " "))
101
+
102
+
103
+ def text_width(text: str, font_size: float) -> float:
104
+ """Conservative advance width of a label at a given font size."""
105
+ latin = CHAR_W_BIG if font_size >= LATIN_WIDE_ABOVE else CHAR_W
106
+ total = 0.0
107
+ for ch in _unescape(text):
108
+ total += CHAR_W_WIDE if ch > "\u2e7f" else latin
109
+ return total * font_size
110
+
111
+
112
+ def _element_boxes(svg: str):
113
+ """Yield (tag, x0, y0, x1, y1) for every element that draws ink."""
114
+ for match in _ELEM_RE.finditer(svg):
115
+ tag, raw_attrs, raw_body = match.group(1), match.group(2), match.group(3)
116
+ a = dict(_ATTR_RE.findall(raw_attrs))
117
+ box = None
118
+ if tag == "rect":
119
+ x, y = float(a.get("x", 0)), float(a.get("y", 0))
120
+ w, h = float(a.get("width", 0)), float(a.get("height", 0))
121
+ box = (x, y, x + w, y + h)
122
+ elif tag == "circle":
123
+ cx, cy, r = float(a.get("cx", 0)), float(a.get("cy", 0)), float(a.get("r", 0))
124
+ box = (cx - r, cy - r, cx + r, cy + r)
125
+ elif tag == "ellipse":
126
+ cx, cy = float(a.get("cx", 0)), float(a.get("cy", 0))
127
+ rx, ry = float(a.get("rx", 0)), float(a.get("ry", 0))
128
+ box = (cx - rx, cy - ry, cx + rx, cy + ry)
129
+ elif tag == "line":
130
+ x1, y1 = float(a.get("x1", 0)), float(a.get("y1", 0))
131
+ x2, y2 = float(a.get("x2", 0)), float(a.get("y2", 0))
132
+ box = (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
133
+ elif tag in ("polyline", "polygon"):
134
+ pts = _pairs(_enum(a.get("points", "")))
135
+ if pts:
136
+ box = (min(p[0] for p in pts), min(p[1] for p in pts),
137
+ max(p[0] for p in pts), max(p[1] for p in pts))
138
+ elif tag == "path":
139
+ # Control points over-approximate the ink box: exactly what a bound
140
+ # check wants, since a curve never leaves its control hull.
141
+ pts = []
142
+ for seg in re.finditer(r"([A-Za-z])([^A-Za-z]*)", a.get("d", "")):
143
+ if seg.group(1).upper() != "Z":
144
+ pts += _pairs(_enum(seg.group(2)))
145
+ if pts:
146
+ box = (min(p[0] for p in pts), min(p[1] for p in pts),
147
+ max(p[0] for p in pts), max(p[1] for p in pts))
148
+ elif tag == "text":
149
+ x, y = float(a.get("x", 0)), float(a.get("y", 0))
150
+ size = float(a.get("font-size", 10))
151
+ width = text_width(_TAG_RE.sub("", raw_body or ""), size)
152
+ anchor = a.get("text-anchor", "start")
153
+ x0 = x - (width if anchor == "end" else width / 2.0 if anchor == "middle" else 0.0)
154
+ box = (x0, y - size * ASCENT, x0 + width, y + size * DESCENT)
155
+ if box is None:
156
+ continue
157
+ tx0, ty0, tx1, ty1 = _apply_transform(a.get("transform", ""), box)
158
+ yield tag, tx0, ty0, tx1, ty1
159
+
160
+
161
+ def _transform_matrix(transform: str) -> Optional[Tuple[float, float, float, float, float, float]]:
162
+ """Compose an SVG transform list into a 2x3 affine matrix (a b c d e f)."""
163
+ ops = re.findall(r"(translate|rotate|scale|matrix)\(([^)]*)\)", transform or "")
164
+ if not ops:
165
+ return None
166
+ m = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
167
+
168
+ def mul(n, by):
169
+ return (by[0] * n[0] + by[2] * n[1], by[1] * n[0] + by[3] * n[1],
170
+ by[0] * n[2] + by[2] * n[3], by[1] * n[2] + by[3] * n[3],
171
+ by[0] * n[4] + by[2] * n[5] + by[4],
172
+ by[1] * n[4] + by[3] * n[5] + by[5])
173
+
174
+ for name, raw in ops:
175
+ v = _enum(raw)
176
+ if name == "translate":
177
+ dx, dy = (v + [0.0, 0.0])[:2]
178
+ op = (1.0, 0.0, 0.0, 1.0, dx, dy)
179
+ elif name == "scale":
180
+ sx = v[0] if v else 1.0
181
+ sy = v[1] if len(v) > 1 else sx
182
+ op = (sx, 0.0, 0.0, sy, 0.0, 0.0)
183
+ elif name == "matrix" and len(v) >= 6:
184
+ op = tuple(v[:6]) # type: ignore[assignment]
185
+ else: # rotate(angle [cx cy])
186
+ angle = math.radians(v[0] if v else 0.0)
187
+ cos_a, sin_a = math.cos(angle), math.sin(angle)
188
+ rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0)
189
+ ox, oy = (v[1], v[2]) if len(v) > 2 else (0.0, 0.0)
190
+ op = mul(rot, mul((1.0, 0.0, 0.0, 1.0, -ox, -oy), (1.0, 0.0, 0.0, 1.0, ox, oy)))
191
+ m = mul(op, m)
192
+ return m
193
+
194
+
195
+ def _apply_transform(transform: str, box) -> Tuple[float, float, float, float]:
196
+ """Map an element box through its transform list (SVG user-space order)."""
197
+ m = _transform_matrix(transform)
198
+ if m is None:
199
+ return box
200
+ x0, y0, x1, y1 = box
201
+ a, b, c, d, e, f = m
202
+ corners = [(a * px + c * py + e, b * px + d * py + f)
203
+ for px, py in ((x0, y0), (x0, y1), (x1, y0), (x1, y1))]
204
+ return (min(p[0] for p in corners), min(p[1] for p in corners),
205
+ max(p[0] for p in corners), max(p[1] for p in corners))
206
+
207
+
208
+ def geometry_overflows(svg: str, tolerance: float = 0.5) -> List[Tuple[str, float, float, float]]:
209
+ """Measure an emitted SVG against its own viewBox.
210
+
211
+ Returns one (tag, max_x, max_y, overflow) entry per offending element, in
212
+ viewBox coordinates. An empty list means every drawn element sits inside the
213
+ canvas within tolerance px, which absorbs sub-pixel label rounding.
214
+ """
215
+ view = _VIEWBOX_RE.search(svg)
216
+ if view is None:
217
+ return [("svg", 0.0, 0.0, float("inf"))]
218
+ vx0, vy0, vw, vh = _enum(view.group(1))[:4]
219
+ right, bottom = vx0 + vw, vy0 + vh
220
+ out: List[Tuple[str, float, float, float]] = []
221
+ for tag, x0, y0, x1, y1 in _element_boxes(svg):
222
+ over = max(x1 - right, y1 - bottom, vx0 - x0, vy0 - y0)
223
+ if over > tolerance:
224
+ out.append((tag, round(max(x0, x1), 1), round(max(y0, y1), 1), round(over, 2)))
225
+ return out
226
+
227
+
228
+ def check_geometry(svg: str, fig_type: str, tolerance: float = 0.5) -> None:
229
+ """Raise GeometryOverflowError if a figure escapes its own viewBox."""
230
+ bad = geometry_overflows(svg, tolerance)
231
+ if not bad:
232
+ return
233
+ tag, x, y, over = max(bad, key=lambda item: item[3])
234
+ raise GeometryOverflowError(
235
+ f"{fig_type}: drawing escapes its viewBox by {over}px "
236
+ f"(<{tag}> reaches x={x}, y={y}; {len(bad)} element(s) outside). "
237
+ f"Size the canvas from the content instead of a fixed w/h."
238
+ )
239
+
240
+
241
+ # Designed canvas minimums. A figure never shrinks below these, so charts whose
242
+ # data already fits keep their exact proportions and byte-identical output.
243
+ MIN_CANVAS_W = 540
244
+ MIN_CANVAS_H = 300
245
+ PAD_RIGHT = 14.0
246
+ PAD_BOTTOM = 16.0
247
+
248
+
249
+ def _fit_canvas(body: List[str], w_min: int = MIN_CANVAS_W, h_min: int = MIN_CANVAS_H,
250
+ pad_right: float = PAD_RIGHT,
251
+ pad_bottom: float = PAD_BOTTOM) -> Tuple[int, int]:
252
+ """Size a canvas from the elements already drawn, never below the minimum.
253
+
254
+ A renderer whose geometry follows the data — row counts, axis counts, label
255
+ lengths, effect magnitudes — must not draw on a fixed canvas: whatever lands
256
+ past the edge is silently clipped by the browser. Measuring the emitted
257
+ elements against a provisional canvas and growing to fit keeps the figure
258
+ whole, while max() with the designed minimum preserves both proportions and
259
+ byte-identical output for the shapes that already fit.
260
+ """
261
+ probe = "<svg>" + "\n".join(body) + "</svg>"
262
+ max_x = max_y = 0.0
263
+ for _tag, _x0, _y0, x1, y1 in _element_boxes(probe):
264
+ max_x, max_y = max(max_x, x1), max(max_y, y1)
265
+ return (int(math.ceil(max(max_x + pad_right, w_min))),
266
+ int(math.ceil(max(max_y + pad_bottom, h_min))))
40
267
 
41
268
 
42
269
  def get_theme(theme: str = "claude") -> Dict[str, str]:
@@ -77,9 +304,6 @@ def _fmt(v: float, digits: int = 2) -> str:
77
304
 
78
305
 
79
306
  class Audit:
80
- """Collects every displayed number as (origin, value) for the
81
- lieflat_data_bound integrity gate."""
82
-
83
307
  def __init__(self, out: Optional[list] = None):
84
308
  self.out: list[Tuple[str, Any]] = [] if out is None else out
85
309
 
@@ -96,10 +320,6 @@ def _svg_open(p: Dict[str, str], w: int, h: int, aria: str) -> List[str]:
96
320
  f'style="background:{p["card_bg"]};">']
97
321
 
98
322
 
99
- # ══════════════════════════════════════════════════════════════════════════
100
- # meta.forest — forest plot (Hedges' g + 95% CI)
101
- # ══════════════════════════════════════════════════════════════════════════
102
-
103
323
  def render_forest_plot(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
104
324
  p = get_theme(theme)
105
325
  A = Audit(audit)
@@ -122,7 +342,6 @@ def render_forest_plot(bundle: dict, theme: str, meta: dict, audit: Optional[lis
122
342
  out.append(f'<text x="{cx:.0f}" y="{head_y}" text-anchor="middle" font-size="10" font-weight="700" fill="{p["subtext"]}" {lf_fade(60)}>Hedges&#39; g [95% CI]</text>')
123
343
  out.append(f'<text x="{w - 24}" y="{head_y}" text-anchor="end" font-size="10" font-weight="700" fill="{p["subtext"]}" {lf_fade(90)}>Effect [95% CI]</text>')
124
344
  out.append(f'<line x1="20" y1="{head_y + 8}" x2="{w - 20}" y2="{head_y + 8}" stroke="{p["border"]}" stroke-width="1" {lf_draw(0)}/>')
125
- # zero line
126
345
  out.append(f'<line x1="{cx:.1f}" y1="{head_y + 8}" x2="{cx:.1f}" y2="{h - 56}" stroke="{p["muted"]}" stroke-dasharray="3,3" stroke-width="1" {lf_fade(120)}/>')
127
346
  row_y = head_y + 26
128
347
  for i, s in enumerate(studies):
@@ -145,11 +364,11 @@ def render_forest_plot(bundle: dict, theme: str, meta: dict, audit: Optional[lis
145
364
  out.append(f'<line x1="{xl:.1f}" y1="{row_y}" x2="{xu:.1f}" y2="{row_y}" stroke="{color}" stroke-width="1.5" {lf_draw(i * STAGGER_BAR + 120)}/>')
146
365
  out.append(f'<line x1="{xl:.1f}" y1="{row_y - 4}" x2="{xl:.1f}" y2="{row_y + 4}" stroke="{color}" stroke-width="1.5" {lf_fade(i * STAGGER_BAR + 140)}/>')
147
366
  out.append(f'<line x1="{xu:.1f}" y1="{row_y - 4}" x2="{xu:.1f}" y2="{row_y + 4}" stroke="{color}" stroke-width="1.5" {lf_fade(i * STAGGER_BAR + 140)}/>')
148
- out.append(f'<rect x="{x_pt - 3.5:.1f}" y="{row_y - 3.5:.1f}" width="7" height="7" fill="{color}" {lf_pop(i * STAGGER_BAR + 150)}><title>{esc(str(s["label"]))} — g = {g:+.2f}{f", N = {s["n"]:.0f}" if s.get("n") else ""}</title></rect>')
367
+ n_suffix = (", N = %.0f" % s["n"]) if s.get("n") else ""
368
+ out.append(f'<rect x="{x_pt - 3.5:.1f}" y="{row_y - 3.5:.1f}" width="7" height="7" fill="{color}" {lf_pop(i * STAGGER_BAR + 150)}><title>{esc(str(s["label"]))} — g = {g:+.2f}{n_suffix}</title></rect>')
149
369
  ci_txt = f'{g:+.2f} [{ci_l:+.2f}, {ci_u:+.2f}]' if ci_l is not None and ci_u is not None else f"{g:+.2f}"
150
370
  out.append(f'<text x="{w - 24}" y="{row_y + 4}" text-anchor="end" font-size="10" font-weight="800" fill="{color}" {lf_fade(i * STAGGER_BAR + 200)}>{ci_txt}</text>')
151
371
  row_y += 27
152
- # axis
153
372
  axis_y = h - 48
154
373
  out.append(f'<line x1="{cx - 0.34 * w:.0f}" y1="{axis_y}" x2="{cx + 0.34 * w:.0f}" y2="{axis_y}" stroke="{p["text"]}" stroke-width="1" {lf_draw(200)}/>')
155
374
  for tick in (-1.0, -0.5, 0.0, 0.5, 1.0):
@@ -165,19 +384,24 @@ def render_forest_plot(bundle: dict, theme: str, meta: dict, audit: Optional[lis
165
384
  return "\n".join(out)
166
385
 
167
386
 
168
- # ══════════════════════════════════════════════════════════════════════════
169
- # evidence.ranked_effects — L2 Dot Cascade
170
- # ══════════════════════════════════════════════════════════════════════════
171
-
172
387
  def render_dot_cascade(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
173
388
  p = get_theme(theme)
174
389
  A = Audit(audit)
175
390
  studies = bundle.get("studies") or []
176
- w, h = 540, 300
177
- out = _svg_open(p, w, h, meta.get("title", "dot cascade"))
391
+ out_title = meta.get("title", "dot cascade")
178
392
  n = len(studies)
179
393
  x_start, x_end, y_base = 64, 516, 236
394
+ # The 45° study labels under the axis are as wide as they are tall, so a
395
+ # packed cascade can need a wider canvas than the 540px default. Positions
396
+ # are untouched; only the canvas grows, and never below the old minimum.
180
397
  x_gap = (x_end - x_start) / max(1, n)
398
+ label_fs = MIN_FONT + 1.5
399
+ labels = [str(s["label"])[:12] for s in studies]
400
+ label_w = max((text_width(label, label_fs) for label in labels), default=0.0)
401
+ last_label_x = x_start + (n - 0.5) * x_gap
402
+ w = max(540, int(math.ceil(last_label_x + label_w * ROT45 + label_fs * ASCENT * ROT45)) + 16)
403
+ h = max(300, int(math.ceil(y_base + 18 + label_w * ROT45 + label_fs * DESCENT * ROT45)) + 10)
404
+ out = _svg_open(p, w, h, out_title)
181
405
  gmax = max(abs(s["g"]) for s in studies) or 1.0
182
406
  scale = 92 / gmax
183
407
  for i, s in enumerate(studies):
@@ -204,29 +428,54 @@ def render_dot_cascade(bundle: dict, theme: str, meta: dict, audit: Optional[lis
204
428
  return "\n".join(out)
205
429
 
206
430
 
207
- # ══════════════════════════════════════════════════════════════════════════
208
- # evidence.year_x_dimension — L9 Bubble Almanac
209
- # ══════════════════════════════════════════════════════════════════════════
210
-
211
431
  def render_bubble_almanac(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
212
432
  p = get_theme(theme)
213
433
  A = Audit(audit)
214
434
  years = bundle.get("years") or []
215
435
  dims = bundle.get("dimensions") or []
216
436
  cells = bundle.get("cells") or []
217
- w, h = 540, 300
218
- out = _svg_open(p, w, h, meta.get("title", "bubble almanac"))
219
- for y in range(70, 262, 7):
220
- out.append(f'<line x1="44" y1="{y}" x2="520" y2="{y}" stroke="{p["grid"]}" stroke-width="0.5" {lf_fade((y - 70) * 2)}/>')
221
437
  x_start, x_gap = 150, (520 - 150) / max(1, len(dims) - 1) if len(dims) > 1 else 0
222
438
  if len(dims) == 1:
223
439
  x_start = 335
224
440
  y_start, y_gap = 92, 44
441
+ # Bubble radius mirrors the draw loop below (sqrt area encoding). Bubbles are
442
+ # area-encoded and are never rescaled (the sqrt encoding is a contract), so
443
+ # the largest one decides how far the grid reaches: the canvas clears it on
444
+ # the right and below, and shifts the grid down when it would cross the top.
445
+ def bubble_r(n: int) -> float:
446
+ return max(3.5, math.sqrt(max(1, n)) * 3.6)
447
+
448
+ max_r = max((bubble_r(int(c["n"])) for c in cells), default=0.0)
449
+ shift_y = max(0.0, 70.0 + max_r + 4 - y_start)
450
+ last_col = x_start + max(0, len(dims) - 1) * x_gap
451
+ # One row per publication year: the year axis is data-driven, so a fixed 300
452
+ # canvas clipped every row past the fifth. The grid band grows with the rows
453
+ # and the canvas keeps its original minimum and 540px width.
454
+ band_bottom = max(262, y_start + max(0, len(years) - 1) * y_gap + 22)
455
+ h = max(300, int(math.ceil(band_bottom + shift_y + max_r + 4)))
456
+ w = max(540, int(math.ceil(last_col + max_r + 4)))
457
+ out = _svg_open(p, w, h, meta.get("title", "bubble almanac"))
458
+ for y in range(70, band_bottom, 7):
459
+ out.append(f'<line x1="44" y1="{y + shift_y:.1f}" x2="520" y2="{y + shift_y:.1f}" stroke="{p["grid"]}" stroke-width="0.5" {lf_fade((y - 70) * 2)}/>')
460
+ dim_labels = {item["label"]: item for item in (bundle.get("dimension_labels") or [])}
461
+ is_zh = meta.get("lang", "en") == "zh"
225
462
  for j, d in enumerate(dims):
226
463
  x = x_start + j * x_gap
227
- out.append(f'<text x="{x:.0f}" y="76" fill="{p["text"]}" font-size="9" font-weight="600" text-anchor="middle" {lf_fade(j * STAGGER_BAR + 40)}>{esc(str(d).replace("_", " ")[:14])}</text>')
464
+ dim_item = dim_labels.get(d, {})
465
+ dim_text = (dim_item.get("label_zh" if is_zh else "label_en")
466
+ or str(d).replace("_", " "))
467
+ # The last column label is centred on the grid edge and would be clipped
468
+ # half-way; clamp it inside the canvas instead (label only, not the data).
469
+ dim_fs = 9
470
+ half = text_width(dim_text[:14], dim_fs) / 2
471
+ # Keep an 8px margin: the label must not sit flush against the canvas
472
+ # edge, where glyph rounding would clip the last stroke.
473
+ tx = min(max(x, 40 + half), w - half - 8)
474
+ # Untouched labels keep their exact old markup: only a clamp changes bytes.
475
+ x_attr = f"{x:.0f}" if abs(tx - x) < 0.05 else f"{tx:.1f}"
476
+ out.append(f'<text x="{x_attr}" y="76" fill="{p["text"]}" font-size="{dim_fs}" font-weight="600" text-anchor="middle" {lf_fade(j * STAGGER_BAR + 40)}>{esc(dim_text[:14])}</text>')
228
477
  for i, y_str in enumerate(years):
229
- y = y_start + i * y_gap
478
+ y = y_start + i * y_gap + shift_y
230
479
  out.append(f'<text x="96" y="{y + 4}" fill="{p["subtext"]}" font-size="9" font-weight="800" text-anchor="end" {lf_fade(i * STAGGER_BAR + 80)}>{esc(y_str)}</text>')
231
480
  for k, c in enumerate(cells):
232
481
  j = dims.index(c["dim"]) if c["dim"] in dims else 0
@@ -236,21 +485,17 @@ def render_bubble_almanac(bundle: dict, theme: str, meta: dict, audit: Optional[
236
485
  if c.get("sig") is not None:
237
486
  A.log(f"cells[{k}].sig", int(c["sig"]))
238
487
  bx = x_start + j * x_gap
239
- by = y_start + i * y_gap
488
+ by = y_start + i * y_gap + shift_y
240
489
  r = max(3.5, math.sqrt(n) * 3.6)
241
490
  color = p["secondary"] if int(c.get("sig") or 0) > 0 else p["primary"]
242
491
  out.append(f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="{r:.1f}" fill="{color}" fill-opacity="0.22" stroke="{color}" stroke-width="1.2" {lf_pop(k * STAGGER_DOT + 100)}>'
243
- f'<title>{esc(str(c["dim"]))} ({esc(str(c["year"]))}) — N = {n} studies, significant = {c.get("sig", 0)}</title></circle>')
492
+ f'<title>{esc(dim_labels.get(c["dim"], {}).get("label_zh" if is_zh else "label_en") or str(c["dim"]).replace("_", " "))} ({esc(str(c["year"]))}) — {"N = " + str(n) + " 篇研究" if is_zh else "N = " + str(n) + " studies"}, {"显著 = " if is_zh else "significant = "}{c.get("sig", 0)}</title></circle>')
244
493
  if int(c.get("sig") or 0) > 0:
245
494
  out.append(f'<circle cx="{bx:.1f}" cy="{by:.1f}" r="3.2" fill="{color}" {lf_pop(k * STAGGER_DOT + 160)}/>')
246
495
  out.append("</svg>")
247
496
  return "\n".join(out)
248
497
 
249
498
 
250
- # ══════════════════════════════════════════════════════════════════════════
251
- # evidence.grouped_distribution — G15 Jitter Strip
252
- # ══════════════════════════════════════════════════════════════════════════
253
-
254
499
  def render_jitter_strip(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
255
500
  p = get_theme(theme)
256
501
  A = Audit(audit)
@@ -280,10 +525,6 @@ def render_jitter_strip(bundle: dict, theme: str, meta: dict, audit: Optional[li
280
525
  return "\n".join(out)
281
526
 
282
527
 
283
- # ══════════════════════════════════════════════════════════════════════════
284
- # evidence.multidim_top — L20 Parallel Coordinates
285
- # ══════════════════════════════════════════════════════════════════════════
286
-
287
528
  def render_parallel_coordinates(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
288
529
  p = get_theme(theme)
289
530
  A = Audit(audit)
@@ -329,16 +570,13 @@ def render_parallel_coordinates(bundle: dict, theme: str, meta: dict, audit: Opt
329
570
  if len(d_parts) < 2:
330
571
  continue
331
572
  title = f'{esc(str(r["label"]))} — ' + ", ".join(f'{ax["key"]}={r.get(ax["key"])}' for ax in axes)
332
- out.append(f'<polyline points="{" ".join(d_parts)}" fill="none" stroke="{p["primary"]}" stroke-width="1" opacity="0.45" {lf_draw(r_i * STAGGER_BAR + 120)}><title>{title}</title></polyline>')
573
+ points_text = " ".join(d_parts)
574
+ out.append(f'<polyline points="{points_text}" fill="none" stroke="{p["primary"]}" stroke-width="1" opacity="0.45" {lf_draw(r_i * STAGGER_BAR + 120)}><title>{title}</title></polyline>')
333
575
  out.append(f'<circle cx="{d_parts[0].split()[0]}" cy="{d_parts[0].split()[1]}" r="2.4" fill="{p["primary"]}" {lf_pop(r_i * STAGGER_BAR + 140)}/>')
334
576
  out.append("</svg>")
335
577
  return "\n".join(out)
336
578
 
337
579
 
338
- # ══════════════════════════════════════════════════════════════════════════
339
- # evidence.study_type_composition / wwc — L14 Hundred Field
340
- # ══════════════════════════════════════════════════════════════════════════
341
-
342
580
  def render_hundred_field(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
343
581
  p = get_theme(theme)
344
582
  A = Audit(audit)
@@ -363,10 +601,6 @@ def render_hundred_field(bundle: dict, theme: str, meta: dict, audit: Optional[l
363
601
  return "\n".join(out)
364
602
 
365
603
 
366
- # ══════════════════════════════════════════════════════════════════════════
367
- # evidence.study_type_composition / wwc — F4 Tick Donut
368
- # ══════════════════════════════════════════════════════════════════════════
369
-
370
604
  def render_tick_donut(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
371
605
  p = get_theme(theme)
372
606
  A = Audit(audit)
@@ -376,7 +610,6 @@ def render_tick_donut(bundle: dict, theme: str, meta: dict, audit: Optional[list
376
610
  out = _svg_open(p, w, h, meta.get("title", "tick donut"))
377
611
  cx, cy, r0, r1 = 200, 158, 88, 104
378
612
  colors = [p["primary"], p["secondary"], p["accent"], p["muted"]]
379
- # largest-remainder share of 100 ticks
380
613
  ticks_per = []
381
614
  assigned = 0
382
615
  for c_i, c in enumerate(cats):
@@ -385,13 +618,13 @@ def render_tick_donut(bundle: dict, theme: str, meta: dict, audit: Optional[list
385
618
  n = int(round(share))
386
619
  ticks_per.append((c_i, n))
387
620
  assigned += n
388
- while assigned > 100: # trim the largest over-assignment
621
+ while assigned > 100:
389
622
  c_i, n = max(ticks_per, key=lambda t: t[1])
390
623
  ticks_per[ticks_per.index((c_i, n))] = (c_i, n - 1)
391
624
  assigned -= 1
392
- while assigned < 100: # give the remainder to the largest category
393
- c_i, _ = max(ticks_per, key=lambda t: t[1])
394
- ticks_per[ticks_per.index((c_i, _))] = (c_i, _ + 1)
625
+ while assigned < 100:
626
+ c_i, n = max(ticks_per, key=lambda t: t[1])
627
+ ticks_per[ticks_per.index((c_i, n))] = (c_i, n + 1)
395
628
  assigned += 1
396
629
  tick_i = 0
397
630
  for c_i, n in ticks_per:
@@ -408,10 +641,6 @@ def render_tick_donut(bundle: dict, theme: str, meta: dict, audit: Optional[list
408
641
  return "\n".join(out)
409
642
 
410
643
 
411
- # ══════════════════════════════════════════════════════════════════════════
412
- # outcomes.direction_counts — F5 Tick Rows
413
- # ══════════════════════════════════════════════════════════════════════════
414
-
415
644
  def _dir_colors(p: Dict[str, str]) -> Dict[str, str]:
416
645
  return {"positive": p["secondary"], "negative": p["primary"], "null": p["muted"]}
417
646
 
@@ -448,10 +677,6 @@ def render_tick_rows(bundle: dict, theme: str, meta: dict, audit: Optional[list]
448
677
  return "\n".join(out)
449
678
 
450
679
 
451
- # ══════════════════════════════════════════════════════════════════════════
452
- # outcomes.direction_counts — F1 Rung Bars
453
- # ══════════════════════════════════════════════════════════════════════════
454
-
455
680
  def render_rung_bars(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
456
681
  p = get_theme(theme)
457
682
  A = Audit(audit)
@@ -461,8 +686,6 @@ def render_rung_bars(bundle: dict, theme: str, meta: dict, audit: Optional[list]
461
686
  colors = _dir_colors(p)
462
687
  x_start, x_end, y_base = 60, 516, 240
463
688
  bar_w = min(52, (x_end - x_start) / max(1, len(rows)) * 0.62)
464
- total_max = max((r["positive"] + r["negative"] + r["null"]) for r in rows) or 1
465
- rung_h = 5.5
466
689
  for r_i, r in enumerate(rows):
467
690
  cx = x_start + (r_i + 0.5) * (x_end - x_start) / len(rows)
468
691
  label = r.get("label_" + meta.get("lang", "en"), r.get("label_en", r["label"]))
@@ -474,8 +697,8 @@ def render_rung_bars(bundle: dict, theme: str, meta: dict, audit: Optional[list]
474
697
  for _ in range(count):
475
698
  if y_base - y > 118:
476
699
  break
477
- y -= rung_h + 2.2
478
- out.append(f'<rect x="{cx - bar_w / 2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{rung_h}" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key} evidence</title></rect>')
700
+ y -= 7.7
701
+ out.append(f'<rect x="{cx - bar_w / 2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="5.5" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key} evidence</title></rect>')
479
702
  shown += 1
480
703
  if shown < count:
481
704
  out.append(f'<text x="{cx:.1f}" y="{y - 4:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 500)}>+{count - shown}</text>')
@@ -486,26 +709,26 @@ def render_rung_bars(bundle: dict, theme: str, meta: dict, audit: Optional[list]
486
709
  return "\n".join(out)
487
710
 
488
711
 
489
- # ══════════════════════════════════════════════════════════════════════════
490
- # outcomes.paired_counts — F6 Paired Rungs
491
- # ══════════════════════════════════════════════════════════════════════════
492
-
493
712
  def render_paired_rungs(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
494
713
  p = get_theme(theme)
495
714
  A = Audit(audit)
496
715
  rows = bundle.get("rows") or []
497
- w, h = 540, 300
498
- out = _svg_open(p, w, h, meta.get("title", "paired rungs"))
499
- colors = _dir_colors(p)
716
+ # Group width follows the row count, so each centered pair column walks
717
+ # rightwards as outcomes are added: past eight rows the last group's value
718
+ # label hangs over the 540px edge. The 480px group band keeps the designed
719
+ # spacing when it fits, and the canvas grows with it when it does not.
500
720
  group_w = 480 / max(1, len(rows))
721
+ colors = _dir_colors(p)
722
+ body: List[str] = []
501
723
  col_w = 13
502
724
  y_base = 238
503
725
  lang = meta.get("lang", "en")
504
- out.append(f'<text x="60" y="76" font-size="8" font-weight="700" fill="{colors["positive"]}" {lf_fade(40)}>{esc(lang == "zh" and "正向" or "POSITIVE")}</text>')
505
- out.append(f'<text x="60" y="92" font-size="8" font-weight="700" fill="{colors["negative"]}" {lf_fade(80)}>{esc(lang == "zh" and "负向" or "NEGATIVE")}</text>')
726
+ body.append(f'<text x="60" y="76" font-size="8" font-weight="700" fill="{colors["positive"]}" {lf_fade(40)}>{esc(lang == "zh" and "正向" or "POSITIVE")}</text>')
727
+ body.append(f'<text x="60" y="92" font-size="8" font-weight="700" fill="{colors["negative"]}" {lf_fade(80)}>{esc(lang == "zh" and "负向" or "NEGATIVE")}</text>')
506
728
  for r_i, r in enumerate(rows):
507
729
  cx = 60 + (r_i + 0.5) * group_w
508
730
  label = r.get("label_" + lang, r.get("label_en", r["label"]))
731
+ last_y = y_base
509
732
  for col, key in ((0, "positive"), (1, "negative")):
510
733
  count = int(r.get(key) or 0)
511
734
  A.log(f"rows[{r_i}].{key}", count)
@@ -514,42 +737,49 @@ def render_paired_rungs(bundle: dict, theme: str, meta: dict, audit: Optional[li
514
737
  for _ in range(count):
515
738
  if y_base - y > 130:
516
739
  break
517
- y -= 5.5 + 2.2
740
+ y -= 7.7
518
741
  x = cx - col_w - 4 + col * (col_w + 8)
519
- out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{col_w}" height="5.5" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key}</title></rect>')
742
+ body.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{col_w}" height="5.5" rx="2.6" fill="{colors[key]}" {lf_fade(r_i * STAGGER_BAR + shown * STAGGER_DOT + 60)}><title>{esc(str(label))} — {key}</title></rect>')
520
743
  shown += 1
744
+ last_y = min(last_y, y)
521
745
  if shown < count:
522
- out.append(f'<text x="{cx:.1f}" y="{y - 4:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 500)}>+{count - shown}</text>')
523
- out.append(f'<text x="{cx:.1f}" y="{y_base + 16}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="600" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 100)}>{esc(str(label)[:10])}</text>')
524
- out.append(f'<text x="{cx:.1f}" y="{y - 8:.1f}" text-anchor="middle" font-size="8" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 520)}>{r["positive"]} / {r["negative"]}</text>')
525
- out.append(f'<line x1="46" y1="{y_base}" x2="512" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
746
+ body.append(f'<text x="{cx:.1f}" y="{y - 4:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 500)}>+{count - shown}</text>')
747
+ body.append(f'<text x="{cx:.1f}" y="{y_base + 16}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="600" fill="{p["subtext"]}" {lf_fade(r_i * STAGGER_BAR + 100)}>{esc(str(label)[:10])}</text>')
748
+ body.append(f'<text x="{cx:.1f}" y="{last_y - 8:.1f}" text-anchor="middle" font-size="8" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 520)}>{r["positive"]} / {r["negative"]}</text>')
749
+ body.append(f'<line x1="46" y1="{y_base}" x2="512" y2="{y_base}" stroke="{p["text"]}" stroke-width="1.2" {lf_draw(120)}/>')
750
+ w, h = _fit_canvas(body)
751
+ out = _svg_open(p, w, h, meta.get("title", "paired rungs")) + body
526
752
  out.append("</svg>")
527
753
  return "\n".join(out)
528
754
 
529
755
 
530
- # ══════════════════════════════════════════════════════════════════════════
531
- # outcomes.bipolar_axes — L7 Brand Spectrum
532
- # ══════════════════════════════════════════════════════════════════════════
533
-
534
756
  def render_brand_spectrum(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
535
757
  p = get_theme(theme)
536
758
  A = Audit(audit)
537
759
  axes = bundle.get("axes") or []
538
- w, h = 540, 300
539
- out = _svg_open(p, w, h, meta.get("title", "brand spectrum"))
540
760
  lang = meta.get("lang", "en")
541
761
  left_t = bundle.get("left_zh" if lang == "zh" else "left_en", "negative")
542
762
  right_t = bundle.get("right_zh" if lang == "zh" else "right_en", "positive")
543
763
  x0, x1 = 150, 400
544
764
  y0, gap = 88, 46
765
+ # One row per bipolar axis: a fixed 300px canvas clipped the sixth row
766
+ # (y = 88 + 5·46 = 318, plus a 7.5px dot and its value label). The canvas
767
+ # grows with the row count and keeps the original minimum and top margin.
768
+ last_y = y0 + max(0, len(axes) - 1) * gap
769
+ # The derived-stat note keeps its old baseline until the rows grow into it.
770
+ caption_y = max(278, int(math.ceil(last_y + 7.5 + 12)))
771
+ h = max(300, caption_y + 22)
772
+ w = 540
773
+ out = _svg_open(p, w, h, meta.get("title", "brand spectrum"))
545
774
  out.append(f'<text x="{x0 - 12}" y="62" text-anchor="end" font-size="9" font-weight="700" fill="{p["subtext"]}" {lf_fade(40)}>{esc(left_t)}</text>')
546
775
  out.append(f'<text x="{x1 + 12}" y="62" font-size="9" font-weight="700" fill="{p["subtext"]}" {lf_fade(80)}>{esc(right_t)}</text>')
547
776
  px = lambda t: x0 + t * (x1 - x0)
548
- ribbon = [f"{px(axes[0]['position']):.1f} {y0}"]
777
+ ribbon = [f"{px(axes[0]['position']):.1f} {y0}"] if axes else []
549
778
  for i in range(1, len(axes)):
550
779
  ribbon.append(f"C {px(axes[i-1]['position']):.1f} {y0 + (i-1)*gap + gap/2:.1f}, {px(axes[i]['position']):.1f} {y0 + i*gap - gap/2:.1f}, {px(axes[i]['position']):.1f} {y0 + i*gap}")
551
- ribbon_d = f"M {ribbon[0]} " + " ".join(ribbon[1:])
552
- out.append(f'<path d="{ribbon_d}" fill="none" stroke="{p["ribbon"]}" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" {lf_draw(60)}/>')
780
+ if ribbon:
781
+ ribbon_d = f"M {ribbon[0]} " + " ".join(ribbon[1:])
782
+ out.append(f'<path d="{ribbon_d}" fill="none" stroke="{p["ribbon"]}" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" opacity="0.95" {lf_draw(60)}/>')
553
783
  for i, ax in enumerate(axes):
554
784
  y = y0 + i * gap
555
785
  pos = float(ax["position"])
@@ -568,15 +798,11 @@ def render_brand_spectrum(bundle: dict, theme: str, meta: dict, audit: Optional[
568
798
  net_txt = f"{net * 100:+.0f}%"
569
799
  out.append(f'<circle cx="{ux:.1f}" cy="{y}" r="7.5" fill="{p["primary"] if net < 0 else p["secondary"]}" stroke="{p["card_bg"]}" stroke-width="1.8" {lf_pop(i * STAGGER_BAR + 160)}><title>{esc(str(label))} — {esc(left_t)}↔{esc(right_t)}: {net_txt} (pos {ax["positive"]} / neg {ax["negative"]} / null {ax["null"]})</title></circle>')
570
800
  out.append(f'<text x="{ux:.1f}" y="{y - 11}" fill="{p["text"]}" font-size="8" font-weight="800" text-anchor="middle" {lf_fade(i * STAGGER_BAR + 220)}>{net_txt}</text>')
571
- out.append(f'<text x="30" y="278" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(400)}>{esc(bundle.get("derived", ""))[:64]}</text>')
801
+ out.append(f'<text x="30" y="{caption_y}" font-size="8" font-weight="600" fill="{p["muted"]}" {lf_fade(400)}>{esc(bundle.get("derived", ""))[:64]}</text>')
572
802
  out.append("</svg>")
573
803
  return "\n".join(out)
574
804
 
575
805
 
576
- # ══════════════════════════════════════════════════════════════════════════
577
- # intervention.phase_weeks — L3 Barcode Lollipop
578
- # ══════════════════════════════════════════════════════════════════════════
579
-
580
806
  def render_barcode_lollipop(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
581
807
  p = get_theme(theme)
582
808
  A = Audit(audit)
@@ -601,7 +827,7 @@ def render_barcode_lollipop(bundle: dict, theme: str, meta: dict, audit: Optiona
601
827
  out.append(f'<line x1="{x:.1f}" y1="{y_base}" x2="{x:.1f}" y2="{y:.1f}" stroke="{p["grid"]}" stroke-width="0.7" {lf_fade(60)}/>')
602
828
  is_peak = wk["week"] in peak_weeks
603
829
  out.append(f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{x:.1f}" y2="{min(y_base, y + 12 + ((i * 7) % 12)):.1f}" stroke="{p["text"]}" stroke-width="{1.4 if is_peak else 0.9}" {lf_fade(min(i * STAGGER_BAR, 1200) + 80)}/>')
604
- out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{4.8 if is_peak else 2.5:.1f}" fill="{p["primary"] if is_peak else p["text"]}" {lf_pop(min(i * STAGGER_DOT, 700) + 160)}><title>{esc(lang == "zh" and f"第 {wk['week']} 周 — 阶段 {phase}" or f"week {wk['week']} — phase {phase}")}</title></circle>')
830
+ out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{4.8 if is_peak else 2.5:.1f}" fill="{p["primary"] if is_peak else p["text"]}" {lf_pop(min(i * STAGGER_DOT, 700) + 160)}/>')
605
831
  if is_peak:
606
832
  pk = next((q for q in peaks if q["week"] == wk["week"]), None)
607
833
  pk_txt = (pk or {}).get("label_zh" if lang == "zh" else "label_en", "")
@@ -616,10 +842,6 @@ def render_barcode_lollipop(bundle: dict, theme: str, meta: dict, audit: Optiona
616
842
  return "\n".join(out)
617
843
 
618
844
 
619
- # ══════════════════════════════════════════════════════════════════════════
620
- # intervention.activity_weights — L1 Launch Fan
621
- # ══════════════════════════════════════════════════════════════════════════
622
-
623
845
  def render_launch_fan(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
624
846
  p = get_theme(theme)
625
847
  A = Audit(audit)
@@ -647,42 +869,61 @@ def render_launch_fan(bundle: dict, theme: str, meta: dict, audit: Optional[list
647
869
  return "\n".join(out)
648
870
 
649
871
 
650
- # ══════════════════════════════════════════════════════════════════════════
651
- # intervention.phase_groups — L8 Dotty Matrix
652
- # ══════════════════════════════════════════════════════════════════════════
653
-
654
872
  def render_dotty_matrix(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
655
873
  p = get_theme(theme)
656
874
  A = Audit(audit)
657
875
  layers = bundle.get("layers") or []
658
- w, h = 540, 310
659
- out = _svg_open(p, w, h, meta.get("title", "dotty matrix"))
660
876
  shades = [p["text"], p["secondary"], p["accent"], p["muted"]]
661
877
 
878
+ # The layer plates are parallelograms stacked in depth. Each extra phase
879
+ # lifts the stack, so the front plate keeps its position while every plate
880
+ # behind it moves up: the canvas follows the projections the data actually
881
+ # draws. Cells are clamped into the 6x6 footprint, which is what keeps the
882
+ # plates a readable square.
883
+ top_margin = 24.0
884
+ n_layers = max(1, len(layers))
885
+ # Depth walks the stack upwards, so past the fifth phase the back plates
886
+ # cross y=0 and are clipped off the top. Shift the whole projection down by
887
+ # exactly what the deepest plate needs; figures that already clear the top
888
+ # margin keep shift 0 and their original geometry.
889
+ stack_top = 244 + (-1.2) * 8.4 - (n_layers - 1) * 52
890
+ shift_y = max(0.0, top_margin - stack_top)
891
+
662
892
  def P(c, r, k):
663
- return 236 + (c - r) * 17, 244 + (c + r) * 8.4 - k * 52
893
+ return 236 + (c - r) * 17, 244 + (c + r) * 8.4 - k * 52 + shift_y
894
+
895
+ max_r = min(5, max((int(cell["r"]) for layer in layers
896
+ for cell in layer.get("cells") or []), default=5))
897
+ max_c = min(5, max((int(cell["c"]) for layer in layers
898
+ for cell in layer.get("cells") or []), default=5))
899
+ # The phase legend sits beside each plate and the derived note under the
900
+ # stack, so the canvas has to fit both the widest plate + its label and the
901
+ # lowest plate + the note.
902
+ depths = [P(max_c + 0.6, max_r + 0.6, k)[1] for k in range(n_layers)]
903
+ derived_y = max(294.0 + shift_y, max(depths) + 12)
904
+ body: List[str] = []
664
905
  for k in range(len(layers)):
906
+ # The plate keeps the designed 6x6 footprint so sparse phases do not
907
+ # shrink the stack; only the dots follow the data.
665
908
  cs = [P(-0.6, -0.6, k), P(5.6, -0.6, k), P(5.6, 5.6, k), P(-0.6, 5.6, k)]
666
909
  poly_d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in cs) + " Z"
667
- out.append(f'<path d="{poly_d}" fill="{p["card_bg"]}" fill-opacity="0.96" stroke="{p["border"]}" stroke-width="0.9" {lf_fade(k * STAGGER_BAR + 40)}/>')
910
+ body.append(f'<path d="{poly_d}" fill="{p["card_bg"]}" fill-opacity="0.96" stroke="{p["border"]}" stroke-width="0.9" {lf_fade(k * STAGGER_BAR + 40)}/>')
668
911
  layer = layers[k]
669
912
  for ci, cell in enumerate(layer["cells"]):
670
913
  A.log(f"layers[{k}].cells[{ci}].r", int(cell["r"]))
671
914
  A.log(f"layers[{k}].cells[{ci}].c", int(cell["c"]))
672
- x, y = P(cell["c"], cell["r"], k)
673
- out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="1.5" fill="{shades[k % len(shades)]}" {lf_pop(min(k * STAGGER_BAR + ci * STAGGER_DOT, 1400))}><title>{esc(str(layer["label"]))} — activity #{ci + 1}</title></circle>')
915
+ x, y = P(min(5, int(cell["c"])), min(5, int(cell["r"])), k)
916
+ body.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="1.5" fill="{shades[k % len(shades)]}" {lf_pop(min(k * STAGGER_BAR + ci * STAGGER_DOT, 1400))}><title>{esc(str(layer["label"]))} — activity #{ci + 1}</title></circle>')
674
917
  cxr, cyr = P(5.6, -0.6, k)
675
- out.append(f'<line x1="{cxr + 4:.1f}" y1="{cyr:.1f}" x2="{cxr + 20:.1f}" y2="{cyr:.1f}" stroke="{p["muted"]}" stroke-width="0.8" {lf_fade(k * STAGGER_BAR + 200)}/>')
676
- out.append(f'<text x="{cxr + 24:.1f}" y="{cyr + 2.5:.1f}" font-size="8" font-weight="700" fill="{shades[k % len(shades)]}" {lf_fade(k * STAGGER_BAR + 220)}>{esc(str(layer["label"])[:18])}</text>')
677
- out.append(f'<text x="30" y="294" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
918
+ body.append(f'<line x1="{cxr + 4:.1f}" y1="{cyr:.1f}" x2="{cxr + 20:.1f}" y2="{cyr:.1f}" stroke="{p["muted"]}" stroke-width="0.8" {lf_fade(k * STAGGER_BAR + 200)}/>')
919
+ body.append(f'<text x="{cxr + 24:.1f}" y="{cyr + 2.5:.1f}" font-size="8" font-weight="700" fill="{shades[k % len(shades)]}" {lf_fade(k * STAGGER_BAR + 220)}>{esc(str(layer["label"])[:18])}</text>')
920
+ body.append(f'<text x="30" y="{derived_y:.0f}" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
921
+ w, h = _fit_canvas(body, h_min=310)
922
+ out = _svg_open(p, w, h, meta.get("title", "dotty matrix")) + body
678
923
  out.append("</svg>")
679
924
  return "\n".join(out)
680
925
 
681
926
 
682
- # ══════════════════════════════════════════════════════════════════════════
683
- # decision.confidence_score — F11 Tick Gauge
684
- # ══════════════════════════════════════════════════════════════════════════
685
-
686
927
  def render_tick_gauge(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
687
928
  p = get_theme(theme)
688
929
  A = Audit(audit)
@@ -705,10 +946,6 @@ def render_tick_gauge(bundle: dict, theme: str, meta: dict, audit: Optional[list
705
946
  return "\n".join(out)
706
947
 
707
948
 
708
- # ══════════════════════════════════════════════════════════════════════════
709
- # methodology.flag_rates — L15 Ballot Tally
710
- # ══════════════════════════════════════════════════════════════════════════
711
-
712
949
  def render_ballot_tally(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
713
950
  p = get_theme(theme)
714
951
  A = Audit(audit)
@@ -730,30 +967,33 @@ def render_ballot_tally(bundle: dict, theme: str, meta: dict, audit: Optional[li
730
967
  x = 162 + t * 11
731
968
  if x > 470:
732
969
  break
733
- out.append(f'<rect x="{x:.1f}" y="{yc - 4:.1f}" width="8" height="8" rx="2" fill="{p["primary"] if t < flagged else p["grid"]}" {lf_pop(min(r_i * STAGGER_BAR + t * STAGGER_DOT, 1400))}><title>{esc(str(label))} — verdict {t + 1}/{total}{" (flagged)" if t < flagged else ""}</title></rect>')
970
+ flag_suffix = " (flagged)" if t < flagged else ""
971
+ out.append(f'<rect x="{x:.1f}" y="{yc - 4:.1f}" width="8" height="8" rx="2" fill="{p["primary"] if t < flagged else p["grid"]}" {lf_pop(min(r_i * STAGGER_BAR + t * STAGGER_DOT, 1400))}><title>{esc(str(label))} — verdict {t + 1}/{total}{flag_suffix}</title></rect>')
734
972
  out.append(f'<text x="512" y="{yc + 3:.1f}" text-anchor="end" font-size="9" font-weight="800" fill="{p["text"]}" {lf_fade(r_i * STAGGER_BAR + 300)}>{flagged}/{total}</text>')
735
973
  out.append(f'<text x="30" y="280" font-size="7.5" font-weight="600" fill="{p["muted"]}" {lf_fade(500)}>{esc(str(bundle.get("derived", ""))[:70])}</text>')
736
974
  out.append("</svg>")
737
975
  return "\n".join(out)
738
976
 
739
977
 
740
- # ══════════════════════════════════════════════════════════════════════════
741
- # evidence.year_x_outcome_counts — L16 Matrix Heat
742
- # ══════════════════════════════════════════════════════════════════════════
743
-
744
978
  def render_matrix_heat(bundle: dict, theme: str, meta: dict, audit: Optional[list] = None) -> str:
745
979
  p = get_theme(theme)
746
980
  A = Audit(audit)
747
981
  years = bundle.get("years") or []
748
982
  outcomes = bundle.get("outcomes") or []
749
983
  cells = bundle.get("cells") or []
750
- w = 540
751
- h = 96 + len(outcomes) * 40 + 24
752
- out = _svg_open(p, w, h, meta.get("title", "matrix heat"))
753
984
  lang = meta.get("lang", "en")
754
- cell_w = 460 / max(1, len(years))
755
985
  cell_h = 32
756
986
  x0, y0 = 150, 78
987
+ n_years = max(1, len(years))
988
+ # Columns must fit the space between the row labels and the right margin.
989
+ # A fixed 460px budget overflowed the 540px canvas from four years up,
990
+ # which clipped the final column out of the rendered figure.
991
+ min_col = 56
992
+ right_margin = 20
993
+ w = max(540, x0 + n_years * min_col + right_margin)
994
+ cell_w = (w - x0 - right_margin) / n_years
995
+ h = 96 + len(outcomes) * 40 + 24
996
+ out = _svg_open(p, w, h, meta.get("title", "matrix heat"))
757
997
  maxv = max((v for row in cells for v in row), default=1) or 1
758
998
  for j, y in enumerate(years):
759
999
  x = x0 + (j + 0.5) * cell_w
@@ -772,15 +1012,10 @@ def render_matrix_heat(bundle: dict, theme: str, meta: dict, audit: Optional[lis
772
1012
  else:
773
1013
  out.append(f'<rect x="{x + 2:.1f}" y="{y + 2:.1f}" width="{cell_w - 4:.1f}" height="{cell_h - 4}" rx="4" fill="{p["grid"]}" fill-opacity="0.5" {lf_fade((i + j) * STAGGER_DOT)}/>')
774
1014
  out.append(f'<text x="{x + cell_w / 2:.1f}" y="{y + cell_h / 2 + 3:.1f}" text-anchor="middle" font-size="{MIN_FONT + 1.5}" font-weight="800" fill="{p["text"]}" {lf_pop(min((i + j) * STAGGER_DOT + 60, 900))}>{v}</text>')
775
- out.append(f'<title>{esc(str(label))} × {esc(str(years[j]))} — {v} studies</title>')
776
1015
  out.append("</svg>")
777
1016
  return "\n".join(out)
778
1017
 
779
1018
 
780
- # ══════════════════════════════════════════════════════════════════════════
781
- # Registry + dispatcher
782
- # ══════════════════════════════════════════════════════════════════════════
783
-
784
1019
  REGISTRY: Dict[str, Dict[str, Any]] = {
785
1020
  "forest_plot": {"catalog_ref": "FOREST-PLOT (publication figure)", "source": "meta.forest",
786
1021
  "extractor": CD.extract_meta_forest, "renderer": render_forest_plot,
@@ -844,9 +1079,9 @@ ACADEMIC_FIGURE_KEYS = ("outcome-comparison.svg", "benchmark-citation-support.sv
844
1079
 
845
1080
  def render_figure(fig_type: str, bundle: dict, theme: str, meta: dict,
846
1081
  audit: Optional[list] = None) -> str:
847
- """Dispatch to the registered renderer. Unknown types raise ValueError —
848
- the caller records the reason and never silently falls back."""
849
1082
  entry = REGISTRY.get(fig_type)
850
1083
  if entry is None:
851
1084
  raise ValueError(f"unregistered lieflat chart type: {fig_type!r}")
852
- return entry["renderer"](bundle, theme, meta or {}, audit)
1085
+ svg = entry["renderer"](bundle, theme, meta or {}, audit)
1086
+ check_geometry(svg, fig_type)
1087
+ return svg