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,671 @@
1
+ """engine/living.py - Living Evidence (v4): subscription, incremental refresh, drift.
2
+
3
+ A LivingEvidenceSubscription binds a revision-bound DecisionSnapshot to the
4
+ project question and a set of query terms. `refresh()` folds newly discovered
5
+ evidence into the evidence graph and re-adjudicates:
6
+
7
+ * no retrieval layer available -> a human/agent injects evidence records
8
+ explicitly (`new_evidence=[...]`);
9
+ * a retrieval layer exists -> a `retriever` adapter callable is
10
+ supplied and returns the same evidence-record shape.
11
+
12
+ Each refresh commits a new graph revision (GraphMutation: source/study/finding/
13
+ evidence_link, optional outcome + methodology audit), runs the tribunal, writes
14
+ a fresh immutable DecisionSnapshot, and emits a drift report under living/drift/
15
+ comparing the tracked snapshot with the new one via `tribunal.decision_diff`.
16
+
17
+ Idempotency: evidence records are deduplicated by a canonical content hash
18
+ (SHA-256 over the caller-supplied record). Re-injecting the same record never
19
+ touches the graph again.
20
+
21
+ Contracts: schemas/v4/living-subscription.schema.json (subscription record),
22
+ schemas/v4/drift-report.schema.json (drift report); graph entities follow
23
+ schemas/v2/*.json (source/study/finding/evidence-link/outcome/methodology-audit).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import secrets
30
+ import re
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+ from typing import Callable
34
+
35
+ from engine.contracts import validate_record
36
+ from engine.graph_store import GraphMutation, GraphStore
37
+ from engine.ids import new_run_id
38
+ from engine.project import ProjectWorkspace
39
+ from engine.tribunal import adjudicate, decision_diff, save_decision_snapshot
40
+ from engine.versions import METHODOLOGY_POLICY_VERSION
41
+ from scripts.validate_schema import SchemaError, validate
42
+
43
+ def _resolve_v4_schema_dir() -> Path:
44
+ """Repository layout first; wheel-installed share/ layout as fallback
45
+ (same pattern as engine/contracts._resolve_schema_dir)."""
46
+ repo = Path(__file__).resolve().parent.parent / "schemas" / "v4"
47
+ if repo.is_dir():
48
+ return repo
49
+ import sys
50
+ share = Path(sys.prefix) / "share" / "eduevidence" / "schemas" / "v4"
51
+ if share.is_dir():
52
+ return share
53
+ return repo
54
+
55
+
56
+ _V4_DIR = _resolve_v4_schema_dir()
57
+
58
+ #: evidence record key -> (graph table, entity schema name)
59
+ _PACKET_TABLES = {
60
+ "source": ("sources", "source"),
61
+ "study": ("studies", "study"),
62
+ "outcome": ("outcomes", "outcome"),
63
+ "finding": ("findings", "finding"),
64
+ "evidence_link": ("evidence_links", "evidence-link"),
65
+ "audit": ("audits", "methodology-audit"),
66
+ }
67
+
68
+ _RELATION_TO_IMPLICATION = {
69
+ "support": "support_adoption",
70
+ "contradict": "oppose_adoption",
71
+ "neutral": "neutral",
72
+ }
73
+
74
+ #: minimum keys a caller-supplied evidence record must carry
75
+ _REQUIRED_PACKET_KEYS = ("study", "finding", "evidence_link")
76
+
77
+ _GRAPH_TABLES = ("sources", "studies", "outcomes", "findings",
78
+ "evidence_links", "audits")
79
+
80
+
81
+ def _now_iso() -> str:
82
+ return datetime.now(timezone.utc).isoformat()
83
+
84
+
85
+ def _local_id(prefix: str, existing: set[str]) -> str:
86
+ """Local entity ID (prefix-<8 hex>) without touching engine.ids registry."""
87
+ while True:
88
+ candidate = f"{prefix}-{secrets.token_hex(4)}"
89
+ if candidate not in existing:
90
+ return candidate
91
+
92
+
93
+ def _evidence_hash(packet: dict) -> str:
94
+ """Canonical content hash of a caller-supplied evidence record.
95
+
96
+ Independent of dict key order; two identical records always hash equal,
97
+ which is what makes repeat refresh idempotent. Records must be
98
+ JSON-serializable; note that 100 vs 100.0 hash differently (byte-level
99
+ idempotency only, documented in the module docstring).
100
+ """
101
+ canonical = json.dumps(packet, sort_keys=True,
102
+ separators=(",", ":"), ensure_ascii=False)
103
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
104
+
105
+
106
+ def _validate_v4(record: dict, schema_name: str) -> None:
107
+ schema_path = _V4_DIR / f"{schema_name}.schema.json"
108
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
109
+ try:
110
+ validate(record, schema)
111
+ except SchemaError as exc:
112
+ raise ValueError(f"invalid {schema_name}: {exc}") from exc
113
+
114
+
115
+ # ---- subscription persistence ------------------------------------------
116
+
117
+ def _subscription_path(project: ProjectWorkspace, subscription_id: str) -> Path:
118
+ return project.path / "living" / "subscriptions" / f"{subscription_id}.json"
119
+
120
+
121
+ def _load_subscription(project: ProjectWorkspace, subscription_id: str) -> dict:
122
+ if not re.fullmatch(r"SUB-[0-9a-f]{8}", str(subscription_id)):
123
+ raise ValueError(
124
+ f"invalid subscription id {subscription_id!r}; expected SUB-<hex8>")
125
+ path = _subscription_path(project, subscription_id)
126
+ if not path.is_file():
127
+ raise FileNotFoundError(
128
+ f"subscription not found: {subscription_id} (missing {path})")
129
+ return json.loads(path.read_text(encoding="utf-8"))
130
+
131
+
132
+ def _save_subscription(project: ProjectWorkspace, subscription: dict) -> Path:
133
+ _validate_v4(subscription, "living-subscription")
134
+ path = _subscription_path(project, subscription["subscription_id"])
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ tmp = path.with_suffix(".json.tmp")
137
+ tmp.write_text(json.dumps(subscription, ensure_ascii=False, indent=2) + "\n",
138
+ encoding="utf-8")
139
+ tmp.replace(path)
140
+ return path
141
+
142
+
143
+ def _write_drift_report(project: ProjectWorkspace, drift: dict) -> Path:
144
+ _validate_v4(drift, "drift-report")
145
+ path = project.path / "living" / "drift" / f"{drift['drift_id']}.json"
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ tmp = path.with_suffix(".json.tmp")
148
+ tmp.write_text(json.dumps(drift, ensure_ascii=False, indent=2) + "\n",
149
+ encoding="utf-8")
150
+ tmp.replace(path)
151
+ return path
152
+
153
+
154
+ def _tracked_snapshot(project: ProjectWorkspace, subscription: dict) -> dict:
155
+ tracked_id = (subscription.get("extensions") or {}).get("last_snapshot_id") \
156
+ or subscription["decision_snapshot_id"]
157
+ path = project.path / "decisions" / f"{tracked_id}.json"
158
+ if not path.is_file():
159
+ raise FileNotFoundError(
160
+ f"tracked decision snapshot {tracked_id!r} missing for subscription "
161
+ f"{subscription['subscription_id']}")
162
+ return json.loads(path.read_text(encoding="utf-8"))
163
+
164
+
165
+ def _dedupe_terms(query_terms: list[str]) -> list[str]:
166
+ """Trim terms and deduplicate case-insensitively (keep first casing)."""
167
+ seen: set[str] = set()
168
+ terms: list[str] = []
169
+ for term in query_terms:
170
+ key = term.strip().lower()
171
+ if key not in seen:
172
+ seen.add(key)
173
+ terms.append(term.strip())
174
+ return terms
175
+
176
+
177
+ # ---- public API --------------------------------------------------------
178
+
179
+ def create_subscription(project: ProjectWorkspace, decision_snapshot_id: str,
180
+ query_terms: list[str]) -> dict:
181
+ """Create a living-evidence subscription bound to a DecisionSnapshot.
182
+
183
+ Validates that the snapshot exists under project/decisions/ (snapshots are
184
+ immutable and are the only thing a subscription may bind to), then writes
185
+ project/living/subscriptions/<id>.json. The subscription question is the
186
+ project question; query_terms describe what new evidence to watch for.
187
+ """
188
+ if not decision_snapshot_id.startswith("DEC-"):
189
+ raise ValueError(
190
+ f"decision snapshot id must start with 'DEC-', got {decision_snapshot_id!r}")
191
+ snapshot_path = project.path / "decisions" / f"{decision_snapshot_id}.json"
192
+ if not snapshot_path.is_file():
193
+ raise ValueError(
194
+ f"decision snapshot {decision_snapshot_id} not found in this project; "
195
+ "a subscription must bind to a real adjudication")
196
+ if not query_terms or any(not isinstance(t, str) or not t.strip()
197
+ for t in query_terms):
198
+ raise ValueError("query_terms must be non-empty strings")
199
+
200
+ subs_dir = project.path / "living" / "subscriptions"
201
+ existing = {p.stem for p in subs_dir.glob("SUB-*.json")} if subs_dir.is_dir() else set()
202
+ subscription = {
203
+ "subscription_id": _local_id("SUB", existing),
204
+ "decision_snapshot_id": decision_snapshot_id,
205
+ "question": project.manifest()["question"],
206
+ "query_terms": _dedupe_terms(query_terms),
207
+ "status": "active",
208
+ "created_at": _now_iso(),
209
+ "extensions": {
210
+ "last_snapshot_id": decision_snapshot_id,
211
+ "ingested_evidence_hashes": [],
212
+ },
213
+ }
214
+ _save_subscription(project, subscription)
215
+ return subscription
216
+
217
+
218
+ def set_subscription_status(project: ProjectWorkspace, subscription_id: str,
219
+ status: str) -> dict:
220
+ """Pause or resume a subscription (status: active | paused)."""
221
+ if status not in ("active", "paused"):
222
+ raise ValueError(f"status must be 'active' or 'paused', got {status!r}")
223
+ subscription = _load_subscription(project, subscription_id)
224
+ subscription["status"] = status
225
+ _save_subscription(project, subscription)
226
+ return subscription
227
+
228
+
229
+ def refresh(project: ProjectWorkspace, subscription_id: str, *,
230
+ new_evidence: list[dict] | None = None,
231
+ retriever: Callable[[dict], list[dict]] | None = None) -> dict:
232
+ """Refresh a subscription: fold new evidence in, re-adjudicate, drift-report.
233
+
234
+ Evidence sources (exactly one):
235
+ * `new_evidence` - a list of evidence records injected by a human/agent
236
+ when no retrieval layer exists. Each record is a dict with keys
237
+ `study`, `finding`, `evidence_link` (required) and optional
238
+ `source`, `outcome`, `audit`. Graph entity ids may be omitted and
239
+ are then generated; explicit ids must not collide with the graph.
240
+ `evidence_link.claim_id` must reference an existing graph claim
241
+ (claim-binding rule from the V3 pilot seed pattern).
242
+ * `retriever` - an adapter callable `retriever(subscription) -> list[dict]`
243
+ invoked when a retrieval layer exists; returns the same record shape.
244
+
245
+ Returns a dict with the drift report and refresh metadata. Re-injecting an
246
+ already-ingested record (same content hash) never commits a graph change.
247
+ """
248
+ if new_evidence is not None and retriever is not None:
249
+ raise ValueError("provide exactly one of new_evidence / retriever")
250
+ if new_evidence is None and retriever is None:
251
+ raise ValueError("no evidence source: pass new_evidence or a retriever callable")
252
+
253
+ subscription = _load_subscription(project, subscription_id)
254
+ if subscription.get("status") != "active":
255
+ raise ValueError(
256
+ f"subscription {subscription_id} is {subscription.get('status', 'unknown')!r}; "
257
+ "resume it before refresh")
258
+
259
+ if retriever is not None:
260
+ new_evidence = list(retriever(subscription) or [])
261
+
262
+ store = GraphStore(project)
263
+ claims = {c["claim_id"] for c in store.read_table("claims")
264
+ if c.get("status", "active") == "active"}
265
+ if not claims:
266
+ raise ValueError("project graph has no claims; a subscription cannot bind evidence")
267
+
268
+ ingested = set((subscription.get("extensions") or {})
269
+ .get("ingested_evidence_hashes", []))
270
+ fresh_packets: list[tuple[str, dict]] = []
271
+ skipped: list[str] = []
272
+ pending_hashes: set[str] = set()
273
+ seen_in_batch: set[str] = set()
274
+ for packet in new_evidence or []:
275
+ if not isinstance(packet, dict):
276
+ raise ValueError(
277
+ f"evidence record must be a dict, got {type(packet).__name__}")
278
+ h = _evidence_hash(packet)
279
+ if h in ingested or h in seen_in_batch:
280
+ skipped.append(h)
281
+ continue
282
+ seen_in_batch.add(h)
283
+ # Idempotent retry (P1-1): a previous refresh may have committed the
284
+ # record's study/finding into the graph but failed before updating the
285
+ # subscription's hash list. Treat graph-presence as ingested and only
286
+ # restore the hash bookkeeping.
287
+ study_id = (packet.get("study") or {}).get("study_id")
288
+ if study_id and store.get("studies", study_id):
289
+ skipped.append(h)
290
+ pending_hashes.add(h)
291
+ continue
292
+ fresh_packets.append((h, packet))
293
+
294
+ if not fresh_packets:
295
+ if pending_hashes:
296
+ _restore_ingested_hashes(project, subscription, pending_hashes)
297
+ return _no_change_refresh(project, subscription, store, skipped)
298
+
299
+ # ---- normalize + validate fresh evidence ---------------------------
300
+ next_revision = store.active_revision() + 1
301
+ mutation, new_hashes, finding_ids, summary_parts = _build_mutation(
302
+ project, store, subscription, fresh_packets, claims, next_revision)
303
+
304
+ revision = store.commit(
305
+ run_id=new_run_id(),
306
+ reason=f"living evidence refresh: {subscription_id}",
307
+ mutation=mutation)
308
+ store.repair_head_mirror()
309
+
310
+ snapshot = adjudicate(store, project=project)
311
+ save_decision_snapshot(project, snapshot)
312
+
313
+ previous = _tracked_snapshot(project, subscription)
314
+ diff = decision_diff(previous, snapshot)
315
+
316
+ suggested = _suggested_action(diff, committed_new_evidence=True)
317
+ summary = _summarize(suggested, diff, previous, snapshot,
318
+ summary_parts, skipped)
319
+
320
+ drift = {
321
+ "drift_id": _local_id("DRF", _existing_drift_ids(project)),
322
+ "subscription_id": subscription_id,
323
+ "from_revision": previous["graph_revision"],
324
+ "to_revision": revision.revision,
325
+ "generated_at": _now_iso(),
326
+ "new_evidence_ids": sorted(finding_ids),
327
+ "summary": summary,
328
+ "suggested_action": suggested,
329
+ "extensions": {
330
+ "evidence_committed": True,
331
+ "new_evidence_hashes": sorted(new_hashes),
332
+ "skipped_duplicate_hashes": sorted(skipped),
333
+ "diff": diff,
334
+ "from_decision_snapshot_id": previous["decision_snapshot_id"],
335
+ "to_decision_snapshot_id": snapshot["decision_snapshot_id"],
336
+ },
337
+ }
338
+ drift_path = _write_drift_report(project, drift)
339
+
340
+ ext = dict(subscription.get("extensions") or {})
341
+ ext["last_refreshed_at"] = drift["generated_at"]
342
+ ext["last_drift_id"] = drift["drift_id"]
343
+ ext["last_snapshot_id"] = snapshot["decision_snapshot_id"]
344
+ ext["from_graph_revision"] = previous["graph_revision"]
345
+ ext["to_graph_revision"] = revision.revision
346
+ ext["ingested_evidence_hashes"] = sorted(ingested | new_hashes)
347
+ subscription["extensions"] = ext
348
+ _save_subscription(project, subscription)
349
+
350
+ return {
351
+ "subscription_id": subscription_id,
352
+ "drift": drift,
353
+ "drift_report_path": str(drift_path),
354
+ "graph_revision": revision.revision,
355
+ "suggested_action": suggested,
356
+ "snapshot": snapshot,
357
+ "new_evidence_ids": sorted(finding_ids),
358
+ "evidence_committed": True,
359
+ }
360
+
361
+
362
+ # ---- refresh internals -------------------------------------------------
363
+
364
+ def _existing_drift_ids(project: ProjectWorkspace) -> set[str]:
365
+ drift_dir = project.path / "living" / "drift"
366
+ if not drift_dir.is_dir():
367
+ return set()
368
+ return {p.stem for p in drift_dir.glob("DRF-*.json")}
369
+
370
+
371
+ def _build_mutation(project, store, subscription, fresh_packets, claims,
372
+ next_revision) -> tuple[GraphMutation, set[str], set[str], list[str]]:
373
+ """Normalize + validate each fresh record and assemble one GraphMutation."""
374
+ existing = {t: {row[_ID_KEY[t]] for row in store.read_table(t)}
375
+ for t in _GRAPH_TABLES}
376
+ upserts: dict[str, list[dict]] = {t: [] for t in _GRAPH_TABLES}
377
+ new_hashes: set[str] = set()
378
+ finding_ids: set[str] = set()
379
+ summary_parts: list[str] = []
380
+
381
+ for idx, (content_hash, packet) in enumerate(fresh_packets, start=1):
382
+ label = f"evidence record #{idx}"
383
+ missing = [k for k in _REQUIRED_PACKET_KEYS if k not in packet]
384
+ if missing:
385
+ raise ValueError(
386
+ f"{label} is missing required keys {missing}; an evidence record "
387
+ "must carry study, finding and evidence_link "
388
+ "(source/outcome/audit optional)")
389
+ if not all(isinstance(packet[k], dict) for k in _REQUIRED_PACKET_KEYS):
390
+ raise ValueError(f"{label}: study/finding/evidence_link must be dicts")
391
+
392
+ # --- source (optional; auto project-origin when absent) ----------
393
+ source_upserted = False
394
+ source_pkt = packet.get("source")
395
+ if source_pkt is not None:
396
+ src = dict(source_pkt)
397
+ src_id = src.get("source_id")
398
+ if src_id is None or src_id not in existing["sources"]:
399
+ if src_id is None:
400
+ src_id = _local_id("SRC", existing["sources"])
401
+ src["source_id"] = src_id
402
+ existing["sources"].add(src_id)
403
+ source_upserted = True
404
+ upserts["sources"].append(src)
405
+ else:
406
+ src_id = _local_id("SRC", existing["sources"])
407
+ existing["sources"].add(src_id)
408
+ src = {
409
+ "source_id": src_id,
410
+ "origin": "project",
411
+ "source_type": "living-refresh",
412
+ "canonical_locator": f"project:living/{subscription['subscription_id']}",
413
+ "validation_status": "valid",
414
+ "content_hash": None,
415
+ "extensions": {"subscription_id": subscription["subscription_id"]},
416
+ }
417
+ source_upserted = True
418
+ upserts["sources"].append(src)
419
+
420
+ # --- outcome (optional; reused when the id already exists) -------
421
+ outcome_upserted = False
422
+ outcome = None
423
+ outcome_pkt = packet.get("outcome")
424
+ finding_pkt = packet["finding"]
425
+ out_id = finding_pkt.get("outcome_id")
426
+ if out_id is None and outcome_pkt is not None:
427
+ out_id = outcome_pkt.get("outcome_id") or (
428
+ f"OUT-{outcome_pkt['name']}" if outcome_pkt.get("name") else None)
429
+ if out_id is None:
430
+ raise ValueError(
431
+ f"{label}: finding.outcome_id is required (or provide an 'outcome' "
432
+ "record with a name/outcome_id)")
433
+ if out_id not in existing["outcomes"]:
434
+ if outcome_pkt is None:
435
+ raise ValueError(
436
+ f"{label}: outcome {out_id!r} is not in the graph; provide an "
437
+ "'outcome' record to create it")
438
+ outcome = {
439
+ "outcome_id": out_id,
440
+ "name": outcome_pkt.get("name") or out_id[len("OUT-"):],
441
+ "outcome_type": outcome_pkt.get("outcome_type", "learning"),
442
+ "extensions": outcome_pkt.get("extensions") or {},
443
+ }
444
+ existing["outcomes"].add(out_id)
445
+ outcome_upserted = True
446
+ upserts["outcomes"].append(outcome)
447
+
448
+ # --- study --------------------------------------------------------
449
+ study = dict(packet["study"])
450
+ study_id = study.get("study_id")
451
+ if study_id is None:
452
+ study_id = _local_id("STU", existing["studies"])
453
+ study["study_id"] = study_id
454
+ if study_id in existing["studies"]:
455
+ raise ValueError(
456
+ f"{label}: study {study_id!r} already exists in the graph; "
457
+ "living evidence must introduce a new study")
458
+ existing["studies"].add(study_id)
459
+ study.setdefault("source_ids", [src_id])
460
+ if not study["source_ids"]:
461
+ study["source_ids"] = [src_id]
462
+ study.setdefault("identity_status", "resolved")
463
+ study.setdefault("extensions", {})
464
+ upserts["studies"].append(study)
465
+
466
+ # --- finding -------------------------------------------------------
467
+ finding = dict(packet["finding"])
468
+ finding_id = finding.get("finding_id")
469
+ if finding_id is None:
470
+ finding_id = _local_id("FND", existing["findings"])
471
+ finding["finding_id"] = finding_id
472
+ if finding_id in existing["findings"]:
473
+ raise ValueError(f"{label}: finding {finding_id!r} already exists in the graph")
474
+ existing["findings"].add(finding_id)
475
+ finding.setdefault("study_id", study_id)
476
+ finding.setdefault("outcome_id", out_id)
477
+ finding.setdefault("source_locator", src["canonical_locator"])
478
+ finding.setdefault("extensions", {})
479
+ upserts["findings"].append(finding)
480
+ finding_ids.add(finding_id)
481
+
482
+ # --- evidence link (claim-binding rule) ---------------------------
483
+ link = dict(packet["evidence_link"])
484
+ link_id = link.get("evidence_link_id")
485
+ if link_id is None:
486
+ link_id = _local_id("LNK", existing["evidence_links"])
487
+ link["evidence_link_id"] = link_id
488
+ if link_id in existing["evidence_links"]:
489
+ raise ValueError(
490
+ f"{label}: evidence link {link_id!r} already exists in the graph")
491
+ existing["evidence_links"].add(link_id)
492
+ link.setdefault("finding_id", finding_id)
493
+ claim_id = link.get("claim_id")
494
+ if claim_id not in claims:
495
+ raise ValueError(
496
+ f"{label}: claim {claim_id!r} not found in project graph; "
497
+ "an evidence link must bind to an existing claim "
498
+ "(claim-binding rule)")
499
+ relation = link.get("relation_to_claim")
500
+ if relation not in _RELATION_TO_IMPLICATION:
501
+ raise ValueError(
502
+ f"{label}: relation_to_claim must be one of "
503
+ f"{sorted(_RELATION_TO_IMPLICATION)}, got {relation!r}")
504
+ link.setdefault("decision_implication", _RELATION_TO_IMPLICATION[relation])
505
+ link.setdefault("directness", 2)
506
+ link.setdefault("applicability", {"scope_match": "direct"})
507
+ link.setdefault("reasoning_note",
508
+ f"living evidence refresh: {subscription['subscription_id']}")
509
+ link["created_in_revision"] = link.get("created_in_revision") or next_revision
510
+ link.setdefault("extensions", {})
511
+ upserts["evidence_links"].append(link)
512
+
513
+ # --- optional methodology audit -----------------------------------
514
+ audit_upserted = False
515
+ audit_pkt = packet.get("audit")
516
+ if audit_pkt is not None:
517
+ audit = dict(audit_pkt)
518
+ audit_id = audit.get("audit_id")
519
+ if audit_id is None:
520
+ audit_id = _local_id("AUD", existing["audits"])
521
+ audit["audit_id"] = audit_id
522
+ if audit_id in existing["audits"]:
523
+ raise ValueError(f"{label}: audit {audit_id!r} already exists in the graph")
524
+ existing["audits"].add(audit_id)
525
+ audit.setdefault("study_id", study_id)
526
+ audit.setdefault("policy_version", METHODOLOGY_POLICY_VERSION)
527
+ audit.setdefault("audited_at", _now_iso())
528
+ audit.setdefault("bias_checks", [])
529
+ audit.setdefault("confounders", [])
530
+ audit.setdefault("limitations", [])
531
+ audit.setdefault("extensions", {})
532
+ audit_upserted = True
533
+ upserts["audits"].append(audit)
534
+
535
+ # --- schema validation of every entity this record upserts --------
536
+ to_validate: list[tuple[str, dict]] = []
537
+ if source_upserted:
538
+ to_validate.append(("source", src))
539
+ to_validate.append(("study", study))
540
+ if outcome_upserted:
541
+ to_validate.append(("outcome", outcome))
542
+ to_validate.append(("finding", finding))
543
+ to_validate.append(("evidence_link", link))
544
+ if audit_upserted:
545
+ to_validate.append(("audit", audit))
546
+ errors: list[str] = []
547
+ for key, entity in to_validate:
548
+ table, schema_name = _PACKET_TABLES[key]
549
+ for err in validate_record(schema_name, entity):
550
+ errors.append(f"{label} {key}: {err}")
551
+ if errors:
552
+ raise ValueError("invalid living evidence record(s):\n- " +
553
+ "\n- ".join(errors))
554
+
555
+ new_hashes.add(content_hash)
556
+ summary_parts.append(
557
+ f"{label}: study {study_id}, finding {finding_id}, link {link_id} "
558
+ f"(claim {claim_id}, {relation})")
559
+
560
+ return GraphMutation(upserts=upserts), new_hashes, finding_ids, summary_parts
561
+
562
+
563
+ _ID_KEY = {
564
+ "sources": "source_id", "studies": "study_id", "outcomes": "outcome_id",
565
+ "findings": "finding_id", "evidence_links": "evidence_link_id",
566
+ "audits": "audit_id",
567
+ }
568
+
569
+
570
+ def _suggested_action(diff: dict, *, committed_new_evidence: bool) -> str:
571
+ """suggested_action rules:
572
+
573
+ action/confidence change -> 'changed'
574
+ new evidence committed but adjudication unchanged -> 'needs_review'
575
+ otherwise -> 'confirmed'
576
+ """
577
+ if diff.get("action_changed") or diff.get("confidence_changed"):
578
+ return "changed"
579
+ if committed_new_evidence:
580
+ return "needs_review"
581
+ return "confirmed"
582
+
583
+
584
+ def _summarize(suggested: str, diff: dict, previous: dict, snapshot: dict,
585
+ summary_parts: list[str], skipped: list[str]) -> str:
586
+ parts: list[str] = []
587
+ if summary_parts:
588
+ parts.append(f"{len(summary_parts)} new evidence record(s) committed")
589
+ if diff.get("action_changed"):
590
+ parts.append(
591
+ f"decision action changed: {previous.get('decision')} -> "
592
+ f"{snapshot.get('decision')}")
593
+ if diff.get("confidence_changed"):
594
+ parts.append(
595
+ f"confidence label changed: {previous.get('confidence_label')} -> "
596
+ f"{snapshot.get('confidence_label')}")
597
+ if diff.get("changed_claims"):
598
+ parts.append(f"changed claims: {sorted(diff['changed_claims'])}")
599
+ if diff.get("new_key_evidence_links"):
600
+ parts.append(
601
+ f"new key evidence links: {len(diff['new_key_evidence_links'])}")
602
+ if skipped:
603
+ parts.append(f"{len(skipped)} duplicate record(s) skipped (content-hash dedupe)")
604
+ if not parts:
605
+ parts.append("no substantive change")
606
+ return "; ".join(parts)
607
+
608
+
609
+ def _restore_ingested_hashes(project, subscription, hashes: set[str]) -> None:
610
+ """Best-effort state recovery (P1-1): a refresh whose graph commit
611
+ succeeded but whose subscription bookkeeping failed left the hashes
612
+ unrecorded; on retry the records are already in the graph, so we only
613
+ need to restore the hash list."""
614
+ ext = dict(subscription.get("extensions") or {})
615
+ ext["ingested_evidence_hashes"] = sorted(
616
+ set(ext.get("ingested_evidence_hashes", [])) | hashes)
617
+ subscription["extensions"] = ext
618
+ _save_subscription(project, subscription)
619
+
620
+
621
+ def _no_change_refresh(project, subscription, store, skipped) -> dict:
622
+ """Refresh with no new evidence: emit a 'confirmed' drift report only.
623
+
624
+ No graph revision is created; from_revision == to_revision.
625
+ """
626
+ previous = _tracked_snapshot(project, subscription)
627
+ diff = decision_diff(previous, previous)
628
+ current_rev = store.active_revision()
629
+ now = _now_iso()
630
+
631
+ summary = ("refresh received no new evidence" if not skipped else
632
+ f"refresh received {len(skipped)} evidence record(s), all already "
633
+ "ingested (content-hash dedupe)")
634
+ drift = {
635
+ "drift_id": _local_id("DRF", _existing_drift_ids(project)),
636
+ "subscription_id": subscription["subscription_id"],
637
+ "from_revision": current_rev,
638
+ "to_revision": current_rev,
639
+ "generated_at": now,
640
+ "new_evidence_ids": [],
641
+ "summary": summary + "; no graph change",
642
+ "suggested_action": "confirmed",
643
+ "extensions": {
644
+ "evidence_committed": False,
645
+ "new_evidence_hashes": [],
646
+ "skipped_duplicate_hashes": sorted(skipped),
647
+ "diff": diff,
648
+ "from_decision_snapshot_id": previous["decision_snapshot_id"],
649
+ "to_decision_snapshot_id": previous["decision_snapshot_id"],
650
+ },
651
+ }
652
+ drift_path = _write_drift_report(project, drift)
653
+
654
+ ext = dict(subscription.get("extensions") or {})
655
+ ext["last_refreshed_at"] = now
656
+ ext["last_drift_id"] = drift["drift_id"]
657
+ ext["from_graph_revision"] = current_rev
658
+ ext["to_graph_revision"] = current_rev
659
+ subscription["extensions"] = ext
660
+ _save_subscription(project, subscription)
661
+
662
+ return {
663
+ "subscription_id": subscription["subscription_id"],
664
+ "drift": drift,
665
+ "drift_report_path": str(drift_path),
666
+ "graph_revision": current_rev,
667
+ "suggested_action": "confirmed",
668
+ "snapshot": None,
669
+ "new_evidence_ids": [],
670
+ "evidence_committed": False,
671
+ }