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.
- package/CONTRIBUTING.md +105 -0
- package/README.md +142 -75
- package/README.zh-CN.md +73 -30
- package/SKILL.md +397 -131
- package/agents/openai.yaml +4 -0
- package/assets/readme/controlled-execution.svg +34 -0
- package/assets/readme/landing-tour.gif +0 -0
- package/assets/readme/logo.png +0 -0
- package/assets/readme/research-workflow.svg +56 -0
- package/assets/readme/studio-graph.png +0 -0
- package/assets/readme/studio-overview.png +0 -0
- package/assets/readme/studio-reports.png +0 -0
- package/assets/readme/studio-tour.gif +0 -0
- package/autoevolve/config.yaml +17 -0
- package/autoevolve/program.md +25 -0
- package/autoevolve/protected.manifest.yaml +34 -0
- package/benchmarks/adversarial/cases.jsonl +7 -0
- package/benchmarks/evidence-library.json +5268 -0
- package/benchmarks/partitions.json +8 -0
- package/bin/eduevidence.js +2 -1
- package/docs/architecture.md +496 -0
- package/docs/autoresearch-evolution-plan.md +2903 -0
- package/docs/autoresearch-implementation-status.md +101 -0
- package/docs/demo-storyboard.md +20 -0
- package/docs/demo-workplace-ai.md +92 -0
- package/docs/demo.md +32 -0
- package/docs/install-guide.md +150 -0
- package/docs/orchestration-role-model.md +1254 -0
- package/docs/release-closeout/README.md +17 -0
- package/docs/release-closeout/frontend-acceptance.md +23 -0
- package/docs/release-closeout/issues.md +19 -0
- package/docs/release-closeout/verification.md +28 -0
- package/docs/release-contract.md +108 -0
- package/docs/research-studio-guide.zh-CN.md +166 -0
- package/docs/sciverse-api.md +125 -0
- package/eduevidence_cli.py +29 -13
- package/engine/_resources.py +13 -0
- package/engine/autoevolve/__init__.py +3 -0
- package/engine/autoevolve/agent_view.py +167 -0
- package/engine/autoevolve/core.py +357 -0
- package/engine/autoevolve/events.py +11 -0
- package/engine/autoevolve/git_workspace.py +77 -0
- package/engine/autoevolve/projection.py +23 -0
- package/engine/autoevolve/runner.py +413 -0
- package/engine/autoevolve/trust.py +146 -0
- package/engine/autoresearch/__init__.py +6 -0
- package/engine/autoresearch/commit.py +132 -0
- package/engine/autoresearch/contracts.py +126 -0
- package/engine/autoresearch/controller.py +207 -0
- package/engine/autoresearch/events.py +12 -0
- package/engine/autoresearch/gap_priority.py +168 -0
- package/engine/autoresearch/projection.py +30 -0
- package/engine/autoresearch/research_memory.py +59 -0
- package/engine/autoresearch/saturation.py +91 -0
- package/engine/briefs.py +2 -1
- package/engine/capabilities.py +1 -0
- package/engine/contracts.py +3 -1
- package/engine/decision_policy.py +96 -0
- package/engine/evidence_graph.py +14 -10
- package/engine/evidencecore.py +7 -5
- package/engine/gaps.py +132 -73
- package/engine/ids.py +2 -0
- package/engine/judge_pack.py +65 -0
- package/engine/library.py +6 -2
- package/engine/library_builtin.py +3 -1
- package/engine/living.py +36 -5
- package/engine/meta_synthesis.py +3 -1
- package/engine/migration.py +88 -3
- package/engine/orchestration.py +460 -0
- package/engine/paths.py +2 -0
- package/engine/pilot.py +36 -33
- package/engine/project.py +2 -2
- package/engine/research_service.py +113 -0
- package/engine/studio_read_model.py +400 -0
- package/engine/taxonomy.py +211 -0
- package/engine/tribunal.py +44 -33
- package/engine/update.py +1 -0
- package/engine/versions.py +1 -1
- package/engine/worker_result.py +109 -0
- package/engine/workflows.py +70 -0
- package/examples/ai-coding-assistant-evidence/EduEvidence_Report.html +2934 -0
- package/examples/ai-coding-assistant-evidence/artifact_manifest.json +15 -0
- package/examples/ai-coding-assistant-evidence/citation_check.json +79 -0
- package/examples/ai-coding-assistant-evidence/claims.jsonl +12 -0
- package/examples/ai-coding-assistant-evidence/evaluation.json +35 -0
- package/examples/ai-coding-assistant-evidence/evidence.jsonl +12 -0
- package/examples/ai-coding-assistant-evidence/final_verdict.json +107 -0
- package/examples/ai-coding-assistant-evidence/frame.json +48 -0
- package/examples/ai-coding-assistant-evidence/gate_report.json +101 -0
- package/examples/ai-coding-assistant-evidence/intervention.json +51 -0
- package/examples/ai-coding-assistant-evidence/methodology.json +36 -0
- package/examples/ai-coding-assistant-evidence/raw_verdict.json +86 -0
- package/examples/ai-coding-assistant-evidence/report_spec.json +230 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_academic.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_claude.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab-dark.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_datalab.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/EduEvidence_Report_presentation.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_academic.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_claude.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab-dark.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_datalab.html +2934 -0
- package/examples/ai-coding-assistant-evidence/reports-5themes/report_presentation.html +2934 -0
- package/examples/ai-coding-assistant-evidence/result.json +1457 -0
- package/examples/ai-coding-assistant-evidence/result.zh.json +1457 -0
- package/examples/ai-coding-assistant-evidence/skeptic.json +72 -0
- package/examples/ai-coding-assistant-evidence/sources.jsonl +8 -0
- package/examples/ai-coding-assistant-evidence/verdict.json +107 -0
- package/examples/spaced-retrieval-practice/applicability.json +14 -0
- package/examples/spaced-retrieval-practice/artifact_manifest.json +15 -0
- package/examples/spaced-retrieval-practice/claims.jsonl +3 -0
- package/examples/spaced-retrieval-practice/evidence.jsonl +6 -0
- package/examples/spaced-retrieval-practice/final_verdict.json +93 -0
- package/examples/spaced-retrieval-practice/frame.json +58 -0
- package/examples/spaced-retrieval-practice/gate_report.json +101 -0
- package/examples/spaced-retrieval-practice/methodology.json +78 -0
- package/examples/spaced-retrieval-practice/report_spec.json +212 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_academic.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_claude.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab-dark.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_datalab.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/EduEvidence_Report_presentation.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_academic.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_claude.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_datalab-dark.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_datalab.html +2728 -0
- package/examples/spaced-retrieval-practice/reports-5themes/report_presentation.html +2728 -0
- package/examples/spaced-retrieval-practice/result.json +942 -0
- package/examples/spaced-retrieval-practice/result.zh.json +942 -0
- package/examples/spaced-retrieval-practice/skeptic.json +70 -0
- package/examples/spaced-retrieval-practice/sources.jsonl +7 -0
- package/examples/spaced-retrieval-practice/verdict.json +93 -0
- package/examples/workplace-ai-assistant/artifact_manifest.json +15 -0
- package/examples/workplace-ai-assistant/claims.jsonl +4 -0
- package/examples/workplace-ai-assistant/evaluation.json +19 -0
- package/examples/workplace-ai-assistant/evidence.jsonl +4 -0
- package/examples/workplace-ai-assistant/evidence_graph.json +444 -0
- package/examples/workplace-ai-assistant/final_verdict.json +78 -0
- package/examples/workplace-ai-assistant/frame.json +41 -0
- package/examples/workplace-ai-assistant/gate_report.json +101 -0
- package/examples/workplace-ai-assistant/intervention.json +27 -0
- package/examples/workplace-ai-assistant/legacy-link-check.json +16 -0
- package/examples/workplace-ai-assistant/methodology.json +60 -0
- package/examples/workplace-ai-assistant/report_spec.json +224 -0
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_academic.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_claude.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab-dark.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_datalab.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/EduEvidence_Report_presentation.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_academic.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_claude.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_datalab-dark.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_datalab.html +2814 -0
- package/examples/workplace-ai-assistant/reports-5themes/report_presentation.html +2814 -0
- package/examples/workplace-ai-assistant/result.json +615 -0
- package/examples/workplace-ai-assistant/result.zh.json +615 -0
- package/examples/workplace-ai-assistant/search_log.json +19 -0
- package/examples/workplace-ai-assistant/skeptic.json +72 -0
- package/examples/workplace-ai-assistant/sources.jsonl +3 -0
- package/examples/workplace-ai-assistant/validation_result.json +9 -0
- package/examples/workplace-ai-assistant/verdict.json +78 -0
- package/install.sh +7 -7
- package/integrations/agent_mcp.py +2 -2
- package/integrations/orchestration_dispatch.py +146 -0
- package/package.json +46 -3
- package/pyproject.toml +14 -22
- package/references/autoresearch.md +30 -0
- package/references/evaluation-policy.md +24 -0
- package/references/orchestration.md +22 -0
- package/references/report-copy-style.md +67 -0
- package/references/retrieval-compliance.md +75 -0
- package/references/retrieval-protocol.md +20 -0
- package/references/scientific-invariants.md +19 -0
- package/retrieval/audit.py +178 -0
- package/retrieval/fetch.py +96 -0
- package/retrieval/sciverse.py +398 -0
- package/retrieval/search.py +47 -7
- package/schemas/applicability.schema.json +94 -0
- package/schemas/chart-spec.schema.json +10 -3
- package/schemas/evidence.schema.json +316 -43
- package/schemas/fetch-result.schema.json +2 -1
- package/schemas/intervention.schema.json +106 -21
- package/schemas/report-result.schema.json +12 -4
- package/schemas/report-spec.schema.json +98 -100
- package/schemas/skeptic.schema.json +86 -0
- package/schemas/source.schema.json +21 -2
- package/schemas/v2/finding.schema.json +5 -1
- package/schemas/v2/methodology-audit.schema.json +5 -1
- package/schemas/v2/outcome.schema.json +28 -5
- package/schemas/v2/project.schema.json +2 -2
- package/schemas/v2/run.schema.json +1 -1
- package/schemas/v2/study.schema.json +5 -1
- package/schemas/vNext/autoevolve-session.schema.json +34 -0
- package/schemas/vNext/eval-snapshot.schema.json +77 -0
- package/schemas/vNext/execution-plan.schema.json +50 -0
- package/schemas/vNext/gap-priority.schema.json +54 -0
- package/schemas/vNext/negative-search-record.schema.json +68 -0
- package/schemas/vNext/research-iteration.schema.json +87 -0
- package/schemas/vNext/research-strategy.schema.json +62 -0
- package/schemas/vNext/skill-experiment.schema.json +90 -0
- package/schemas/vNext/task-spec.schema.json +156 -0
- package/schemas/vNext/worker-result.schema.json +60 -0
- package/schemas/verdict.schema.json +164 -28
- package/scripts/benchmark_judge.py +2 -2
- package/scripts/benchmark_v3.py +26 -43
- package/scripts/build_esl_artifacts.py +4 -4
- package/scripts/build_evidence_library.py +2 -2
- package/scripts/build_gh_pages.py +98 -0
- package/scripts/build_readme_diagrams.py +72 -0
- package/scripts/build_report_variants.py +101 -0
- package/scripts/build_result.py +74 -9
- package/scripts/check_autoresearch_invariants.py +95 -0
- package/scripts/check_package_parity.py +85 -0
- package/scripts/check_protocol_alignment.py +375 -0
- package/scripts/check_versioned_schemas.py +254 -0
- package/scripts/claim_audit.py +13 -8
- package/scripts/compute_confidence.py +10 -0
- package/scripts/daily_evolve.py +30 -0
- package/scripts/dashboard_server.py +130 -101
- package/scripts/did_regression.py +17 -32
- package/scripts/enrich_projects_human_and_lieflat.py +1 -1
- package/scripts/evidence_score.py +5 -2
- package/scripts/generate_metrics.py +4 -3
- package/scripts/generate_new_projects.py +5 -5
- package/scripts/orchestrator.py +286 -36
- package/scripts/pre_verdict_gate.py +224 -26
- package/scripts/quickstart.py +18 -2
- package/scripts/rebake_all_5themes.py +1 -2
- package/scripts/research_auto_cli.py +475 -0
- package/scripts/run_workspace.py +24 -8
- package/scripts/search_provenance.py +64 -0
- package/scripts/serve_web.py +9 -10
- package/scripts/skill_lint.py +1 -1
- package/scripts/skill_payload.py +81 -0
- package/scripts/test_adversarial_empirical.py +26 -19
- package/scripts/validate_schema.py +46 -2
- package/scripts/vnext_cli.py +133 -0
- package/setup.py +12 -0
- package/skill/agents/evaluation-designer.md +20 -4
- package/skill/agents/evidence-analyst.md +19 -3
- package/skill/agents/evidence-judge.md +50 -2
- package/skill/agents/evidence-retriever.md +20 -3
- package/skill/agents/intervention-designer.md +20 -4
- package/skill/agents/method-reviewer.md +18 -2
- package/skill/agents/{education-planner.md → research-planner.md} +19 -3
- package/skill/agents/skeptic.md +18 -2
- package/skill/roles/registry.yaml +45 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +28 -9
- package/skill/sub-skills/contradiction-analysis/SKILL.md +31 -11
- package/skill/sub-skills/data-analysis/SKILL.md +34 -15
- package/skill/sub-skills/ethics-review/SKILL.md +33 -10
- package/skill/sub-skills/evidence-extraction/SKILL.md +29 -11
- package/skill/sub-skills/evidence-review/SKILL.md +31 -12
- package/skill/sub-skills/gap-analysis/SKILL.md +31 -9
- package/skill/sub-skills/literature-review/SKILL.md +35 -14
- package/skill/sub-skills/methodology-audit/SKILL.md +29 -12
- package/skill/sub-skills/report-generation/SKILL.md +40 -6
- package/skill/sub-skills/research-planning/SKILL.md +41 -14
- package/skill/sub-skills/study-design/SKILL.md +30 -9
- package/skill/task-briefs/adjudicate.md +32 -7
- package/skill/task-briefs/applicability.md +38 -0
- package/skill/task-briefs/audit.md +32 -7
- package/skill/task-briefs/challenge.md +34 -5
- package/skill/task-briefs/evaluate.md +30 -5
- package/skill/task-briefs/extract.md +31 -8
- package/skill/task-briefs/frame.md +39 -10
- package/skill/task-briefs/intervene.md +32 -6
- package/skill/task-briefs/present.md +32 -8
- package/skill/task-briefs/projection.md +37 -0
- package/skill/task-briefs/retrieve.md +36 -6
- package/skill/workflows/decision-and-pilot.md +85 -0
- package/skill/workflows/evaluate-and-update.md +93 -0
- package/skill/workflows/evidence-review.md +117 -0
- package/visualization/eduevidence-report/assets/base.css +2 -2
- package/visualization/eduevidence-report/assets/reader.css +752 -0
- package/visualization/eduevidence-report/assets/reader.js +132 -0
- package/visualization/eduevidence-report/references/chart-selection-catalog.md +109 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +3 -1
- package/visualization/eduevidence-report/scripts/build_figures.py +25 -3
- package/visualization/eduevidence-report/scripts/build_infographics.py +5 -1
- package/visualization/eduevidence-report/scripts/build_report.py +561 -121
- package/visualization/eduevidence-report/scripts/charts_data.py +2 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +371 -136
- package/visualization/eduevidence-report/scripts/zh_labels.py +80 -1
- package/visualization/eduevidence-report/themes/academic.css +1 -1
- package/visualization/eduevidence-report/themes/claude.css +1 -1
- package/visualization/eduevidence-report/themes/datalab-dark.css +2 -2
- package/visualization/eduevidence-report/themes/datalab.css +2 -2
- package/visualization/eduevidence-report/themes/presentation.css +2 -2
- package/web/README.md +18 -0
- package/web/architecture.html +14885 -0
- package/web/index.html +53 -0
- package/web/studio/THIRD_PARTY_LICENSES.txt +146 -0
- package/web/studio/assets/index-B8tkF44Q.css +1 -0
- package/web/studio/assets/index-CQ6Keoyc.js +230 -0
- package/web/studio/config.json +1 -0
- package/web/studio/index.html +14 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- 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
|
|
38
|
-
STAGGER_BAR = 100
|
|
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("<", "<").replace(">", ">")
|
|
99
|
+
.replace(""", '"').replace("'", "'").replace("'", "'")
|
|
100
|
+
.replace("&", "&").replace(" ", " "))
|
|
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' 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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:
|
|
393
|
-
c_i,
|
|
394
|
-
ticks_per[ticks_per.index((c_i,
|
|
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 -=
|
|
478
|
-
out.append(f'<rect x="{cx - bar_w / 2:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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 -=
|
|
740
|
+
y -= 7.7
|
|
518
741
|
x = cx - col_w - 4 + col * (col_w + 8)
|
|
519
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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="
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1085
|
+
svg = entry["renderer"](bundle, theme, meta or {}, audit)
|
|
1086
|
+
check_geometry(svg, fig_type)
|
|
1087
|
+
return svg
|