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.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -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/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -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/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -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/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +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/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -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
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- 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
|