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,240 @@
1
+ """V2 graph projections + V1 compatibility result.json.
2
+
3
+ Projections are read-only views of a graph revision: they never mutate graph
4
+ entities. Outcome aggregation uses Finding.effect_direction; claim trace uses
5
+ EvidenceLink.relation_to_claim; study counts use Study IDs. Compatibility
6
+ `evidence` rows are view rows with explicit finding_id/evidence_link_id in
7
+ extensions so no identity is lost.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ from engine.graph_store import GraphStore
16
+ from engine.project import ProjectWorkspace
17
+ from engine.semantics import claim_relation, decision_implication, finding_effect
18
+
19
+
20
+ def _load_latest_snapshot(project: ProjectWorkspace,
21
+ decision_snapshot_id: str | None) -> dict | None:
22
+ decisions = project.path / "decisions"
23
+ if not decisions.is_dir():
24
+ return None
25
+ if decision_snapshot_id:
26
+ p = decisions / f"{decision_snapshot_id}.json"
27
+ if p.is_file():
28
+ return json.loads(p.read_text(encoding="utf-8"))
29
+ return None
30
+ snaps = []
31
+ for p in decisions.glob("DEC-*.json"):
32
+ try:
33
+ rec = json.loads(p.read_text(encoding="utf-8"))
34
+ except (json.JSONDecodeError, OSError):
35
+ continue
36
+ snaps.append(rec)
37
+ if not snaps:
38
+ return None
39
+ # latest by created_at, never by filename (DEC ids are random hex)
40
+ return max(snaps, key=lambda r: r.get("created_at", ""))
41
+
42
+
43
+ def _load_gaps(project: ProjectWorkspace) -> list[dict]:
44
+ gaps: list[dict] = []
45
+ gaps_dir = project.path / "gaps"
46
+ if gaps_dir.is_dir():
47
+ for f in sorted(gaps_dir.glob("gaps-rev-*.jsonl")):
48
+ for line in f.read_text(encoding="utf-8").splitlines():
49
+ if line.strip():
50
+ gaps.append(json.loads(line))
51
+ return gaps
52
+
53
+
54
+ def _load_study_designs(project: ProjectWorkspace) -> list[dict]:
55
+ designs: list[dict] = []
56
+ ddir = project.path / "study-designs"
57
+ if ddir.is_dir():
58
+ for f in sorted(ddir.glob("DSN-*.json")):
59
+ designs.append(json.loads(f.read_text(encoding="utf-8")))
60
+ return designs
61
+
62
+
63
+ def build_report_projection(project: ProjectWorkspace, *,
64
+ graph_revision: int | None = None,
65
+ decision_snapshot_id: str | None = None) -> dict:
66
+ """Project the active (or named) graph revision into a report view."""
67
+ store = GraphStore.create(project)
68
+ revision = graph_revision if graph_revision is not None else store.active_revision()
69
+ if revision != store.active_revision():
70
+ raise ValueError(
71
+ f"projection of historical revision {revision} not yet supported; "
72
+ f"active revision is {store.active_revision()}")
73
+
74
+ manifest = project.manifest()
75
+ sources = store.read_table("sources")
76
+ studies = store.read_table("studies")
77
+ findings = store.read_table("findings")
78
+ outcomes = store.read_table("outcomes")
79
+ claims = store.read_table("claims")
80
+ links = store.read_table("evidence_links")
81
+ audits = store.read_table("audits")
82
+
83
+ snapshot = _load_latest_snapshot(project, decision_snapshot_id)
84
+
85
+ # outcome aggregation by effect_direction
86
+ outcome_rows: dict[str, dict] = {}
87
+ for o in outcomes:
88
+ outcome_rows[o["outcome_id"]] = {
89
+ "outcome_id": o["outcome_id"],
90
+ "name": o["name"],
91
+ "outcome_type": o["outcome_type"],
92
+ "positive_count": 0, "negative_count": 0, "null_count": 0,
93
+ "evidence_ids": [],
94
+ }
95
+ for f in findings:
96
+ row = outcome_rows.setdefault(f["outcome_id"], {
97
+ "outcome_id": f["outcome_id"], "name": f.get("measure", f["outcome_id"]),
98
+ "outcome_type": "learning", "positive_count": 0,
99
+ "negative_count": 0, "null_count": 0, "evidence_ids": []})
100
+ direction = finding_effect(f)
101
+ if direction == "positive":
102
+ row["positive_count"] += 1
103
+ elif direction == "negative":
104
+ row["negative_count"] += 1
105
+ else:
106
+ row["null_count"] += 1
107
+ row["evidence_ids"].append(f["finding_id"])
108
+
109
+ # claim trace via EvidenceLink semantics
110
+ claim_rows: list[dict] = []
111
+ for c in claims:
112
+ c_links = [l for l in links if l["claim_id"] == c["claim_id"]]
113
+ claim_rows.append({
114
+ "claim_id": c["claim_id"],
115
+ "claim": c["text"],
116
+ "claim_type": c["claim_type"],
117
+ "outcome_type": c["primary_outcome_ids"][0] if c["primary_outcome_ids"] else None,
118
+ "evidence_ids": [l["finding_id"] for l in c_links],
119
+ "status": c["status"],
120
+ "supporting_links": [l["evidence_link_id"] for l in c_links
121
+ if claim_relation(l) == "support"],
122
+ "contradicting_links": [l["evidence_link_id"] for l in c_links
123
+ if claim_relation(l) == "contradict"],
124
+ })
125
+
126
+ # evidence view rows (compat) with identity preserved
127
+ study_source: dict[str, str | None] = {}
128
+ for s in studies:
129
+ study_source[s["study_id"]] = s["source_ids"][0] if s.get("source_ids") else None
130
+ evidence_rows = []
131
+ for f in findings:
132
+ f_links = [l for l in links if l["finding_id"] == f["finding_id"]]
133
+ evidence_rows.append({
134
+ "evidence_id": f["finding_id"],
135
+ "source_id": study_source.get(f["study_id"]),
136
+ "study_id": f["study_id"],
137
+ "claim": f.get("raw_result_text", ""),
138
+ "outcome_type": f.get("measure", ""),
139
+ "relation_to_claim": claim_relation(f_links[0]) if f_links else "neutral",
140
+ "effect_direction": finding_effect(f),
141
+ "decision_relation": decision_implication(f_links[0]) if f_links else "neutral",
142
+ "source_location": f.get("source_locator", ""),
143
+ "extensions": {
144
+ "finding_id": f["finding_id"],
145
+ "evidence_link_id": f_links[0]["evidence_link_id"] if f_links else None,
146
+ },
147
+ })
148
+
149
+ return {
150
+ "project_id": project.project_id,
151
+ "graph_revision": revision,
152
+ "decision_snapshot_id": snapshot["decision_snapshot_id"] if snapshot else None,
153
+ "decision": snapshot["decision"] if snapshot else "INSUFFICIENT_EVIDENCE",
154
+ "confidence_label": snapshot["confidence_label"] if snapshot else None,
155
+ "research_frame": {
156
+ "education_question": manifest["question"],
157
+ "research_mode": manifest["research_mode"],
158
+ "decision_target": manifest["decision_target"],
159
+ },
160
+ "counts": {
161
+ "source_count": len(sources),
162
+ "study_count": len(studies),
163
+ "finding_count": len(findings),
164
+ "claim_count": len(claims),
165
+ "evidence_link_count": len(links),
166
+ },
167
+ "sources": sources,
168
+ "studies": studies,
169
+ "findings": findings,
170
+ "evidence_links": links,
171
+ "outcomes": list(outcome_rows.values()),
172
+ "claims": claim_rows,
173
+ "evidence": evidence_rows,
174
+ "methodology_reviews": audits,
175
+ "knowledge_gaps": _load_gaps(project),
176
+ "study_designs": _load_study_designs(project),
177
+ "analysis_provenance": [
178
+ {
179
+ "dataset_id": (s.get("extensions") or {}).get("dataset_id"),
180
+ "design_id": (s.get("extensions") or {}).get("design_id"),
181
+ "analysis_run_id": (s.get("extensions") or {}).get("analysis_run_id"),
182
+ }
183
+ for s in sources if s.get("origin") == "project"
184
+ ],
185
+ "provenance": {
186
+ "project_id": project.project_id,
187
+ "graph_revision": revision,
188
+ "projected_at": __import__("datetime").datetime.now(
189
+ __import__("datetime").timezone.utc).isoformat(),
190
+ },
191
+ }
192
+
193
+
194
+ def build_v1_compat_result(project: ProjectWorkspace, *,
195
+ graph_revision: int | None = None,
196
+ decision_snapshot_id: str | None = None) -> dict:
197
+ """Map the projection into the V1 result.json shape the renderer expects."""
198
+ proj = build_report_projection(project, graph_revision=graph_revision,
199
+ decision_snapshot_id=decision_snapshot_id)
200
+ return {
201
+ "meta": {"engine_version": "2.0.0", "project_id": proj["project_id"]},
202
+ "execution": {"graph_revision": proj["graph_revision"]},
203
+ "research_frame": proj["research_frame"],
204
+ "decision": {
205
+ "verdict": proj["decision"],
206
+ "confidence": proj["confidence_label"],
207
+ "summary": "",
208
+ },
209
+ "outcomes": proj["outcomes"],
210
+ "claims": proj["claims"],
211
+ "sources": proj["sources"],
212
+ "evidence": proj["evidence"],
213
+ "methodology_reviews": proj["methodology_reviews"],
214
+ "conflicts": [],
215
+ "applicability": {},
216
+ "intervention": {},
217
+ "evaluation": {},
218
+ "knowledge_gaps": proj["knowledge_gaps"],
219
+ "study_designs": proj["study_designs"],
220
+ "analysis_provenance": proj["analysis_provenance"],
221
+ "benchmark": {},
222
+ "provenance": proj["provenance"],
223
+ }
224
+
225
+
226
+ def build_localization_pack(projection: dict, *, lang: str,
227
+ localized_text: dict[str, str]) -> dict:
228
+ """Wrap a projection with a localization layer (language strings)."""
229
+ if lang not in ("zh", "en"):
230
+ raise ValueError(f"unsupported language {lang!r}")
231
+ return {
232
+ "lang": lang,
233
+ "project_id": projection.get("project_id"),
234
+ "graph_revision": projection.get("graph_revision"),
235
+ "decision_snapshot_id": projection.get("decision_snapshot_id"),
236
+ "decision": projection.get("decision"),
237
+ "confidence_label": projection.get("confidence_label"),
238
+ "localized_text": dict(localized_text),
239
+ "counts": projection.get("counts", {}),
240
+ }
@@ -0,0 +1,109 @@
1
+ """engine/robustness.py - leave-one-out sensitivity analysis for v4 meta-analysis.
2
+
3
+ leave_one_out re-run a pooling function k times, each time dropping
4
+ one study, and compare every result against the
5
+ full-sample pooled effect.
6
+ robustness_label robust vs fragile: any leave-one-out iteration whose
7
+ pooled direction flips, or whose 95% CI crosses zero,
8
+ marks the synthesis fragile.
9
+
10
+ Pure stdlib, no third-party dependencies.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Any, Callable
16
+
17
+
18
+ def _is_number(value: Any) -> bool:
19
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
20
+
21
+
22
+ def _usable(rows: list[dict]) -> list[dict]:
23
+ import math as _m
24
+ return [r for r in rows or []
25
+ if _is_number(r.get("d")) and _is_number(r.get("se")) and r["se"] > 0
26
+ and _m.isfinite(float(r["d"])) and _m.isfinite(float(r["se"]))]
27
+
28
+
29
+ def _direction(d: float) -> int:
30
+ """+1 positive, −1 negative, 0 exactly zero."""
31
+ if d > 0:
32
+ return 1
33
+ if d < 0:
34
+ return -1
35
+ return 0
36
+
37
+
38
+ def _ci_crosses_zero(pooled: dict) -> bool:
39
+ return pooled["ci_low"] <= 0.0 <= pooled["ci_high"]
40
+
41
+
42
+ def leave_one_out(rows: list[dict],
43
+ pooling_fn: Callable[[list[dict]], dict | None]) -> dict | None:
44
+ """Re-run ``pooling_fn`` on every k−1 subset; compare to the full pooled.
45
+
46
+ ``direction_flip`` is True when the dropped study reverses the pooled
47
+ direction (full-sample effect is nonzero and the subset moves to the
48
+ opposite sign); ``ci_crosses_zero`` when the subset CI spans zero;
49
+ ``decision_changed`` = either. Returns None when fewer than two usable
50
+ rows exist.
51
+ """
52
+ usable = _usable(rows)
53
+ k = len(usable)
54
+ if k < 2:
55
+ return None
56
+ full = pooling_fn(usable)
57
+ if full is None:
58
+ return None
59
+ full_dir = _direction(full["d"])
60
+ iterations = []
61
+ for i, removed in enumerate(usable):
62
+ subset = usable[:i] + usable[i + 1:]
63
+ pooled = pooling_fn(subset)
64
+ if pooled is None:
65
+ continue
66
+ direction_flip = (full_dir != 0
67
+ and _direction(pooled["d"]) != 0
68
+ and _direction(pooled["d"]) != full_dir)
69
+ ci_crosses_zero = _ci_crosses_zero(pooled)
70
+ iterations.append({
71
+ "removed_study_id": removed.get("study_id"),
72
+ "removed_index": i,
73
+ "pooled": pooled,
74
+ "direction_flip": direction_flip,
75
+ "ci_crosses_zero": ci_crosses_zero,
76
+ "decision_changed": direction_flip or ci_crosses_zero,
77
+ })
78
+ return {
79
+ "method": "leave_one_out",
80
+ "k": k,
81
+ "full_pooled": full,
82
+ "iterations": iterations,
83
+ "n_direction_flips": sum(1 for it in iterations if it["direction_flip"]),
84
+ "n_ci_crossings": sum(1 for it in iterations if it["ci_crosses_zero"]),
85
+ "n_decision_changes": sum(1 for it in iterations if it["decision_changed"]),
86
+ }
87
+
88
+
89
+ def robustness_label(results: dict | None) -> str:
90
+ """robust / fragile classification of a leave-one-out result.
91
+
92
+ Fragile when any dropped study flips the pooled direction or makes the
93
+ CI cross zero; otherwise robust. None (no analysis possible) is fragile.
94
+ """
95
+ if results is None:
96
+ return "fragile"
97
+ for it in results.get("iterations", []):
98
+ if it.get("direction_flip") or it.get("ci_crosses_zero"):
99
+ return "fragile"
100
+ return "robust"
101
+
102
+
103
+ def _mean_std(values: list[float]) -> tuple[float, float]:
104
+ """Unused helper kept for parity with the textbook SD definition."""
105
+ if not values:
106
+ return 0.0, 0.0
107
+ m = sum(values) / len(values)
108
+ v = sum((x - m) ** 2 for x in values) / (len(values) - 1) if len(values) > 1 else 0.0
109
+ return m, math.sqrt(v)
package/engine/run.py ADDED
@@ -0,0 +1,85 @@
1
+ """RunRecord lifecycle — one execution attempt or mutation inside a Project.
2
+
3
+ A Run starts at the Project's current graph revision (`graph_revision_before`)
4
+ and, on completion, records `graph_revision_after`. Runs live at
5
+ `runs/<run_id>/run.json`; a second Run never overwrites the first.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from engine.contracts import validate_record
16
+ from engine.ids import new_run_id
17
+ from engine.project import ProjectWorkspace
18
+ from engine.versions import (
19
+ CONFIDENCE_POLICY_VERSION,
20
+ METHODOLOGY_POLICY_VERSION,
21
+ SOURCE_VALIDATION_POLICY_VERSION,
22
+ )
23
+
24
+
25
+ def _now_iso() -> str:
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ def _run_path(project: ProjectWorkspace, run_id: str) -> Path:
30
+ return project.runs_dir() / run_id / "run.json"
31
+
32
+
33
+ def _atomic_write_json(path: Path, record: dict) -> None:
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ tmp = path.with_name(path.name + ".tmp")
36
+ tmp.write_text(
37
+ json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
38
+ )
39
+ os.replace(tmp, path)
40
+
41
+
42
+ def _check(record: dict) -> None:
43
+ errors = validate_record("run", record)
44
+ if errors:
45
+ raise ValueError(f"invalid run record: {errors}")
46
+
47
+
48
+ def start_run(project: ProjectWorkspace, *, purpose: str,
49
+ capabilities: list[str], execution_backend: str) -> dict:
50
+ """Open a new Run at the Project's current revision."""
51
+ run = {
52
+ "run_id": new_run_id(),
53
+ "project_id": project.project_id,
54
+ "purpose": purpose,
55
+ "started_at": _now_iso(),
56
+ "status": "running",
57
+ "graph_revision_before": project.current_revision(),
58
+ "graph_revision_after": None,
59
+ "capabilities": sorted(set(capabilities)),
60
+ "execution_backend": execution_backend,
61
+ "policy_versions": {
62
+ "source_validation": SOURCE_VALIDATION_POLICY_VERSION,
63
+ "methodology": METHODOLOGY_POLICY_VERSION,
64
+ "confidence": CONFIDENCE_POLICY_VERSION,
65
+ },
66
+ }
67
+ _check(run)
68
+ _atomic_write_json(_run_path(project, run["run_id"]), run)
69
+ return run
70
+
71
+
72
+ def finish_run(project: ProjectWorkspace, run_id: str, *,
73
+ status: str, graph_revision_after: int) -> dict:
74
+ """Close a Run with its final status and end revision."""
75
+ path = _run_path(project, run_id)
76
+ if not path.is_file():
77
+ raise FileNotFoundError(f"run {run_id!r} not found in project {project.project_id}")
78
+ run = json.loads(path.read_text(encoding="utf-8"))
79
+ if run["status"] != "running":
80
+ raise ValueError(f"run {run_id} is already {run['status']!r}; cannot finish twice")
81
+ run["status"] = status
82
+ run["graph_revision_after"] = graph_revision_after
83
+ _check(run)
84
+ _atomic_write_json(path, run)
85
+ return run
@@ -0,0 +1,135 @@
1
+ """engine/semantics.py — Centralized V2 & V4/V5 semantics and Outcome Ontology.
2
+
3
+ Combines:
4
+ 1. V2 Direction & Implication Semantics (finding_effect, claim_relation, decision_implication)
5
+ 2. V5 Social Science Outcome Ontology & OutcomeClassifier (OutcomeDimension, OutcomeClassifier)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Optional, Set
10
+
11
+ # --- V2 Direction & Relation Constants ---
12
+ FINDING_EFFECTS = ("positive", "negative", "null", "mixed", "not_applicable")
13
+ CLAIM_RELATIONS = ("support", "contradict", "neutral")
14
+ DECISION_IMPLICATIONS = ("support_adoption", "oppose_adoption", "conditional", "neutral")
15
+
16
+ _RELATION_TO_IMPLICATION = {
17
+ "support": "support_adoption",
18
+ "contradict": "oppose_adoption",
19
+ "neutral": "neutral",
20
+ }
21
+
22
+
23
+ def finding_effect(finding: dict) -> str:
24
+ """Observed effect direction of a V2 Finding."""
25
+ effect = finding.get("effect_direction")
26
+ if effect in FINDING_EFFECTS:
27
+ return effect
28
+ return "null"
29
+
30
+
31
+ def claim_relation(link: dict) -> str:
32
+ """Relation of an EvidenceLink to the Claim it binds."""
33
+ relation = link.get("relation_to_claim")
34
+ if relation in CLAIM_RELATIONS:
35
+ return relation
36
+ return "neutral"
37
+
38
+
39
+ def decision_implication(link: dict) -> str:
40
+ """Implication of an EvidenceLink for the current teaching decision."""
41
+ implication = link.get("decision_implication")
42
+ if implication in DECISION_IMPLICATIONS:
43
+ return implication
44
+ return _RELATION_TO_IMPLICATION[claim_relation(link)]
45
+
46
+
47
+ def independent_study_ids(findings: list[dict]) -> set[str]:
48
+ """Unique Study IDs behind a set of Findings (independent-study counting)."""
49
+ return {f["study_id"] for f in findings if f.get("study_id")}
50
+
51
+
52
+ def independent_sample_keys(studies: list[dict]) -> set[str]:
53
+ """Unique independence keys across Studies (independent sample counting)."""
54
+ keys = {s["independence_key"] for s in studies if s.get("independence_key")}
55
+ return keys
56
+
57
+
58
+ def graph_counts(store) -> dict[str, int]:
59
+ """Entity counts of the active graph revision."""
60
+ return {
61
+ "source_count": len(store.read_table("sources")),
62
+ "study_count": len(store.read_table("studies")),
63
+ "finding_count": len(store.read_table("findings")),
64
+ "outcome_count": len(store.read_table("outcomes")),
65
+ "claim_count": len(store.read_table("claims")),
66
+ "evidence_link_count": len(store.read_table("evidence_links")),
67
+ "audit_count": len(store.read_table("audits")),
68
+ }
69
+
70
+
71
+ # --- V5 Outcome Ontology & Dimension Classifier ---
72
+
73
+ class OutcomeDimension:
74
+ PROCEDURAL_EFFICIENCY = "PROCEDURAL_EFFICIENCY" # Speed, task velocity, immediate completion during intervention
75
+ CONCEPTUAL_MASTERY = "CONCEPTUAL_MASTERY" # Deep understanding, mental model construction, reasoning
76
+ INDEPENDENT_TRANSFER = "INDEPENDENT_TRANSFER" # Delayed retention, unassisted solo closed-book transfer
77
+ AFFECTIVE_PSYCHOSOCIAL = "AFFECTIVE_PSYCHOSOCIAL" # Anxiety, engagement, self-efficacy, motivation
78
+ SOCIOECONOMIC_POLICY = "SOCIOECONOMIC_POLICY" # Family expenditure, equity gap, resource allocation
79
+ GENERAL_MEASURE = "GENERAL_MEASURE"
80
+
81
+ ALL = [
82
+ PROCEDURAL_EFFICIENCY,
83
+ CONCEPTUAL_MASTERY,
84
+ INDEPENDENT_TRANSFER,
85
+ AFFECTIVE_PSYCHOSOCIAL,
86
+ SOCIOECONOMIC_POLICY,
87
+ GENERAL_MEASURE,
88
+ ]
89
+
90
+
91
+ class OutcomeClassifier:
92
+ """Classifies outcome metric descriptions into canonical social science dimensions."""
93
+
94
+ @staticmethod
95
+ def classify(metric_text: str) -> str:
96
+ if not metric_text:
97
+ return OutcomeDimension.GENERAL_MEASURE
98
+
99
+ t = metric_text.lower()
100
+ # 1. Independent Transfer & Delayed Retention (Highest Priority to detect Scaffolding Traps)
101
+ if any(k in t for k in [
102
+ "transfer", "retention", "delayed", "exam", "solo", "unassisted", "no-ai",
103
+ "post-test", "follow-up", "long-term", "persistence",
104
+ "迁移", "闭卷", "期末", "长期", "留存", "保持", "无ai测试", "独立解题", "手写代码"
105
+ ]):
106
+ return OutcomeDimension.INDEPENDENT_TRANSFER
107
+ # 2. Conceptual Mastery & Deep Reasoning
108
+ elif any(k in t for k in [
109
+ "thinking", "conceptual", "problem solving", "reasoning", "comprehension",
110
+ "mental model", "algorithmic", "abstraction", "debugging strategy",
111
+ "思维", "概念", "架构", "认知", "问题解决", "算法理解", "心智模型", "调试策略"
112
+ ]):
113
+ return OutcomeDimension.CONCEPTUAL_MASTERY
114
+ # 3. Procedural Efficiency & In-task velocity
115
+ elif any(k in t for k in [
116
+ "speed", "velocity", "completion", "time", "procedural", "task performance",
117
+ "accuracy", "efficiency", "correctness", "syntax", "lines of code", "loc",
118
+ "速度", "耗时", "效率", "作业完成", "完成时间", "语法正确", "即时准确率", "代码量"
119
+ ]):
120
+ return OutcomeDimension.PROCEDURAL_EFFICIENCY
121
+ # 4. Affective & Psychosocial
122
+ elif any(k in t for k in [
123
+ "anxiety", "engagement", "interest", "efficacy", "collaboration", "motivation",
124
+ "confidence", "self-regulation", "frustration",
125
+ "焦虑", "投入", "效能", "协作", "动机", "自信", "自我调节", "挫败感", "学习兴趣"
126
+ ]):
127
+ return OutcomeDimension.AFFECTIVE_PSYCHOSOCIAL
128
+ # 5. Socioeconomic & Policy
129
+ elif any(k in t for k in [
130
+ "expenditure", "cost", "equity", "burden", "socioeconomic", "disparity",
131
+ "支出", "负担", "公平", "成本", "数字鸿沟", "社会经济"
132
+ ]):
133
+ return OutcomeDimension.SOCIOECONOMIC_POLICY
134
+
135
+ return OutcomeDimension.GENERAL_MEASURE
@@ -0,0 +1,87 @@
1
+ """Evidence-grounded StudyDesign gate + persistence.
2
+
3
+ A StudyDesign may only be created from explicit KnowledgeGap entities that
4
+ exist in THIS project (same project_id) and were derived from the current
5
+ graph revision. The engine validates grounding and stores designs; it does
6
+ not itself claim ethics approval — human-subject flags are surfaced, and
7
+ institutional review remains the institution's decision.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from engine.contracts import validate_record
17
+ from engine.project import ProjectWorkspace
18
+
19
+
20
+ def _now_iso() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ def validate_design_grounding(project: ProjectWorkspace, design: dict) -> list[str]:
25
+ """Validate that the design references real, project-local GAP ids.
26
+
27
+ Returns a list of error strings (empty == valid).
28
+ """
29
+ errors: list[str] = []
30
+ # grounding gate first: an empty gap list is a grounding failure with a
31
+ # clear message, not just a schema minItems violation
32
+ if not design.get("gap_ids"):
33
+ return ["gap_ids must be non-empty: no new study design without "
34
+ "evidence grounding"]
35
+ schema_errors = validate_record("study-design", design)
36
+ if schema_errors:
37
+ errors.extend(schema_errors)
38
+ return errors
39
+
40
+ gap_ids = design["gap_ids"]
41
+
42
+ # load this project's gaps (all revisions; the gap file records revision)
43
+ gaps_dir = project.path / "gaps"
44
+ known_gap_ids: set[str] = set()
45
+ if gaps_dir.is_dir():
46
+ for f in sorted(gaps_dir.glob("gaps-rev-*.jsonl")):
47
+ for line in f.read_text(encoding="utf-8").splitlines():
48
+ if line.strip():
49
+ rec = json.loads(line)
50
+ known_gap_ids.add(rec["gap_id"])
51
+ # gap must be derived from the current graph revision
52
+ if rec["gap_id"] in gap_ids and rec.get(
53
+ "derived_from_graph_revision") != project.current_revision():
54
+ errors.append(
55
+ f"gap {rec['gap_id']} derives from revision "
56
+ f"{rec.get('derived_from_graph_revision')} but the "
57
+ f"project is at revision {project.current_revision()}")
58
+ missing = [g for g in gap_ids if g not in known_gap_ids]
59
+ if missing:
60
+ errors.append(f"design references unknown gaps: {missing}")
61
+ return errors
62
+
63
+
64
+ def save_study_design(project: ProjectWorkspace, design: dict) -> Path:
65
+ """Persist a StudyDesign under study-designs/ (never rewritten)."""
66
+ errors = validate_design_grounding(project, design)
67
+ if errors:
68
+ raise ValueError("design fails grounding gate:\n- " + "\n- ".join(errors))
69
+ path = project.path / "study-designs" / f"{design['design_id']}.json"
70
+ if path.exists():
71
+ raise FileExistsError(f"study design already exists: {path}")
72
+ path.write_text(json.dumps(design, ensure_ascii=False, indent=2) + "\n",
73
+ encoding="utf-8")
74
+ return path
75
+
76
+
77
+ def save_analysis_plan(project: ProjectWorkspace, plan: dict) -> Path:
78
+ """Persist an AnalysisPlan under study-designs/ (or analyses/)."""
79
+ errors = validate_record("analysis-plan", plan)
80
+ if errors:
81
+ raise ValueError(f"invalid analysis plan: {errors}")
82
+ path = project.path / "analyses" / f"{plan['analysis_plan_id']}.json"
83
+ if path.exists():
84
+ raise FileExistsError(f"analysis plan already exists: {path}")
85
+ path.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n",
86
+ encoding="utf-8")
87
+ return path