eduevidence 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
package/engine/log.py ADDED
@@ -0,0 +1,39 @@
1
+ """Central logging for the EduEvidence engine and retrieval layers (plan E4).
2
+
3
+ The engine is a library: it must never configure handlers or emit to stdout by
4
+ itself. Every module obtains its logger here; applications (CLI, dashboard,
5
+ tests) opt into output with one call to `enable_console_logging()`.
6
+
7
+ Usage:
8
+ from engine.log import get_log
9
+ log = get_log("fetch")
10
+ log.info("fallback provider=%s reason=%s", provider, reason)
11
+
12
+ Stdlib only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ _ROOT = "eduevidence"
20
+
21
+
22
+ def get_log(component: str) -> logging.Logger:
23
+ """Return a namespaced logger with a NullHandler default."""
24
+ logger = logging.getLogger(f"{_ROOT}.{component}")
25
+ if not logger.handlers:
26
+ logger.addHandler(logging.NullHandler())
27
+ logger.propagate = True
28
+ return logger
29
+
30
+
31
+ def enable_console_logging(level: int = logging.INFO) -> None:
32
+ """Opt-in root handler for CLI entrypoints (idempotent)."""
33
+ root = logging.getLogger(_ROOT)
34
+ if not any(isinstance(h, logging.StreamHandler) for h in root.handlers):
35
+ handler = logging.StreamHandler()
36
+ handler.setFormatter(logging.Formatter(
37
+ "%(asctime)s %(levelname)-7s %(name)s: %(message)s", "%H:%M:%S"))
38
+ root.addHandler(handler)
39
+ root.setLevel(level)
@@ -0,0 +1,333 @@
1
+ """engine/meta_analysis.py - v4 quantitative effect-size synthesis (meta-analysis).
2
+
3
+ Hand-written effect-size pooling over extracted study rows, following the
4
+ textbook definitions of Borenstein, Hedges, Higgins & Rothstein (2009),
5
+ "Introduction to Meta-Analysis" (Wiley):
6
+
7
+ fixed effect inverse-variance weighting: M = Σ(w·d) / Σw,
8
+ SE = 1 / √Σw, w = 1/se²
9
+ random effects DerSimonian & Laird (1986) moment estimate of τ² from
10
+ Cochran's Q, then weighting with w* = 1/(v + τ²)
11
+ heterogeneity Q = Σ w·(d − M_fixed)², df = k − 1,
12
+ I² = 100·(Q − df)/Q, τ² = max(0, (Q − df)/C),
13
+ C = Σw − Σw²/Σw
14
+
15
+ Pure stdlib (math only), no third-party dependencies. Defensive against empty
16
+ input, missing precision, and zero variance: pooling functions return None
17
+ when no usable row exists; studies without a numeric effect value or without
18
+ derivable precision are marked ``not_extractable`` by :func:`collect_effect_sizes`.
19
+
20
+ Output contract: schemas/v4/meta-analysis.schema.json.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import secrets
26
+ from datetime import datetime, timezone
27
+ from typing import Any
28
+
29
+ _Z_975 = 1.959963984540054 # two-tailed 95% normal quantile
30
+
31
+
32
+ def _now_iso() -> str:
33
+ return datetime.now(timezone.utc).isoformat()
34
+
35
+
36
+ def _is_number(value: Any) -> bool:
37
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
38
+
39
+
40
+ def _two_tailed_p(z: float) -> float:
41
+ """Two-tailed p under the standard normal: 2·(1 − Φ(|z|))."""
42
+ return 2.0 * (1.0 - 0.5 * (1.0 + math.erf(abs(z) / math.sqrt(2.0))))
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # 1. Extraction
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _effect_estimate_dict(evidence: dict) -> dict:
50
+ ee = evidence.get("effect_estimate")
51
+ if isinstance(ee, dict):
52
+ return ee
53
+ if _is_number(ee):
54
+ return {"value": ee}
55
+ return {}
56
+
57
+
58
+ def _derive_se(ee: dict, d: float, sample_size: Any) -> float | None:
59
+ """Precision of a Cohen's d, in priority order.
60
+
61
+ 1. explicit ``se`` field on the effect estimate;
62
+ 2. symmetric 95% CI: se = (ci_high − ci_low) / (2·1.96);
63
+ 3. large-sample approximation from total n (equal group sizes):
64
+ se = √(4/n + d²/(2n)) (Borenstein 2009, ch. 4, eq. 4.14/4.18).
65
+ """
66
+ if _is_number(ee.get("se")) and ee["se"] > 0:
67
+ return float(ee["se"])
68
+ low, high = ee.get("ci_low"), ee.get("ci_high")
69
+ if _is_number(low) and _is_number(high) and high > low:
70
+ return (float(high) - float(low)) / (2.0 * _Z_975)
71
+ if _is_number(sample_size) and sample_size > 0:
72
+ n = float(sample_size)
73
+ return math.sqrt(4.0 / n + (d * d) / (2.0 * n))
74
+ return None
75
+
76
+
77
+ def collect_effect_sizes(evidence_list: list[dict]) -> list[dict]:
78
+ """Extract per-study Cohen's-d rows from evidence objects.
79
+
80
+ Reads ``effect_estimate`` (value / se / ci_low / ci_high), ``sample_size``
81
+ and ``quality_dimensions`` (a D3_measurement_validity of 0 invalidates the
82
+ numeric estimate). Returns rows ``{study_id, outcome_id, d, se, n}``;
83
+ entries whose effect size cannot be extracted are returned with
84
+ ``d/se/n = None`` plus ``not_extractable: True`` and a ``reason``.
85
+ """
86
+ if not evidence_list:
87
+ return []
88
+ rows: list[dict] = []
89
+ for evidence in evidence_list:
90
+ if not isinstance(evidence, dict):
91
+ continue
92
+ study_id = evidence.get("study_id") or "unknown"
93
+ outcome_id = (evidence.get("outcome_id") or evidence.get("outcome_type")
94
+ or evidence.get("claim_id") or "unknown")
95
+ sample_size = evidence.get("sample_size")
96
+ base = {"study_id": study_id, "outcome_id": outcome_id,
97
+ "d": None, "se": None,
98
+ "n": (int(sample_size) if _is_number(sample_size) else None)}
99
+
100
+ ee = _effect_estimate_dict(evidence)
101
+ d = ee.get("value")
102
+ if not _is_number(d):
103
+ base.update({"not_extractable": True,
104
+ "reason": "missing_effect_value"})
105
+ rows.append(base)
106
+ continue
107
+
108
+ qd = evidence.get("quality_dimensions")
109
+ if isinstance(qd, dict) and qd.get("D3_measurement_validity") == 0:
110
+ base.update({"not_extractable": True,
111
+ "reason": "invalid_measurement_quality"})
112
+ rows.append(base)
113
+ continue
114
+
115
+ d = float(d)
116
+ se = _derive_se(ee, d, sample_size)
117
+ if se is None:
118
+ base.update({"not_extractable": True,
119
+ "reason": "missing_precision"})
120
+ rows.append(base)
121
+ continue
122
+ base.update({"d": d, "se": se,
123
+ "n": int(sample_size) if _is_number(sample_size) else None,
124
+ "not_extractable": False, "reason": None})
125
+ rows.append(base)
126
+ return rows
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # 2. Pooling
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def _usable(rows: list[dict]) -> list[dict]:
134
+ """Rows with finite, positive precision and a finite effect value."""
135
+ clean: list[dict] = []
136
+ for row in rows or []:
137
+ d, se = row.get("d"), row.get("se")
138
+ if _is_number(d) and _is_number(se) and se > 0 and math.isfinite(float(d)) \
139
+ and math.isfinite(float(se)) and float(se) < 1e100:
140
+ clean.append(row)
141
+ return clean
142
+
143
+
144
+ def fixed_effect_pooling(rows: list[dict]) -> dict | None:
145
+ """Inverse-variance fixed-effect pooling (Borenstein 2009, ch. 15-16).
146
+
147
+ Returns ``None`` when no usable row exists (empty input / all precision
148
+ missing). Output carries per-study inverse-variance ``weights`` for forest
149
+ plots plus a two-tailed normal test of the pooled effect (``z``/``p_value``).
150
+ """
151
+ usable = _usable(rows)
152
+ if not usable:
153
+ return None
154
+ # precision form avoids se**2 overflow for extreme se (review P2)
155
+ weights = [p * p for p in (1.0 / float(r["se"]) for r in usable)]
156
+ sum_w = sum(weights)
157
+ if sum_w <= 0 or not math.isfinite(sum_w):
158
+ return None
159
+ d = sum(w * float(r["d"]) for w, r in zip(weights, usable)) / sum_w
160
+ se = 1.0 / math.sqrt(sum_w)
161
+ ci_low, ci_high = d - _Z_975 * se, d + _Z_975 * se
162
+ return {
163
+ "method": "fixed_effect",
164
+ "k": len(usable),
165
+ "d": d,
166
+ "se": se,
167
+ "ci_low": ci_low,
168
+ "ci_high": ci_high,
169
+ "z": d / se,
170
+ "p_value": _two_tailed_p(d / se),
171
+ "weights": weights,
172
+ "sum_w": sum_w,
173
+ }
174
+
175
+
176
+ def random_effect_pooling(rows: list[dict]) -> dict | None:
177
+ """DerSimonian-Laird random-effects pooling (Borenstein 2009, ch. 16.2).
178
+
179
+ τ² is the DL moment estimate from Cochran's Q (floored at 0); I² is the
180
+ fraction of total variance attributable to between-study heterogeneity.
181
+ Returns ``None`` when no usable row exists; with k = 1, τ² is undefined
182
+ (no heterogeneity evidence) so τ² = 0 and the single study dominates.
183
+ """
184
+ usable = _usable(rows)
185
+ if not usable:
186
+ return None
187
+ k = len(usable)
188
+ fixed = fixed_effect_pooling(usable)
189
+ assert fixed is not None
190
+ d_fixed = fixed["d"]
191
+ # precision form avoids se**2 overflow for extreme se (review P2)
192
+ weights = [p * p for p in (1.0 / float(r["se"]) for r in usable)]
193
+ sum_w = sum(weights)
194
+ Q = sum(w * (float(r["d"]) - d_fixed) ** 2 for w, r in zip(weights, usable))
195
+ df = k - 1
196
+
197
+ sum_w2 = sum(w * w for w in weights)
198
+ denom_c = sum_w - (sum_w2 / sum_w) if sum_w > 0 else 0.0
199
+ tau2 = (Q - df) / denom_c if (Q > df and denom_c > 0) else 0.0
200
+ tau2 = max(0.0, tau2)
201
+ # I2 must stay in [0, 100]: with near-homogeneous data Q < df is common
202
+ # (P(Q < df) ~ 40% for real k-1 df), so clamp before dividing (P0-1).
203
+ I2 = 100.0 * max(0.0, Q - df) / Q if Q > 0 else 0.0
204
+
205
+ re_weights = [1.0 / ((float(r["se"]) ** 2) + tau2) for r in usable]
206
+ sum_w_star = sum(re_weights)
207
+ if sum_w_star <= 0 or not math.isfinite(sum_w_star):
208
+ return None
209
+ d = sum(w * float(r["d"]) for w, r in zip(re_weights, usable)) / sum_w_star
210
+ se = 1.0 / math.sqrt(sum_w_star)
211
+ return {
212
+ "method": "random_effect",
213
+ "k": k,
214
+ "d": d,
215
+ "se": se,
216
+ "ci_low": d - _Z_975 * se,
217
+ "ci_high": d + _Z_975 * se,
218
+ "z": d / se,
219
+ "p_value": _two_tailed_p(d / se),
220
+ "tau2": tau2,
221
+ "Q": Q,
222
+ "df": df,
223
+ "I2": I2,
224
+ "weights": re_weights,
225
+ "sum_w": sum_w_star,
226
+ }
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # 3. Forest plot data
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def forest_data(rows: list[dict], pooled: dict | None) -> dict | None:
234
+ """Forest-plot data (ECharts/SVG friendly): per-study effect + CI + weight.
235
+
236
+ Study weights come from the pooling model when available (``pooled``
237
+ carries ``weights``), otherwise inverse variance 1/se²; ``weight_pct`` is
238
+ the share of each study within the pooled diamond. Returns None when there
239
+ is nothing to plot.
240
+ """
241
+ usable = _usable(rows)
242
+ if not usable or pooled is None:
243
+ return None
244
+ weights = pooled.get("weights")
245
+ if not weights or len(weights) != len(usable):
246
+ # precision form avoids se**2 overflow for extreme se (review P2)
247
+ weights = [p * p for p in (1.0 / float(r["se"]) for r in usable)]
248
+ sum_w = sum(weights)
249
+ if sum_w <= 0:
250
+ return None
251
+ studies = []
252
+ for i, r in enumerate(usable):
253
+ d = float(r["d"])
254
+ se = float(r["se"])
255
+ studies.append({
256
+ "study_id": r.get("study_id"),
257
+ "outcome_id": r.get("outcome_id"),
258
+ "d": d,
259
+ "se": se,
260
+ "ci_low": d - _Z_975 * se,
261
+ "ci_high": d + _Z_975 * se,
262
+ "weight": weights[i],
263
+ "weight_pct": 100.0 * weights[i] / sum_w,
264
+ })
265
+ return {
266
+ "studies": studies,
267
+ "pooled": {
268
+ "d": pooled["d"],
269
+ "se": pooled["se"],
270
+ "ci_low": pooled["ci_low"],
271
+ "ci_high": pooled["ci_high"],
272
+ "weight": sum_w,
273
+ "weight_pct": 100.0,
274
+ },
275
+ "k": len(usable),
276
+ }
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # 4. Full v4 pipeline (schema-shaped composition)
281
+ # ---------------------------------------------------------------------------
282
+
283
+ def _new_ma_id() -> str:
284
+ return f"MA-{secrets.token_hex(4)}"
285
+
286
+
287
+ def run_meta_analysis(evidence_list: list[dict],
288
+ outcome_id: str = "OUT-meta",
289
+ pooling: str = "random_effect") -> dict:
290
+ """Compose the full v4 meta-analysis contract from evidence objects.
291
+
292
+ Headline pooling for leave-one-out robustness and the final label defaults
293
+ to random effects (the conservative choice under heterogeneity); the fixed
294
+ and random results are both reported. Raises ValueError when fewer than two
295
+ studies are poolable (defensive: refuse to synthesize nonsense).
296
+ """
297
+ from engine.bias import egger_regression, fail_safe_n
298
+ from engine.robustness import leave_one_out, robustness_label
299
+
300
+ if pooling not in ("fixed_effect", "random_effect"):
301
+ raise ValueError(f"unknown pooling {pooling!r}")
302
+ rows = collect_effect_sizes(evidence_list)
303
+ usable = _usable(rows)
304
+ if len(usable) < 2:
305
+ raise ValueError(
306
+ f"meta-analysis needs >= 2 poolable studies, got {len(usable)}")
307
+ pooling_fn = (fixed_effect_pooling if pooling == "fixed_effect"
308
+ else random_effect_pooling)
309
+ pooled_fixed = fixed_effect_pooling(usable)
310
+ pooled_random = random_effect_pooling(usable)
311
+ headline = pooling_fn(usable)
312
+ assert pooled_fixed is not None and pooled_random is not None and headline is not None
313
+
314
+ loo = leave_one_out(usable, pooling_fn)
315
+ assert loo is not None
316
+ return {
317
+ "meta_analysis_id": _new_ma_id(),
318
+ "outcome_id": outcome_id,
319
+ "generated_at": _now_iso(),
320
+ "k": len(usable),
321
+ "pooled_fixed": pooled_fixed,
322
+ "pooled_random": pooled_random,
323
+ "Q": pooled_random["Q"],
324
+ "df": pooled_random["df"],
325
+ "I2": pooled_random["I2"],
326
+ "tau2": pooled_random["tau2"],
327
+ "egger": egger_regression(usable),
328
+ "fail_safe_n": fail_safe_n(usable),
329
+ "leave_one_out": loo,
330
+ "label": robustness_label(loo),
331
+ "studies": usable,
332
+ "not_extractable": [r for r in rows if r.get("not_extractable")],
333
+ }
@@ -0,0 +1,111 @@
1
+ """engine/meta_synthesis.py - cross-project synthesis over the Shared Library (v3).
2
+
3
+ Aggregates verified facts of one immutable library revision (sources/studies/
4
+ findings/audits) into an outcome-level overview:
5
+
6
+ per outcome token (OUT-<token> convention) -> positive/negative/null
7
+ finding ids + the independent study keys behind them; plus library-wide
8
+ independent-study and source counts.
9
+
10
+ The synthesis is an interpretive projection: it never mutates library state.
11
+ Contract: schemas/v3/synthesis.schema.json.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from engine.ids import new_local_id
21
+ from engine.library import ResearchLibrary
22
+ from scripts.validate_schema import SchemaError, validate
23
+
24
+ _SYNTHESIS_SCHEMA = (Path(__file__).resolve().parent.parent / "schemas" / "v3"
25
+ / "synthesis.schema.json")
26
+
27
+
28
+ def _now_iso() -> str:
29
+ return datetime.now(timezone.utc).isoformat()
30
+
31
+
32
+ def _outcome_token(outcome_id: str) -> str:
33
+ return outcome_id[len("OUT-"):] if outcome_id.startswith("OUT-") else outcome_id
34
+
35
+
36
+ def _latest_audits(audits: list[dict]) -> dict[str, dict]:
37
+ latest: dict[str, dict] = {}
38
+ for a in audits:
39
+ cur = latest.get(a["study_id"])
40
+ if cur is None or a["audited_at"] >= cur["audited_at"]:
41
+ latest[a["study_id"]] = a
42
+ return latest
43
+
44
+
45
+ def synthesize_library(library: ResearchLibrary) -> dict:
46
+ """Build a LibrarySynthesis over the library's ACTIVE revision."""
47
+ findings = library.read_table("findings")
48
+ studies = {s["study_id"]: s for s in library.read_table("studies")}
49
+ sources = {s["source_id"]: s for s in library.read_table("sources")}
50
+ audits = _latest_audits(library.read_table("audits"))
51
+
52
+ by_outcome: dict[str, dict[str, Any]] = {}
53
+ usable_study_keys: set[str] = set()
54
+
55
+ for fnd in findings:
56
+ study = studies.get(fnd.get("study_id"))
57
+ if study is None:
58
+ continue
59
+ # Usability filter aligned with engine/tribunal._usable_studies (P2-11):
60
+ # unresolved identity, no validated source, or no passing audit -> not usable.
61
+ if study.get("identity_status") == "unresolved":
62
+ continue
63
+ if not any(
64
+ sid in sources and sources[sid].get("validation_status")
65
+ in ("valid", "accepted_partial")
66
+ for sid in study.get("source_ids", [])
67
+ ):
68
+ continue
69
+ audit = audits.get(fnd["study_id"])
70
+ if audit is None or audit.get("overall_status") == "fail":
71
+ continue
72
+ token = _outcome_token(fnd.get("outcome_id", ""))
73
+ row = by_outcome.setdefault(token, {
74
+ "outcome_token": token,
75
+ "positive_findings": [], "negative_findings": [],
76
+ "null_findings": [], "study_keys": [],
77
+ })
78
+ bucket = {"positive": "positive_findings",
79
+ "negative": "negative_findings"}.get(
80
+ fnd.get("effect_direction"), "null_findings")
81
+ row[bucket].append(fnd["finding_id"])
82
+ key = study.get("independence_key") or fnd["study_id"]
83
+ if key not in row["study_keys"]:
84
+ row["study_keys"].append(key)
85
+ usable_study_keys.add(key)
86
+
87
+ existing = set() # fresh synthesis id; collisions impossible in practice
88
+ synthesis = {
89
+ "synthesis_id": new_local_id("SYN", existing),
90
+ "library_revision": library.active_revision(),
91
+ "generated_at": _now_iso(),
92
+ "independent_studies": len(usable_study_keys),
93
+ "source_count": len(sources),
94
+ "outcomes": [by_outcome[k] for k in sorted(by_outcome)],
95
+ "extensions": {"finding_count": len(findings),
96
+ "study_count": len(studies)},
97
+ }
98
+ schema = json.loads(_SYNTHESIS_SCHEMA.read_text(encoding="utf-8"))
99
+ try:
100
+ validate(synthesis, schema)
101
+ except SchemaError as exc:
102
+ raise ValueError(f"invalid library synthesis: {exc}") from exc
103
+ return synthesis
104
+
105
+
106
+ def save_synthesis(synthesis: dict, out_dir: Path) -> Path:
107
+ out_dir.mkdir(parents=True, exist_ok=True)
108
+ path = out_dir / f"{synthesis['synthesis_id']}.json"
109
+ path.write_text(json.dumps(synthesis, ensure_ascii=False, indent=2) + "\n",
110
+ encoding="utf-8")
111
+ return path