eduevidence 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
@@ -0,0 +1,571 @@
1
+ """engine/evidence_graph.py — Unified SSOT Evidence Graph for EduEvidence.
2
+
3
+ Provides a unified, strongly-typed graph data model holding all 7 core node entities:
4
+ 1. PaperNode (Sources & Bibliographic records)
5
+ 2. EvidenceNode (Empirical findings, Hedges' g effect sizes, sample sizes, WWC ratings)
6
+ 3. OutcomeNode (Ontology-mapped outcome dimensions)
7
+ 4. ClaimNode (Hypothesized assertions & pooled status)
8
+ 5. RiskNode (Methodological & social science pitfalls, e.g. Scaffolding Dependency)
9
+ 6. GapNode (Identified research gaps & future trial recommendations)
10
+ 7. DecisionNode (Adjudicated four-state verdict, confidence, boundaries & protocols)
11
+
12
+ All downstream modules (Tribunal, GapLens, Meta-Analysis, Visual Reports, Web Dashboard)
13
+ consume and update this unified graph model.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import math
19
+ import time
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Set, Tuple
23
+
24
+ from engine.events import event_bus
25
+ from engine.semantics import OutcomeClassifier, OutcomeDimension
26
+
27
+
28
+ # ============================================================================
29
+ # 1. Standard Node Data Models
30
+ # ============================================================================
31
+
32
+ @dataclass
33
+ class PaperNode:
34
+ paper_id: str
35
+ title: str
36
+ authors: List[str] = field(default_factory=list)
37
+ year: int = 2024
38
+ venue: str = "Academic Publication"
39
+ doi: Optional[str] = None
40
+ url: Optional[str] = None
41
+ authority_tier: int = 1 # Tier 1 (peer-reviewed / gold) to Tier 5 (grey/web)
42
+ peer_reviewed: bool = True
43
+ summary: str = ""
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ return asdict(self)
47
+
48
+
49
+ @dataclass
50
+ class EvidenceNode:
51
+ evidence_id: str
52
+ paper_id: str
53
+ outcome_metric: str
54
+ outcome_dimension: str = OutcomeDimension.GENERAL_MEASURE
55
+ claim_id: Optional[str] = None
56
+ outcome_id: Optional[str] = None
57
+ effect_size: Dict[str, Any] = field(default_factory=lambda: {"metric": "Hedges g", "value": 0.0, "ci_lower": 0.0, "ci_upper": 0.0, "p_value": 0.05})
58
+ sample_size: int = 0
59
+ sample_description: str = ""
60
+ study_design: str = "Quasi-Experimental" # RCT, Quasi-Experimental DID, Meta-Analysis, Observational
61
+ direction: str = "NEUTRAL" # SUPPORTS, CONTRADICTS, NEUTRAL, MIXED
62
+ confidence_score: float = 0.85
63
+ wwc_rating: str = "Meets Standards with Reservations" # WWC 5.0
64
+ key_quote: str = ""
65
+ calibrated_weight: float = 1.0
66
+ bias_flag: Optional[str] = None
67
+
68
+ def __post_init__(self):
69
+ if self.outcome_dimension == OutcomeDimension.GENERAL_MEASURE and self.outcome_metric:
70
+ self.outcome_dimension = OutcomeClassifier.classify(self.outcome_metric)
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ return asdict(self)
74
+
75
+
76
+ @dataclass
77
+ class OutcomeNode:
78
+ outcome_id: str
79
+ name: str
80
+ dimension: str = OutcomeDimension.GENERAL_MEASURE
81
+ category: str = "Learning" # Learning, Task, Process, Risk, Policy
82
+ description: str = ""
83
+
84
+ def __post_init__(self):
85
+ if self.dimension == OutcomeDimension.GENERAL_MEASURE and self.name:
86
+ self.dimension = OutcomeClassifier.classify(self.name)
87
+
88
+ def to_dict(self) -> Dict[str, Any]:
89
+ return asdict(self)
90
+
91
+
92
+ @dataclass
93
+ class ClaimNode:
94
+ claim_id: str
95
+ statement: str
96
+ outcome_dimension: str = OutcomeDimension.GENERAL_MEASURE
97
+ outcome_metric: str = ""
98
+ status: str = "UNCERTAIN" # SUPPORTED, CONTRADICTS, MIXED, UNCERTAIN, INSUFFICIENT_EVIDENCE
99
+ pooled_effect_g: float = 0.0
100
+ evidence_ids: List[str] = field(default_factory=list)
101
+ bias_warning: str = ""
102
+
103
+ def to_dict(self) -> Dict[str, Any]:
104
+ return asdict(self)
105
+
106
+
107
+ @dataclass
108
+ class RiskNode:
109
+ risk_id: str
110
+ risk_type: str # Scaffolding Dependency Trap, Novice Illusion, Academic Integrity, Transfer Deficit, Equity Gap
111
+ severity: str = "MODERATE" # HIGH, MODERATE, LOW
112
+ description: str = ""
113
+ mitigation: str = ""
114
+ triggered_by_evidence_ids: List[str] = field(default_factory=list)
115
+
116
+ def to_dict(self) -> Dict[str, Any]:
117
+ return asdict(self)
118
+
119
+
120
+ @dataclass
121
+ class GapNode:
122
+ gap_id: str
123
+ gap_type: str # Population Gap, Measurement/Retention Gap, Methodological Gap, Contradiction Lens
124
+ description: str = ""
125
+ target_outcome: str = ""
126
+ existing_evidence_summary: str = ""
127
+ recommended_trial_design: str = ""
128
+
129
+ def to_dict(self) -> Dict[str, Any]:
130
+ return asdict(self)
131
+
132
+
133
+ @dataclass
134
+ class DecisionNode:
135
+ decision_id: str
136
+ verdict: str # ADOPT, PILOT, REJECT, INSUFFICIENT_EVIDENCE
137
+ confidence_score: float = 0.85
138
+ rationale: str = ""
139
+ applicability_boundary: str = ""
140
+ intervention_plan: Dict[str, Any] = field(default_factory=dict)
141
+ evaluation_plan: Dict[str, Any] = field(default_factory=dict)
142
+ stop_conditions: List[str] = field(default_factory=list)
143
+ timestamp: str = ""
144
+
145
+ def __post_init__(self):
146
+ if not self.timestamp:
147
+ self.timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
148
+
149
+ def to_dict(self) -> Dict[str, Any]:
150
+ return asdict(self)
151
+
152
+
153
+ @dataclass
154
+ class GraphEdge:
155
+ source_id: str
156
+ target_id: str
157
+ relation: str # EXTRACTED_FROM, EVALUATES, SUPPORTS, CONTRADICTS, EXPOSES_RISK,
158
+ # IDENTIFIES_GAP, GROUNDS_DECISION, MEASURES
159
+ weight: float = 1.0
160
+ metadata: Dict[str, Any] = field(default_factory=dict)
161
+
162
+ def to_dict(self) -> Dict[str, Any]:
163
+ return asdict(self)
164
+
165
+
166
+ # ============================================================================
167
+ # 2. Unified EvidenceGraph Single Source of Truth (SSOT)
168
+ # ============================================================================
169
+
170
+ class EvidenceGraph:
171
+ """The central Single Source of Truth (SSOT) evidence graph."""
172
+
173
+ def __init__(self, project_id: str = "default_project"):
174
+ self.project_id = project_id
175
+ self.revision_id: int = 1
176
+ self.intent: Dict[str, Any] = {}
177
+
178
+ # 7 Core Node Storage Tables
179
+ self.papers: Dict[str, PaperNode] = {}
180
+ self.evidence: Dict[str, EvidenceNode] = {}
181
+ self.outcomes: Dict[str, OutcomeNode] = {}
182
+ self.claims: Dict[str, ClaimNode] = {}
183
+ self.risks: Dict[str, RiskNode] = {}
184
+ self.gaps: Dict[str, GapNode] = {}
185
+ self.decision: Optional[DecisionNode] = None
186
+
187
+ # Edges Table
188
+ self.edges: List[GraphEdge] = []
189
+ self.audit_warnings: List[str] = []
190
+ self.created_at: str = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
191
+ self.updated_at: str = self.created_at
192
+
193
+ # --- Node Add / Upsert Helpers ---
194
+
195
+ def add_paper(self, paper: PaperNode) -> PaperNode:
196
+ self.papers[paper.paper_id] = paper
197
+ self._touch()
198
+ return paper
199
+
200
+ def _ensure_measures_edge(self, evidence_id: str, outcome_id: str) -> None:
201
+ """Idempotently connect an Evidence node to the Outcome it measures.
202
+
203
+ add_edge already dedupes on (source, target, relation), so repeated
204
+ upserts in either order produce exactly one MEASURES edge.
205
+ """
206
+ if evidence_id in self.evidence and outcome_id in self.outcomes:
207
+ self.add_edge(evidence_id, outcome_id, "MEASURES")
208
+ self._touch()
209
+
210
+ def add_evidence(self, ev: EvidenceNode) -> EvidenceNode:
211
+ self.evidence[ev.evidence_id] = ev
212
+ # Auto-create edge to paper if paper exists
213
+ if ev.paper_id in self.papers:
214
+ self.add_edge(ev.evidence_id, ev.paper_id, "EXTRACTED_FROM", weight=ev.calibrated_weight)
215
+ if ev.outcome_id:
216
+ self._ensure_measures_edge(ev.evidence_id, ev.outcome_id)
217
+ self._touch()
218
+ return ev
219
+
220
+ def add_outcome(self, outcome: OutcomeNode) -> OutcomeNode:
221
+ self.outcomes[outcome.outcome_id] = outcome
222
+ # Back-fill MEASURES edges for evidence already in the graph
223
+ for ev in self.evidence.values():
224
+ if ev.outcome_id == outcome.outcome_id:
225
+ self._ensure_measures_edge(ev.evidence_id, outcome.outcome_id)
226
+ self._touch()
227
+ return outcome
228
+
229
+ def add_claim(self, claim: ClaimNode) -> ClaimNode:
230
+ self.claims[claim.claim_id] = claim
231
+ for ev_id in claim.evidence_ids:
232
+ if ev_id in self.evidence:
233
+ rel = "SUPPORTS" if self.evidence[ev_id].direction == "SUPPORTS" else (
234
+ "CONTRADICTS" if self.evidence[ev_id].direction == "CONTRADICTS" else "EVALUATES"
235
+ )
236
+ self.add_edge(ev_id, claim.claim_id, rel)
237
+ self._touch()
238
+ return claim
239
+
240
+ def add_risk(self, risk: RiskNode) -> RiskNode:
241
+ self.risks[risk.risk_id] = risk
242
+ for ev_id in risk.triggered_by_evidence_ids:
243
+ if ev_id in self.evidence:
244
+ self.add_edge(ev_id, risk.risk_id, "EXPOSES_RISK")
245
+ self._touch()
246
+ return risk
247
+
248
+ def add_gap(self, gap: GapNode) -> GapNode:
249
+ self.gaps[gap.gap_id] = gap
250
+ self._touch()
251
+ return gap
252
+
253
+ def set_decision(self, decision: DecisionNode) -> DecisionNode:
254
+ self.decision = decision
255
+ # Connect decision to claims & risks
256
+ for c_id in self.claims:
257
+ self.add_edge(c_id, decision.decision_id, "GROUNDS_DECISION")
258
+ for r_id in self.risks:
259
+ self.add_edge(r_id, decision.decision_id, "GROUNDS_DECISION")
260
+ self._touch()
261
+ return decision
262
+
263
+ def add_edge(self, source_id: str, target_id: str, relation: str, weight: float = 1.0, metadata: Optional[Dict[str, Any]] = None) -> GraphEdge:
264
+ # Check duplicate
265
+ for e in self.edges:
266
+ if e.source_id == source_id and e.target_id == target_id and e.relation == relation:
267
+ e.weight = weight
268
+ if metadata:
269
+ e.metadata.update(metadata)
270
+ return e
271
+ edge = GraphEdge(source_id=source_id, target_id=target_id, relation=relation, weight=weight, metadata=metadata or {})
272
+ self.edges.append(edge)
273
+ return edge
274
+
275
+ def _touch(self):
276
+ self.updated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
277
+
278
+ # --- Query & Aggregation Helpers ---
279
+
280
+ def get_evidence_by_dimension(self, dimension: str) -> List[EvidenceNode]:
281
+ return [ev for ev in self.evidence.values() if ev.outcome_dimension == dimension]
282
+
283
+ def get_evidence_for_claim(self, claim_id: str) -> List[EvidenceNode]:
284
+ claim = self.claims.get(claim_id)
285
+ if not claim:
286
+ return []
287
+ return [self.evidence[eid] for eid in claim.evidence_ids if eid in self.evidence]
288
+
289
+ def get_forest_plot_data(self) -> List[Dict[str, Any]]:
290
+ """Extracts structured forest plot data points across all evidence nodes."""
291
+ points = []
292
+ for ev in self.evidence.values():
293
+ paper = self.papers.get(ev.paper_id)
294
+ study_label = f"{paper.authors[0] if paper and paper.authors else ev.paper_id} ({paper.year if paper else ''})"
295
+ effect_val = ev.effect_size.get("value", 0.0)
296
+ ci_l = ev.effect_size.get("ci_lower")
297
+ ci_u = ev.effect_size.get("ci_upper")
298
+ has_ci = ci_l is not None and ci_u is not None and float(ci_u) >= float(ci_l)
299
+ points.append({
300
+ "evidence_id": ev.evidence_id,
301
+ "study_label": study_label,
302
+ "venue": paper.venue if paper else "Academic",
303
+ "outcome_metric": ev.outcome_metric,
304
+ "outcome_dimension": ev.outcome_dimension,
305
+ "effect_size": effect_val,
306
+ "ci_lower": round(float(ci_l), 3) if has_ci else None,
307
+ "ci_upper": round(float(ci_u), 3) if has_ci else None,
308
+ "precision_provenance": "reported_ci" if has_ci else "not_available",
309
+ "sample_size": ev.sample_size,
310
+ "weight": ev.calibrated_weight,
311
+ "direction": ev.direction,
312
+ "wwc_rating": ev.wwc_rating,
313
+ })
314
+ # Sort by dimension and effect size
315
+ return sorted(points, key=lambda p: (p["outcome_dimension"], p["effect_size"]), reverse=True)
316
+
317
+ def compute_meta_synthesis(self) -> Dict[str, Any]:
318
+ """Calculates pooled effect sizes across outcome dimensions using Inverse-Variance weighting."""
319
+ by_dim: Dict[str, List[EvidenceNode]] = {}
320
+ for ev in self.evidence.values():
321
+ by_dim.setdefault(ev.outcome_dimension, []).append(ev)
322
+
323
+ dim_results = {}
324
+ for dim, nodes in by_dim.items():
325
+ effects = []
326
+ variances = []
327
+ precision_counts = {"reported_ci": 0, "derived_from_sample_size": 0}
328
+ excluded_no_precision = 0
329
+ for n in nodes:
330
+ eff = n.effect_size.get("value")
331
+ if eff is None or math.isnan(float(eff)) or math.isinf(float(eff)):
332
+ continue
333
+
334
+ # Statistical variance derivation (Borenstein et al. 2009)
335
+ ci_l = n.effect_size.get("ci_lower")
336
+ ci_u = n.effect_size.get("ci_upper")
337
+ if ci_l is not None and ci_u is not None and float(ci_u) > float(ci_l):
338
+ se = (float(ci_u) - float(ci_l)) / (2.0 * 1.95996)
339
+ precision_counts["reported_ci"] += 1
340
+ elif n.sample_size and n.sample_size > 0:
341
+ eff_f = float(eff)
342
+ se = math.sqrt(4.0 / n.sample_size + (eff_f ** 2) / (2.0 * n.sample_size))
343
+ precision_counts["derived_from_sample_size"] += 1
344
+ else:
345
+ # No CI and no sample size: precision is genuinely unknown;
346
+ # never substitute a default SE (no false precision).
347
+ excluded_no_precision += 1
348
+ continue
349
+
350
+ effects.append(float(eff))
351
+ variances.append(max(1e-4, se ** 2))
352
+
353
+ if not effects:
354
+ continue
355
+
356
+ inv_w = [1.0 / v for v in variances]
357
+ sum_w = sum(inv_w)
358
+ weighted_g = sum(e * w for e, w in zip(effects, inv_w)) / sum_w
359
+
360
+ k = len(effects)
361
+ if k > 1:
362
+ q_stat = sum(w * ((e - weighted_g) ** 2) for e, w in zip(effects, inv_w))
363
+ df = k - 1
364
+ i_squared = max(0.0, min(100.0, ((q_stat - df) / q_stat) * 100.0)) if q_stat > df else 0.0
365
+ else:
366
+ q_stat = 0.0
367
+ i_squared = 0.0
368
+
369
+ dim_results[dim] = {
370
+ "dimension": dim,
371
+ "studies_count": len(effects),
372
+ "pooled_g": round(weighted_g, 3),
373
+ "q_statistic": round(q_stat, 2),
374
+ "i_squared": round(i_squared, 1),
375
+ "precision_sources": precision_counts,
376
+ "excluded_no_precision": excluded_no_precision,
377
+ "interpretation": (
378
+ "Substantial Positive Effect" if weighted_g > 0.50 else (
379
+ "Moderate Positive Effect" if weighted_g > 0.20 else (
380
+ "Null / Negligible Effect" if weighted_g >= -0.05 else "Negative Deficit"
381
+ )
382
+ )
383
+ )
384
+ }
385
+ return dim_results
386
+
387
+ def export_echarts_graph(self) -> Dict[str, Any]:
388
+ """Exports graph topology formatted for ECharts Force graph visualization."""
389
+ category_map = {
390
+ "PaperNode": 0,
391
+ "EvidenceNode": 1,
392
+ "ClaimNode": 2,
393
+ "OutcomeNode": 3,
394
+ "RiskNode": 4,
395
+ "GapNode": 5,
396
+ "DecisionNode": 6,
397
+ }
398
+ categories = [
399
+ {"name": "Literature Paper (文献)"},
400
+ {"name": "Evidence Node (实证发现)"},
401
+ {"name": "Claim (研究主张)"},
402
+ {"name": "Outcome (测量指标)"},
403
+ {"name": "Methodological Risk (学术陷阱)"},
404
+ {"name": "Research Gap (学术缺口)"},
405
+ {"name": "Decision (裁决快照)"},
406
+ ]
407
+
408
+ nodes = []
409
+ for p in self.papers.values():
410
+ nodes.append({
411
+ "id": p.paper_id,
412
+ "name": f"{p.authors[0] if p.authors else p.paper_id} ({p.year})",
413
+ "category": category_map["PaperNode"],
414
+ "category_name": "PaperNode (文献)",
415
+ "symbolSize": 28,
416
+ "value": p.title,
417
+ "authors": p.authors,
418
+ "year": p.year,
419
+ "venue": p.venue,
420
+ "doi": p.doi,
421
+ "tier": p.authority_tier,
422
+ "quote": p.summary,
423
+ })
424
+ for ev in self.evidence.values():
425
+ effect_val = ev.effect_size.get("value", 0.0)
426
+ symbol_size = max(18, min(45, int(18 + abs(effect_val) * 20)))
427
+ nodes.append({
428
+ "id": ev.evidence_id,
429
+ "name": f"{ev.evidence_id}: {ev.outcome_metric} (g={effect_val:+.2f})",
430
+ "category": category_map["EvidenceNode"],
431
+ "category_name": "EvidenceNode (实证效应量)",
432
+ "symbolSize": symbol_size,
433
+ "dimension": ev.outcome_dimension,
434
+ "direction": ev.direction,
435
+ "effect_size": effect_val,
436
+ "ci_lower": ev.effect_size.get("ci_lower", "N/A"),
437
+ "ci_upper": ev.effect_size.get("ci_upper", "N/A"),
438
+ "sample_size": ev.sample_size,
439
+ "wwc_rating": ev.wwc_rating,
440
+ "quote": ev.key_quote,
441
+ })
442
+ for o in self.outcomes.values():
443
+ nodes.append({
444
+ "id": o.outcome_id,
445
+ "name": f"📊 {o.name}",
446
+ "category": category_map["OutcomeNode"],
447
+ "category_name": "OutcomeNode (测量指标)",
448
+ "symbolSize": 30,
449
+ "dimension": o.dimension,
450
+ "value": o.description,
451
+ })
452
+ for c in self.claims.values():
453
+ nodes.append({
454
+ "id": c.claim_id,
455
+ "name": f"{c.claim_id}: {c.statement[:24]}...",
456
+ "category": category_map["ClaimNode"],
457
+ "category_name": "ClaimNode (科学主张)",
458
+ "symbolSize": 34,
459
+ "status": c.status,
460
+ "pooled_g": c.pooled_effect_g,
461
+ "value": c.statement,
462
+ "quote": c.bias_warning,
463
+ })
464
+ for r in self.risks.values():
465
+ nodes.append({
466
+ "id": r.risk_id,
467
+ "name": f"⚠️ {r.risk_type}",
468
+ "category": category_map["RiskNode"],
469
+ "category_name": "RiskNode (方法学风险)",
470
+ "symbolSize": 36,
471
+ "severity": r.severity,
472
+ "value": r.description,
473
+ "quote": r.mitigation,
474
+ })
475
+ for g in self.gaps.values():
476
+ nodes.append({
477
+ "id": g.gap_id,
478
+ "name": f"🔬 {g.gap_type}",
479
+ "category": category_map["GapNode"],
480
+ "category_name": "GapNode (研究缺口)",
481
+ "symbolSize": 32,
482
+ "value": g.description,
483
+ "quote": g.recommended_trial_design,
484
+ })
485
+ if self.decision:
486
+ nodes.append({
487
+ "id": self.decision.decision_id,
488
+ "name": f"⚖️ {self.decision.verdict}",
489
+ "category": category_map["DecisionNode"],
490
+ "category_name": "DecisionNode (裁决快照)",
491
+ "symbolSize": 50,
492
+ "verdict": self.decision.verdict,
493
+ "confidence": self.decision.confidence_score,
494
+ "value": self.decision.rationale,
495
+ "quote": self.decision.applicability_boundary,
496
+ })
497
+
498
+ links = []
499
+ for e in self.edges:
500
+ line_color = (
501
+ "#2ea043" if e.relation == "SUPPORTS" else (
502
+ "#f85149" if e.relation in ("CONTRADICTS", "EXPOSES_RISK") else "#58a6ff"
503
+ )
504
+ )
505
+ links.append({
506
+ "source": e.source_id,
507
+ "target": e.target_id,
508
+ "label": {"show": True, "formatter": e.relation},
509
+ "lineStyle": {"color": line_color, "width": max(1.0, e.weight * 2.0)},
510
+ })
511
+
512
+ return {
513
+ "categories": categories,
514
+ "nodes": nodes,
515
+ "links": links,
516
+ }
517
+
518
+ # --- Serialization & Persistence ---
519
+
520
+ def to_dict(self) -> Dict[str, Any]:
521
+ return {
522
+ "project_id": self.project_id,
523
+ "revision_id": self.revision_id,
524
+ "intent": self.intent,
525
+ "created_at": self.created_at,
526
+ "updated_at": self.updated_at,
527
+ "audit_warnings": self.audit_warnings,
528
+ "papers": {k: v.to_dict() for k, v in self.papers.items()},
529
+ "evidence": {k: v.to_dict() for k, v in self.evidence.items()},
530
+ "outcomes": {k: v.to_dict() for k, v in self.outcomes.items()},
531
+ "claims": {k: v.to_dict() for k, v in self.claims.items()},
532
+ "risks": {k: v.to_dict() for k, v in self.risks.items()},
533
+ "gaps": {k: v.to_dict() for k, v in self.gaps.items()},
534
+ "decision": self.decision.to_dict() if self.decision else None,
535
+ "edges": [e.to_dict() for e in self.edges],
536
+ }
537
+
538
+ def to_json(self, indent: int = 2) -> str:
539
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
540
+
541
+ @classmethod
542
+ def from_dict(cls, data: Dict[str, Any]) -> EvidenceGraph:
543
+ graph = cls(project_id=data.get("project_id", "default_project"))
544
+ graph.revision_id = data.get("revision_id", 1)
545
+ graph.intent = data.get("intent", {})
546
+ graph.created_at = data.get("created_at", graph.created_at)
547
+ graph.updated_at = data.get("updated_at", graph.updated_at)
548
+ graph.audit_warnings = data.get("audit_warnings", [])
549
+
550
+ for k, v in data.get("papers", {}).items():
551
+ graph.papers[k] = PaperNode(**v)
552
+ for k, v in data.get("evidence", {}).items():
553
+ graph.evidence[k] = EvidenceNode(**v)
554
+ for k, v in data.get("outcomes", {}).items():
555
+ graph.outcomes[k] = OutcomeNode(**v)
556
+ for k, v in data.get("claims", {}).items():
557
+ graph.claims[k] = ClaimNode(**v)
558
+ for k, v in data.get("risks", {}).items():
559
+ graph.risks[k] = RiskNode(**v)
560
+ for k, v in data.get("gaps", {}).items():
561
+ graph.gaps[k] = GapNode(**v)
562
+ if data.get("decision"):
563
+ graph.decision = DecisionNode(**data["decision"])
564
+ for e in data.get("edges", []):
565
+ graph.edges.append(GraphEdge(**e))
566
+
567
+ return graph
568
+
569
+ @classmethod
570
+ def from_json(cls, json_str: str) -> EvidenceGraph:
571
+ return cls.from_dict(json.loads(json_str))
@@ -0,0 +1,88 @@
1
+ """Graph-oriented evidence review mutations.
2
+
3
+ Every scientific change goes through GraphStore.commit() — nothing in this
4
+ module writes JSONL directly. Ingestion gates:
5
+ - a Source whose validation_status is neither valid nor accepted_partial
6
+ cannot be used to build a validated Finding path;
7
+ - Findings/Studies must reference existing Source provenance;
8
+ - Claims may link a negative Finding without changing its direction;
9
+ - counter-evidence Findings are never dropped for unfavorable implications.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from engine.graph_store import GraphStore, GraphMutation, GraphRevision
15
+
16
+ VALID_SOURCE_STATUSES = ("valid", "accepted_partial")
17
+
18
+
19
+ def ingest_validated_sources(store: GraphStore, *, run_id: str,
20
+ sources: list[dict]) -> GraphRevision:
21
+ """Commit validated sources (only valid/accepted_partial statuses)."""
22
+ for src in sources:
23
+ if src.get("validation_status") not in VALID_SOURCE_STATUSES:
24
+ raise ValueError(
25
+ f"source {src.get('source_id')} has status "
26
+ f"{src.get('validation_status')!r}; only {list(VALID_SOURCE_STATUSES)} "
27
+ f"can enter the graph as validated evidence"
28
+ )
29
+ return store.commit(
30
+ run_id=run_id, reason="ingest validated sources",
31
+ mutation=GraphMutation(upserts={"sources": sources}, retire_ids={}))
32
+
33
+
34
+ def ingest_extracted_studies_findings(store: GraphStore, *, run_id: str,
35
+ studies: list[dict],
36
+ findings: list[dict]) -> GraphRevision:
37
+ """Commit extracted Studies + Findings (must reference existing Sources)."""
38
+ source_ids = {s["source_id"] for s in store.read_table("sources")}
39
+ study_ids = {s["study_id"] for s in store.read_table("studies")}
40
+ for s in studies:
41
+ missing = [sid for sid in s.get("source_ids", []) if sid not in source_ids]
42
+ if missing:
43
+ raise ValueError(
44
+ f"study {s.get('study_id')} references missing sources: {missing}")
45
+ for f in findings:
46
+ if f.get("study_id") not in study_ids and f.get("study_id") not in {
47
+ s.get("study_id") for s in studies}:
48
+ raise ValueError(
49
+ f"finding {f.get('finding_id')} references absent study "
50
+ f"{f.get('study_id')}; provenance missing")
51
+ return store.commit(
52
+ run_id=run_id, reason="ingest studies and findings",
53
+ mutation=GraphMutation(upserts={"studies": studies, "findings": findings},
54
+ retire_ids={}))
55
+
56
+
57
+ def ingest_methodology_audits(store: GraphStore, *, run_id: str,
58
+ audits: list[dict]) -> GraphRevision:
59
+ """Commit methodology audits (must reference existing Studies)."""
60
+ study_ids = {s["study_id"] for s in store.read_table("studies")}
61
+ for a in audits:
62
+ if a.get("study_id") not in study_ids:
63
+ raise ValueError(
64
+ f"audit {a.get('audit_id')} references missing study "
65
+ f"{a.get('study_id')}")
66
+ return store.commit(
67
+ run_id=run_id, reason="ingest methodology audits",
68
+ mutation=GraphMutation(upserts={"audits": audits}, retire_ids={}))
69
+
70
+
71
+ def ingest_claims_links(store: GraphStore, *, run_id: str,
72
+ claims: list[dict], links: list[dict]) -> GraphRevision:
73
+ """Commit Claims + EvidenceLinks (must reference existing Findings).
74
+
75
+ A link may support a negative Finding; the Finding's effect_direction is
76
+ never rewritten by the link. Counter-evidence links with unfavorable
77
+ decision implications are preserved.
78
+ """
79
+ finding_ids = {f["finding_id"] for f in store.read_table("findings")}
80
+ for link in links:
81
+ if link.get("finding_id") not in finding_ids:
82
+ raise ValueError(
83
+ f"evidence_link {link.get('evidence_link_id')} references "
84
+ f"missing finding {link.get('finding_id')}")
85
+ return store.commit(
86
+ run_id=run_id, reason="ingest claims and links",
87
+ mutation=GraphMutation(upserts={"claims": claims, "evidence_links": links},
88
+ retire_ids={}))