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,192 @@
1
+ #!/usr/bin/env python3
2
+ """citation_check — per-citation DOI verification against registries (plan E6).
3
+
4
+ Turns the v5.2.0 integrity audit into a first-class engine capability:
5
+
6
+ - every Source/Evidence citation can carry `doi_verified` and `retracted`
7
+ fields, produced by this module against Crossref (most DOIs) and DataCite
8
+ (arXiv 10.48550/*);
9
+ - verification is evidence-grade: it never guesses. A DOI that does not
10
+ resolve is `not_found`; a resolved record whose title clearly differs from
11
+ the local title is `mismatch`; a registered retraction is surfaced as
12
+ `retracted=True`.
13
+
14
+ Security: identical discipline to scripts/audit_dois.py — strict DOI shape,
15
+ two allow-listed HTTPS registry hosts, redirects confined to those hosts.
16
+
17
+ Stdlib only. Network calls happen only in `verify()`/`check_doi`;
18
+ `classify()` is pure and fully unit-testable offline.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import http.client
24
+ import json
25
+ import re
26
+ import time
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from typing import Any
31
+
32
+ CROSSREF_API = "https://api.crossref.org/works/{doi}"
33
+ DATACITE_API = "https://api.datacite.org/dois/{doi}"
34
+ ALLOWED_HOSTS = {"api.crossref.org", "api.datacite.org"}
35
+ DATACITE_PREFIXES = {"10.48550"}
36
+ MAILTO = "citation-check@eduevidence.local"
37
+
38
+ STRICT_DOI_RE = re.compile(r"^10\.\d{4,9}/[A-Za-z0-9._()/:-]+$")
39
+ DOI_CLEAN_RE = re.compile(r"\b(10\.\d{4,9}/[A-Za-z0-9._()/:;-]+)")
40
+ STRIP_SUFFIX = ".,;)"
41
+
42
+ TITLE_OVERLAP_THRESHOLD = 0.5
43
+
44
+ # Classification vocabulary used by callers and schemas.
45
+ STATUS_OK = "ok"
46
+ STATUS_MISMATCH = "mismatch"
47
+ STATUS_NOT_FOUND = "not_found"
48
+ STATUS_ERROR = "error"
49
+
50
+
51
+ def clean_doi(doi: str) -> str:
52
+ return doi.strip().rstrip(STRIP_SUFFIX)
53
+
54
+
55
+ def token_set(text: str) -> set[str]:
56
+ out: set[str] = set()
57
+ for tok in re.split(r"[^a-z0-9]+", text.lower()):
58
+ if len(tok) > 2:
59
+ out.add(tok)
60
+ if tok.endswith("s") and len(tok) > 3:
61
+ out.add(tok[:-1])
62
+ return out
63
+
64
+
65
+ def title_overlap(a: str | None, b: str | None) -> float | None:
66
+ if not a or not b:
67
+ return None
68
+ ta, tb = token_set(a), token_set(b)
69
+ if not ta or not tb:
70
+ return None
71
+ return len(ta & tb) / min(len(ta), len(tb))
72
+
73
+
74
+ def classify(doi: str, registry_status: str, registry_msg: dict[str, Any] | None,
75
+ local_title: str | None) -> dict[str, Any]:
76
+ """Pure classifier: registry outcome + optional local title → verdict dict."""
77
+ cr_title = None
78
+ if isinstance(registry_msg, dict):
79
+ titles = registry_msg.get("title") or []
80
+ cr_title = titles[0] if titles else None
81
+ status = registry_status
82
+ similarity = title_overlap(local_title, cr_title)
83
+ if status == STATUS_OK and similarity is not None and similarity < TITLE_OVERLAP_THRESHOLD:
84
+ status = STATUS_MISMATCH
85
+
86
+ retracted = False
87
+ if isinstance(registry_msg, dict) and status == STATUS_OK:
88
+ updates = registry_msg.get("update-to") or []
89
+ if isinstance(updates, list):
90
+ retracted = any(
91
+ isinstance(u, dict) and str(u.get("type", "")).lower() == "retraction"
92
+ for u in updates)
93
+ if not retracted:
94
+ # Some deposits mark retraction in the title itself ("Retracted: ...").
95
+ t = (cr_title or "").strip().lower()
96
+ retracted = t.startswith("retracted") or "(retracted" in t
97
+
98
+ result: dict[str, Any] = {
99
+ "doi": doi,
100
+ "status": status,
101
+ "doi_verified": status == STATUS_OK,
102
+ "retracted": retracted,
103
+ "registry_title": cr_title,
104
+ }
105
+ if similarity is not None:
106
+ result["title_overlap"] = round(similarity, 3)
107
+ return result
108
+
109
+
110
+ class _SameHostRedirectHandler(urllib.request.HTTPRedirectHandler):
111
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
112
+ if urllib.parse.urlparse(newurl).hostname not in ALLOWED_HOSTS:
113
+ return None
114
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
115
+
116
+
117
+ class RegistryClient:
118
+ """Small cached HTTP client bound to the two registry hosts."""
119
+
120
+ def __init__(self, timeout_s: int = 20, sleep_s: float = 0.25):
121
+ self.timeout_s = timeout_s
122
+ self.sleep_s = sleep_s
123
+ self.cache: dict[str, tuple[str, dict | None]] = {}
124
+ self.opener = urllib.request.build_opener(_SameHostRedirectHandler)
125
+
126
+ def fetch_raw(self, doi: str) -> tuple[str, dict | None]:
127
+ """Return (registry_status, payload_message) for a cleaned DOI."""
128
+ if not STRICT_DOI_RE.match(doi):
129
+ return (STATUS_ERROR, {"detail": f"strict shape check failed: {doi!r}"})
130
+ template = DATACITE_API if doi.split("/", 1)[0] in DATACITE_PREFIXES else CROSSREF_API
131
+ url = template.format(doi=urllib.request.quote(doi, safe=""))
132
+ req = urllib.request.Request(url, headers={
133
+ "User-Agent": f"EduEvidence-citation-check/1.0 (mailto:{MAILTO})"})
134
+ result: tuple[str, dict | None] = (STATUS_ERROR, None)
135
+ for attempt in range(2):
136
+ try:
137
+ with self.opener.open(req, timeout=self.timeout_s) as resp:
138
+ payload = json.loads(resp.read().decode("utf-8"))
139
+ message = payload.get("message", payload.get("data", {}))
140
+ if isinstance(message, dict) and "attributes" in message:
141
+ attrs = message["attributes"]
142
+ titles = [t.get("title") for t in attrs.get("titles", []) if t.get("title")]
143
+ message = {"title": titles}
144
+ result = (STATUS_OK, message)
145
+ break
146
+ except urllib.error.HTTPError as exc:
147
+ result = ((STATUS_NOT_FOUND, {"status": exc.code}) if exc.code == 404
148
+ else (STATUS_ERROR, {"status": exc.code}))
149
+ break
150
+ except (urllib.error.URLError, http.client.RemoteDisconnected, ConnectionError,
151
+ TimeoutError, OSError, json.JSONDecodeError) as exc:
152
+ result = (STATUS_ERROR, {"detail": str(exc)})
153
+ if attempt == 0:
154
+ time.sleep(1.5)
155
+ time.sleep(self.sleep_s)
156
+ return result
157
+
158
+ def check(self, doi: str, local_title: str | None = None) -> dict[str, Any]:
159
+ doi = clean_doi(doi)
160
+ cache_key = (doi, local_title or "")
161
+ if cache_key not in self.cache:
162
+ status, msg = self.fetch_raw(doi)
163
+ self.cache[cache_key] = classify(doi, status, msg, local_title)
164
+ return self.cache[cache_key]
165
+
166
+
167
+ def extract_dois(record: dict[str, Any]) -> list[tuple[str, str | None]]:
168
+ """Pull (doi, paired_title) pairs from a flat artifact record."""
169
+ pairs: list[tuple[str, str | None]] = []
170
+ doi_val = None
171
+ title_val = None
172
+ for key, val in record.items():
173
+ if not isinstance(val, str):
174
+ continue
175
+ k = key.strip().lower()
176
+ if k.endswith("_doi") or k == "doi":
177
+ d = clean_doi(val)
178
+ if STRICT_DOI_RE.match(d):
179
+ doi_val = d
180
+ elif k == "title" or k == "source_title":
181
+ title_val = val
182
+ if doi_val:
183
+ pairs.append((doi_val, title_val))
184
+ else:
185
+ for key, val in record.items():
186
+ if isinstance(val, str):
187
+ for raw in DOI_CLEAN_RE.findall(val):
188
+ d = clean_doi(raw)
189
+ if STRICT_DOI_RE.match(d):
190
+ pairs.append((d, title_val))
191
+ break
192
+ return pairs
@@ -0,0 +1,117 @@
1
+ """V2 schema registry and validation helpers.
2
+
3
+ Reuses the repository's existing zero-dependency Draft-07 validator
4
+ (`scripts.validate_schema`) — the engine layer never prints; errors are
5
+ returned as stable strings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ from scripts.validate_schema import SchemaError, validate
14
+
15
+ _REPO_SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schemas" / "v2"
16
+
17
+
18
+ def _resolve_schema_dir() -> Path:
19
+ """Repository layout first; wheel-installed share/ layout as fallback."""
20
+ if _REPO_SCHEMA_DIR.is_dir():
21
+ return _REPO_SCHEMA_DIR
22
+ import sys
23
+ share = Path(sys.prefix) / "share" / "eduevidence" / "schemas" / "v2"
24
+ if share.is_dir():
25
+ return share
26
+ return _REPO_SCHEMA_DIR
27
+
28
+
29
+ SCHEMA_DIR = _resolve_schema_dir()
30
+
31
+ # name -> schema file name (frozen registry)
32
+ _REGISTRY: dict[str, str] = {
33
+ "research-intent": "research-intent.schema.json",
34
+ "project": "project.schema.json",
35
+ "run": "run.schema.json",
36
+ "source": "source.schema.json",
37
+ "study": "study.schema.json",
38
+ "finding": "finding.schema.json",
39
+ "outcome": "outcome.schema.json",
40
+ "claim": "claim.schema.json",
41
+ "evidence-link": "evidence-link.schema.json",
42
+ "methodology-audit": "methodology-audit.schema.json",
43
+ "graph-revision": "graph-revision.schema.json",
44
+ "knowledge-gap": "knowledge-gap.schema.json",
45
+ "study-design": "study-design.schema.json",
46
+ "dataset-asset": "dataset-asset.schema.json",
47
+ "analysis-plan": "analysis-plan.schema.json",
48
+ "analysis-run": "analysis-run.schema.json",
49
+ "decision-snapshot": "decision-snapshot.schema.json",
50
+ }
51
+
52
+ _cache: dict[str, dict] = {}
53
+
54
+
55
+ def schema_path(name: str) -> Path:
56
+ """Resolve a registered schema name to its file; raise FileNotFoundError."""
57
+ if name not in _REGISTRY:
58
+ raise FileNotFoundError(f"unknown V2 schema name {name!r}")
59
+ return SCHEMA_DIR / _REGISTRY[name]
60
+
61
+
62
+ def load_schema(name: str) -> dict:
63
+ """Load (cached) the schema document for a registered name."""
64
+ if name not in _cache:
65
+ _cache[name] = __import__("json").loads(schema_path(name).read_text(encoding="utf-8"))
66
+ return _cache[name]
67
+
68
+
69
+ def reload_schemas() -> None:
70
+ """Drop the schema cache so later loads re-read schema files.
71
+
72
+ Needed after schema files change at runtime (e.g. tests that mutate a
73
+ schema to prove briefs are schema-derived). Use this instead of reaching
74
+ into the private `_cache`.
75
+ """
76
+ _cache.clear()
77
+
78
+
79
+ def validate_record(name: str, record: dict) -> list[str]:
80
+ """Validate `record` against the named V2 schema.
81
+
82
+ Returns a list of stable error strings (empty == valid). Errors are
83
+ collected by re-validating per-property so a single record yields all
84
+ violations, not just the first.
85
+ """
86
+ if name not in _REGISTRY:
87
+ raise FileNotFoundError(f"unknown V2 schema name {name!r}")
88
+ schema = load_schema(name)
89
+ errors: list[str] = []
90
+ try:
91
+ validate(record, schema)
92
+ except SchemaError as exc:
93
+ errors.append(str(exc))
94
+ _collect_property_errors(record, schema, errors)
95
+ return errors
96
+
97
+
98
+ def _collect_property_errors(record: dict, schema: dict, errors: list[str]) -> None:
99
+ """Best-effort per-property error collection after a whole-record failure.
100
+
101
+ Individual-property validation keeps errors stable and granular without
102
+ changing the validator's semantics.
103
+ """
104
+ props = schema.get("properties", {})
105
+ for key, prop_schema in props.items():
106
+ if key in record:
107
+ try:
108
+ validate(record[key], prop_schema, f"$.{key}")
109
+ except SchemaError as exc:
110
+ if str(exc) not in errors:
111
+ errors.append(str(exc))
112
+ required = schema.get("required", [])
113
+ for key in required:
114
+ if key not in record:
115
+ msg = f"$: required property {key!r} is missing"
116
+ if msg not in errors:
117
+ errors.append(msg)
@@ -0,0 +1,165 @@
1
+ """Immutable DatasetAsset ingest + privacy gate.
2
+
3
+ Raw bytes are copied once into datasets/raw/<dataset_id>/ and SHA-256 hashed;
4
+ re-ingesting identical bytes deduplicates by hash inside the same Project.
5
+ Raw user data never enters the Shared Research Library. Missing privacy
6
+ classification fails the gate; deidentification_required=True with
7
+ deidentification_status=not_done blocks analysis. Raw files are never
8
+ mutated after ingest.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import hashlib
15
+ import json
16
+ import shutil
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from engine.contracts import validate_record
21
+ from engine.ids import new_local_id
22
+ from engine.project import ProjectWorkspace
23
+
24
+
25
+ def _now_iso() -> str:
26
+ return datetime.now(timezone.utc).isoformat()
27
+
28
+
29
+ def _sha256(path: Path) -> str:
30
+ h = hashlib.sha256()
31
+ with path.open("rb") as f:
32
+ for chunk in iter(lambda: f.read(65536), b""):
33
+ h.update(chunk)
34
+ return h.hexdigest()
35
+
36
+
37
+ def derive_csv_profile(path: Path) -> dict:
38
+ """Row/column count + per-column missingness without pandas."""
39
+ columns: list[str] | None = None
40
+ row_count = 0
41
+ missing: dict[str, int] = {}
42
+ with path.open("r", encoding="utf-8-sig", newline="") as f:
43
+ reader = csv.reader(f)
44
+ for i, row in enumerate(reader):
45
+ if i == 0:
46
+ columns = row
47
+ for c in columns:
48
+ missing[c] = 0
49
+ continue
50
+ row_count += 1
51
+ if columns is not None:
52
+ for idx, c in enumerate(columns):
53
+ if idx >= len(row) or row[idx].strip() == "":
54
+ missing[c] += 1
55
+ if columns is None:
56
+ return {"row_count": 0, "column_count": 0, "missingness": {}}
57
+ return {
58
+ "row_count": row_count,
59
+ "column_count": len(columns),
60
+ "missingness": {c: missing[c] for c in columns},
61
+ }
62
+
63
+
64
+ def validate_dataset_asset(project: ProjectWorkspace, asset: dict) -> list[str]:
65
+ """Privacy gate + schema + project locality."""
66
+ errors: list[str] = []
67
+ schema_errors = validate_record("dataset-asset", asset)
68
+ if schema_errors:
69
+ errors.extend(schema_errors)
70
+ return errors
71
+ if asset.get("project_id") != project.project_id:
72
+ errors.append(
73
+ f"asset project_id {asset.get('project_id')} != workspace "
74
+ f"{project.project_id}")
75
+ return errors
76
+
77
+
78
+ def ingest_dataset(project: ProjectWorkspace, *, design_id: str,
79
+ source_path: Path, privacy: dict,
80
+ variable_dictionary: dict[str, str] | None = None) -> dict:
81
+ """Ingest raw dataset bytes immutably; returns the DatasetAsset dict."""
82
+ source_path = Path(source_path).expanduser().resolve()
83
+ if not source_path.is_file():
84
+ raise FileNotFoundError(f"dataset source missing: {source_path}")
85
+
86
+ # privacy gate: classification required
87
+ classification = privacy.get("classification")
88
+ if classification not in ("public", "internal", "confidential", "restricted"):
89
+ raise ValueError(
90
+ f"missing/invalid privacy classification {classification!r}; "
91
+ f"dataset ingest blocked")
92
+
93
+ content_hash = _sha256(source_path)
94
+
95
+ # dedupe by hash inside this project; a stricter classification request
96
+ # must never silently rebind to a weaker stored classification
97
+ raw_dir = project.path / "datasets" / "raw"
98
+ for existing in sorted(raw_dir.glob("DAT-*")):
99
+ manifest = existing / "manifest.json"
100
+ if manifest.is_file():
101
+ rec = json.loads(manifest.read_text(encoding="utf-8"))
102
+ if rec.get("content_hash") == content_hash:
103
+ stored = rec.get("privacy_classification")
104
+ order = ("public", "internal", "confidential", "restricted")
105
+ if order.index(classification) > order.index(stored):
106
+ raise ValueError(
107
+ f"dataset {content_hash[:10]}… already stored with "
108
+ f"weaker classification {stored!r}; re-ingesting as "
109
+ f"{classification!r} is refused"
110
+ )
111
+ return rec
112
+
113
+ dataset_id = new_local_id("DAT", {
114
+ p.name for p in raw_dir.iterdir() if p.is_dir()})
115
+ dest_dir = raw_dir / dataset_id
116
+ dest_dir.mkdir(parents=True, exist_ok=True)
117
+
118
+ # copy bytes once
119
+ dest_raw = dest_dir / source_path.name
120
+ shutil.copy2(source_path, dest_raw)
121
+
122
+ profile = derive_csv_profile(dest_raw)
123
+ asset = {
124
+ "dataset_id": dataset_id,
125
+ "project_id": project.project_id,
126
+ "design_id": design_id,
127
+ "source_type": source_path.suffix.lstrip(".").lower() or "unknown",
128
+ "path": str(dest_raw),
129
+ "content_hash": content_hash,
130
+ "schema_summary": {"columns": profile["column_count"]},
131
+ "row_count": profile["row_count"],
132
+ "column_count": profile["column_count"],
133
+ "variable_dictionary": variable_dictionary,
134
+ "privacy_classification": classification,
135
+ "consent_metadata": privacy.get("consent_metadata"),
136
+ "deidentification_status": privacy.get("deidentification_status", "not_done"),
137
+ "created_at": _now_iso(),
138
+ "extensions": {
139
+ "missingness": profile["missingness"],
140
+ "deidentification_required": bool(
141
+ privacy.get("deidentification_required", False)),
142
+ },
143
+ }
144
+ errors = validate_dataset_asset(project, asset)
145
+ if errors:
146
+ shutil.rmtree(dest_dir, ignore_errors=True)
147
+ raise ValueError("dataset asset invalid:\n- " + "\n- ".join(errors))
148
+
149
+ # atomic manifest write
150
+ tmp = dest_dir / "manifest.json.tmp"
151
+ tmp.write_text(json.dumps(asset, ensure_ascii=False, indent=2) + "\n",
152
+ encoding="utf-8")
153
+ tmp.replace(dest_dir / "manifest.json")
154
+ return asset
155
+
156
+
157
+ def analysis_blocked_by_privacy(asset: dict) -> list[str]:
158
+ """Return blocking reasons if analysis must not proceed."""
159
+ reasons: list[str] = []
160
+ if asset.get("deidentification_status") == "not_done" and (
161
+ asset.get("extensions") or {}).get("deidentification_required"):
162
+ reasons.append(
163
+ "deidentification_required but deidentification_status=not_done; "
164
+ "analysis blocked until deidentified")
165
+ return reasons
@@ -0,0 +1,67 @@
1
+ """engine/events.py — Real-time thread-safe EventBus for EduEvidence.
2
+
3
+ Provides event publishing and subscription across orchestrator, engine modules,
4
+ and dashboard SSE streams with bounded ring-buffer history.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import collections
9
+ import json
10
+ import threading
11
+ import time
12
+ from typing import Any, Callable, Deque, Dict, List, Optional
13
+
14
+
15
+ class EventBus:
16
+ _instance: Optional[EventBus] = None
17
+ _lock = threading.Lock()
18
+
19
+ def __new__(cls) -> EventBus:
20
+ with cls._lock:
21
+ if cls._instance is None:
22
+ cls._instance = super(EventBus, cls).__new__(cls)
23
+ cls._instance._subscribers = []
24
+ cls._instance._history = collections.deque(maxlen=500)
25
+ cls._instance._bus_lock = threading.RLock()
26
+ return cls._instance
27
+
28
+ def subscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None:
29
+ with self._bus_lock:
30
+ if callback not in self._subscribers:
31
+ self._subscribers.append(callback)
32
+
33
+ def unsubscribe(self, callback: Callable[[Dict[str, Any]], None]) -> None:
34
+ with self._bus_lock:
35
+ if callback in self._subscribers:
36
+ self._subscribers.remove(callback)
37
+
38
+ def publish(self, event_type: str, payload: Dict[str, Any]) -> Dict[str, Any]:
39
+ event = {
40
+ "type": event_type,
41
+ "payload": payload,
42
+ "timestamp": time.time(),
43
+ "iso_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
44
+ }
45
+ with self._bus_lock:
46
+ self._history.append(event)
47
+ subs = list(self._subscribers)
48
+
49
+ for sub in subs:
50
+ try:
51
+ sub(event)
52
+ except Exception:
53
+ pass
54
+ return event
55
+
56
+ def get_history(self, event_type: Optional[str] = None) -> List[Dict[str, Any]]:
57
+ with self._bus_lock:
58
+ if event_type:
59
+ return [e for e in self._history if e["type"] == event_type]
60
+ return list(self._history)
61
+
62
+ def clear(self) -> None:
63
+ with self._bus_lock:
64
+ self._history.clear()
65
+
66
+
67
+ event_bus = EventBus()