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,335 @@
1
+ """Immutable revision snapshots with atomic HEAD commits.
2
+
3
+ A commit either fully lands (revision dir + HEAD + project mirror) or leaves
4
+ the prior active revision intact. A crash after the immutable revision
5
+ directory exists but before HEAD changes leaves only an inactive orphan
6
+ revision, which readers ignore. `graph/HEAD` is authoritative; the
7
+ `project.json.graph_revision` mirror is repaired/flagged on divergence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from engine.contracts import validate_record
20
+ from engine.graph_validate import validate_graph
21
+ from engine.project import ProjectWorkspace
22
+
23
+ GRAPH_TABLES = (
24
+ "sources", "studies", "findings", "outcomes",
25
+ "claims", "evidence_links", "audits",
26
+ )
27
+
28
+ # graph table name -> entity id field
29
+ _TABLE_ID_KEY = {
30
+ "sources": "source_id",
31
+ "studies": "study_id",
32
+ "findings": "finding_id",
33
+ "outcomes": "outcome_id",
34
+ "claims": "claim_id",
35
+ "evidence_links": "evidence_link_id",
36
+ "audits": "audit_id",
37
+ }
38
+
39
+ # graph table name -> schema name
40
+ _TABLE_SCHEMA = {
41
+ "sources": "source",
42
+ "studies": "study",
43
+ "findings": "finding",
44
+ "outcomes": "outcome",
45
+ "claims": "claim",
46
+ "evidence_links": "evidence-link",
47
+ "audits": "methodology-audit",
48
+ }
49
+
50
+ _REV_FORMAT = "rev-{:06d}"
51
+
52
+
53
+ def _now_iso() -> str:
54
+ return datetime.now(timezone.utc).isoformat()
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class GraphMutation:
59
+ upserts: dict[str, list[dict]] = field(default_factory=dict)
60
+ retire_ids: dict[str, list[str]] = field(default_factory=dict)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class GraphRevision:
65
+ revision: int
66
+ parent_revision: int
67
+ run_id: str
68
+ reason: str
69
+ touched_entities: dict[str, list[str]]
70
+ before_hash: str
71
+ after_hash: str
72
+ created_at: str
73
+
74
+
75
+ def _table_hash(rows: list[dict], id_key: str) -> str:
76
+ """Deterministic canonical hash of one table (sorted by entity id)."""
77
+ ordered = sorted(rows, key=lambda r: r[id_key])
78
+ h = hashlib.sha256()
79
+ for row in ordered:
80
+ h.update(json.dumps(row, sort_keys=True, separators=(",", ":")).encode("utf-8"))
81
+ h.update(b"\n")
82
+ return h.hexdigest()
83
+
84
+
85
+ class GraphStore:
86
+ """Versioned evidence graph for one Project."""
87
+
88
+ def __init__(self, project: ProjectWorkspace):
89
+ self.project = project
90
+ self.graph_dir = project.path / "graph"
91
+ self.revisions_dir = self.graph_dir / "revisions"
92
+ self.head_path = self.graph_dir / "HEAD"
93
+
94
+ @classmethod
95
+ def create(cls, project: ProjectWorkspace) -> "GraphStore":
96
+ store = cls(project)
97
+ store.revisions_dir.mkdir(parents=True, exist_ok=True)
98
+ if not store.head_path.is_file():
99
+ _atomic_write_text(store.head_path, "0")
100
+ return store
101
+
102
+ # ---- reads -----------------------------------------------------------
103
+
104
+ def active_revision(self) -> int:
105
+ return int(self.head_path.read_text(encoding="utf-8").strip())
106
+
107
+ def _revision_dir(self, revision: int) -> Path:
108
+ return self.revisions_dir / _REV_FORMAT.format(revision)
109
+
110
+ def _read_snapshot(self, revision: int) -> dict[str, list[dict]]:
111
+ """Read the complete active snapshot; empty tables for revision 0."""
112
+ if revision == 0:
113
+ return {table: [] for table in GRAPH_TABLES}
114
+ rev_dir = self._revision_dir(revision)
115
+ snapshot: dict[str, list[dict]] = {}
116
+ for table in GRAPH_TABLES:
117
+ path = rev_dir / f"{table}.jsonl"
118
+ if not path.is_file():
119
+ raise FileNotFoundError(
120
+ f"revision {revision} is missing table file {path.name}; "
121
+ f"graph is corrupt"
122
+ )
123
+ rows = []
124
+ for line in path.read_text(encoding="utf-8").splitlines():
125
+ if line.strip():
126
+ rows.append(json.loads(line))
127
+ snapshot[table] = rows
128
+ return snapshot
129
+
130
+ def read_table(self, table: str) -> list[dict]:
131
+ if table not in GRAPH_TABLES:
132
+ raise ValueError(f"unknown graph table {table!r}")
133
+ return self._read_snapshot(self.active_revision())[table]
134
+
135
+ def get(self, table: str, entity_id: str) -> dict | None:
136
+ id_key = _TABLE_ID_KEY[table]
137
+ for row in self.read_table(table):
138
+ if row[id_key] == entity_id:
139
+ return row
140
+ return None
141
+
142
+ def canonical_hash(self, revision: int | None = None) -> str:
143
+ """Deterministic canonical hash of a snapshot (default: active)."""
144
+ snapshot = self._read_snapshot(
145
+ self.active_revision() if revision is None else revision
146
+ )
147
+ return self._hash_snapshot(snapshot)
148
+
149
+ @staticmethod
150
+ def _hash_snapshot(snapshot: dict[str, list[dict]]) -> str:
151
+ h = hashlib.sha256()
152
+ for table in GRAPH_TABLES:
153
+ h.update(_table_hash(snapshot.get(table, []), _TABLE_ID_KEY[table]).encode("utf-8"))
154
+ h.update(b"\n")
155
+ return h.hexdigest()
156
+
157
+ @classmethod
158
+ def empty_graph_hash(cls) -> str:
159
+ return cls._hash_snapshot({t: [] for t in GRAPH_TABLES})
160
+
161
+ # ---- validation ------------------------------------------------------
162
+
163
+ def validate(self) -> list[str]:
164
+ """Cross-entity integrity + HEAD/mirror consistency of active state."""
165
+ problems: list[str] = []
166
+ try:
167
+ snapshot = self._read_snapshot(self.active_revision())
168
+ except FileNotFoundError as exc:
169
+ return [str(exc)]
170
+ problems.extend(validate_graph(snapshot))
171
+ mirror = self.project.current_revision()
172
+ head = self.active_revision()
173
+ if mirror != head:
174
+ problems.append(
175
+ f"project.json graph_revision ({mirror}) diverges from "
176
+ f"graph/HEAD ({head}); repair with repair_head_mirror()"
177
+ )
178
+ return problems
179
+
180
+ def repair_head_mirror(self) -> None:
181
+ """HEAD is authoritative; mirror the project manifest to it."""
182
+ self.project.update_manifest(graph_revision=self.active_revision())
183
+
184
+ # ---- commit ----------------------------------------------------------
185
+
186
+ def commit(self, *, run_id: str, reason: str,
187
+ mutation: GraphMutation) -> GraphRevision:
188
+ before_rev = self.active_revision()
189
+ before_snapshot = self._read_snapshot(before_rev)
190
+ next_rev = before_rev + 1
191
+
192
+ # 1. apply mutation in memory
193
+ after_snapshot = self._apply(before_snapshot, mutation)
194
+
195
+ # 2. per-entity schema validation
196
+ for table, rows in after_snapshot.items():
197
+ schema = _TABLE_SCHEMA[table]
198
+ id_key = _TABLE_ID_KEY[table]
199
+ for row in rows:
200
+ errors = validate_record(schema, row)
201
+ if errors:
202
+ raise ValueError(
203
+ f"commit rejected: {table} {row.get(id_key, '?')} "
204
+ f"fails schema: {'; '.join(errors)}"
205
+ )
206
+
207
+ # 3. cross-entity validation
208
+ problems = validate_graph(after_snapshot)
209
+ if problems:
210
+ raise ValueError(
211
+ "commit rejected: cross-entity integrity:\n- " + "\n- ".join(problems)
212
+ )
213
+
214
+ before_hash = self._hash_snapshot(before_snapshot)
215
+ after_hash = self._hash_snapshot(after_snapshot)
216
+
217
+ # 4. write the complete next snapshot to a temp revision dir
218
+ tmp_dir = self.revisions_dir / f".tmp-{run_id}"
219
+ if tmp_dir.exists():
220
+ import shutil
221
+ shutil.rmtree(tmp_dir)
222
+ tmp_dir.mkdir(parents=True)
223
+ try:
224
+ for table in GRAPH_TABLES:
225
+ _write_jsonl(tmp_dir / f"{table}.jsonl", after_snapshot[table])
226
+ touched = {
227
+ table: [row[_TABLE_ID_KEY[table]] for row in mutation.upserts.get(table, [])]
228
+ + list(mutation.retire_ids.get(table, []))
229
+ for table in GRAPH_TABLES
230
+ if mutation.upserts.get(table) or mutation.retire_ids.get(table)
231
+ }
232
+ manifest = {
233
+ "revision": next_rev,
234
+ "parent_revision": before_rev,
235
+ "run_id": run_id,
236
+ "reason": reason,
237
+ "touched_entities": touched,
238
+ "before_hash": before_hash,
239
+ "after_hash": after_hash,
240
+ "created_at": _now_iso(),
241
+ "extensions": {},
242
+ }
243
+ manifest_errors = validate_record("graph-revision", manifest)
244
+ if manifest_errors:
245
+ raise ValueError(
246
+ f"commit rejected: revision manifest invalid: "
247
+ f"{'; '.join(manifest_errors)}")
248
+ _write_json(tmp_dir / "manifest.json", manifest)
249
+
250
+ # 5. atomically promote: .tmp-<run> -> rev-00000N
251
+ rev_dir = self._revision_dir(next_rev)
252
+ if rev_dir.exists():
253
+ # an orphan occupying the next revision number (not reached
254
+ # by HEAD) is inactive: remove it and retake the number
255
+ if self.active_revision() == before_rev:
256
+ import shutil
257
+ shutil.rmtree(rev_dir)
258
+ else:
259
+ raise FileExistsError(
260
+ f"refusing to rewrite existing revision directory {rev_dir}"
261
+ )
262
+ os.replace(tmp_dir, rev_dir)
263
+
264
+
265
+ # 6. atomically switch HEAD
266
+ _atomic_write_text(self.head_path, str(next_rev))
267
+
268
+ # 7. atomically mirror into project.json; on failure roll HEAD
269
+ # back so the caller never sees a failure for a landed commit
270
+ try:
271
+ self.project.update_manifest(graph_revision=next_rev)
272
+ except Exception:
273
+ _atomic_write_text(self.head_path, str(before_rev))
274
+ raise
275
+ except Exception:
276
+ # never leave a half-promoted state behind
277
+ if tmp_dir.exists():
278
+ import shutil
279
+ shutil.rmtree(tmp_dir)
280
+ raise
281
+
282
+ return GraphRevision(
283
+ revision=next_rev,
284
+ parent_revision=before_rev,
285
+ run_id=run_id,
286
+ reason=reason,
287
+ touched_entities=touched,
288
+ before_hash=before_hash,
289
+ after_hash=after_hash,
290
+ created_at=manifest["created_at"],
291
+ )
292
+
293
+ @staticmethod
294
+ def _apply(snapshot: dict[str, list[dict]],
295
+ mutation: GraphMutation) -> dict[str, list[dict]]:
296
+ after: dict[str, list[dict]] = {
297
+ table: [dict(row) for row in rows] for table, rows in snapshot.items()
298
+ }
299
+ for table, upserts in mutation.upserts.items():
300
+ if table not in GRAPH_TABLES:
301
+ raise ValueError(f"unknown graph table {table!r}")
302
+ id_key = _TABLE_ID_KEY[table]
303
+ by_id = {row[id_key]: row for row in after[table]}
304
+ for row in upserts:
305
+ if id_key not in row:
306
+ raise ValueError(f"{table} upsert missing {id_key}: {row!r}")
307
+ by_id[row[id_key]] = dict(row)
308
+ after[table] = list(by_id.values())
309
+ for table, retire_ids in mutation.retire_ids.items():
310
+ if table not in GRAPH_TABLES:
311
+ raise ValueError(f"unknown graph table {table!r}")
312
+ id_key = _TABLE_ID_KEY[table]
313
+ retired = set(retire_ids)
314
+ after[table] = [row for row in after[table] if row[id_key] not in retired]
315
+ return after
316
+
317
+
318
+ def _atomic_write_text(path: Path, text: str) -> None:
319
+ path.parent.mkdir(parents=True, exist_ok=True)
320
+ tmp = path.with_name(path.name + ".tmp")
321
+ tmp.write_text(text, encoding="utf-8")
322
+ os.replace(tmp, path)
323
+
324
+
325
+ def _write_jsonl(path: Path, rows: list[dict]) -> None:
326
+ lines = "".join(
327
+ json.dumps(row, sort_keys=True, separators=(",", ":")) + "\n" for row in rows
328
+ )
329
+ path.write_text(lines, encoding="utf-8")
330
+
331
+
332
+ def _write_json(path: Path, record: dict) -> None:
333
+ path.write_text(
334
+ json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
335
+ )
@@ -0,0 +1,87 @@
1
+ """Cross-entity integrity checks for the V2 evidence graph.
2
+
3
+ Reference integrity is a commit precondition: a Finding may not point at a
4
+ missing Study, an EvidenceLink at a missing Finding/Claim, an Audit at a
5
+ missing Study. Returns stable error strings; no printing from the engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ # graph table name -> entity id field
12
+ _TABLE_ID_KEY = {
13
+ "sources": "source_id",
14
+ "studies": "study_id",
15
+ "findings": "finding_id",
16
+ "outcomes": "outcome_id",
17
+ "claims": "claim_id",
18
+ "evidence_links": "evidence_link_id",
19
+ "audits": "audit_id",
20
+ }
21
+
22
+
23
+ def _ids(rows: list[dict], table: str) -> set[str]:
24
+ key = _TABLE_ID_KEY[table]
25
+ return {row[key] for row in rows}
26
+
27
+
28
+ def validate_graph(tables: dict[str, list[dict]]) -> list[str]:
29
+ """Validate cross-entity references across all graph tables.
30
+
31
+ `tables` maps table name -> list of entity dicts (the in-memory graph
32
+ state being validated, already schema-validated per entity).
33
+ """
34
+ errors: list[str] = []
35
+
36
+ sources = _ids(tables.get("sources", []), "sources")
37
+ studies = _ids(tables.get("studies", []), "studies")
38
+ findings = _ids(tables.get("findings", []), "findings")
39
+ outcomes = _ids(tables.get("outcomes", []), "outcomes")
40
+ claims = _ids(tables.get("claims", []), "claims")
41
+ links = tables.get("evidence_links", [])
42
+ audits = tables.get("audits", [])
43
+
44
+ for f in tables.get("findings", []):
45
+ if f["study_id"] not in studies:
46
+ errors.append(
47
+ f"finding {f['finding_id']} references missing study {f['study_id']}"
48
+ )
49
+ if f["outcome_id"] not in outcomes:
50
+ errors.append(
51
+ f"finding {f['finding_id']} references missing outcome {f['outcome_id']}"
52
+ )
53
+
54
+ for s in tables.get("studies", []):
55
+ for sid in s.get("source_ids", []):
56
+ if sid not in sources:
57
+ errors.append(
58
+ f"study {s['study_id']} references missing source {sid}"
59
+ )
60
+
61
+ for link in links:
62
+ if link["finding_id"] not in findings:
63
+ errors.append(
64
+ f"evidence_link {link['evidence_link_id']} references missing "
65
+ f"finding {link['finding_id']}"
66
+ )
67
+ if link["claim_id"] not in claims:
68
+ errors.append(
69
+ f"evidence_link {link['evidence_link_id']} references missing "
70
+ f"claim {link['claim_id']}"
71
+ )
72
+
73
+ for c in tables.get("claims", []):
74
+ for oid in c.get("primary_outcome_ids", []):
75
+ if oid not in outcomes:
76
+ errors.append(
77
+ f"claim {c['claim_id']} references missing outcome {oid}"
78
+ )
79
+
80
+ for a in audits:
81
+ if a["study_id"] not in studies:
82
+ errors.append(
83
+ f"methodology_audit {a['audit_id']} references missing "
84
+ f"study {a['study_id']}"
85
+ )
86
+
87
+ return errors
package/engine/ids.py ADDED
@@ -0,0 +1,77 @@
1
+ """Stable entity ID helpers.
2
+
3
+ Project and Run IDs are **creation identities**, not question hashes: two
4
+ teams may intentionally create two independent Projects with the same
5
+ question, so uniqueness comes from UTC creation time plus a short entropy
6
+ suffix. The normalized question fingerprint is metadata only, never identity.
7
+
8
+ Local entity prefixes are frozen in the V2 design:
9
+
10
+ PRJ Project RUN Run SRC Source STU Study FND Finding OUT Outcome
11
+ CLM Claim LNK EvidenceLink AUD MethodologyAudit GAP KnowledgeGap
12
+ DSN StudyDesign DAT DatasetAsset APL AnalysisPlan ANL AnalysisRun
13
+ DEC DecisionSnapshot PIL PilotRun SYN LibrarySynthesis
14
+
15
+ Interpretive entities (Claim, EvidenceLink, KnowledgeGap, DecisionSnapshot)
16
+ keep stable Project-local IDs across edits; version/revision metadata records
17
+ change instead of silently re-identifying.
18
+ """
19
+
20
+ from datetime import datetime, timezone
21
+ import hashlib
22
+ import secrets
23
+
24
+ _LOCAL_PREFIXES = frozenset({
25
+ "SRC", "STU", "FND", "OUT", "CLM", "LNK", "AUD", "GAP",
26
+ "DSN", "DAT", "APL", "ANL", "DEC", "PIL", "SYN",
27
+ })
28
+
29
+
30
+ def _utc_compact(now: datetime) -> str:
31
+ if now.tzinfo is None:
32
+ now = now.replace(tzinfo=timezone.utc)
33
+ return now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
34
+
35
+
36
+ def _question_fingerprint(question: str) -> str:
37
+ norm = " ".join(question.strip().lower().split())
38
+ return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:8]
39
+
40
+
41
+ def _unique_suffix(existing: set[str]) -> str:
42
+ while True:
43
+ suffix = secrets.token_hex(4)
44
+ if suffix not in existing:
45
+ return suffix
46
+
47
+
48
+ def new_project_id(question: str, *, now: datetime | None = None,
49
+ existing: set[str] | None = None) -> str:
50
+ """Create a unique Project creation identity.
51
+
52
+ `existing` (when supplied) is the set of suffixes already in use, so a
53
+ caller can guarantee collision-free allocation within its namespace.
54
+ """
55
+ now = now or datetime.now(timezone.utc)
56
+ existing = existing or set()
57
+ suffix = _unique_suffix(existing)
58
+ return f"PRJ-{_utc_compact(now)}-{_question_fingerprint(question)}-{suffix}"
59
+
60
+
61
+ def new_run_id(now: datetime | None = None, existing: set[str] | None = None) -> str:
62
+ """Create a unique Run creation identity."""
63
+ now = now or datetime.now(timezone.utc)
64
+ existing = existing or set()
65
+ suffix = _unique_suffix(existing)
66
+ return f"RUN-{_utc_compact(now)}-{suffix}"
67
+
68
+
69
+ def new_local_id(prefix: str, existing: set[str]) -> str:
70
+ """Create a unique local entity ID with a frozen design prefix."""
71
+ if prefix not in _LOCAL_PREFIXES:
72
+ raise ValueError(
73
+ f"unknown entity prefix {prefix!r}; frozen prefixes: "
74
+ f"{sorted(_LOCAL_PREFIXES)}"
75
+ )
76
+ suffix = _unique_suffix(existing)
77
+ return f"{prefix}-{suffix}"