reader-workbench 1.0.0__py3-none-any.whl

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 (293) hide show
  1. reader_workbench/__init__.py +22 -0
  2. reader_workbench/__main__.py +4 -0
  3. reader_workbench/_version.py +17 -0
  4. reader_workbench/api/__init__.py +74 -0
  5. reader_workbench/api/_record_reads.py +75 -0
  6. reader_workbench/api/artifacts.py +79 -0
  7. reader_workbench/api/facade.py +538 -0
  8. reader_workbench/api/models.py +285 -0
  9. reader_workbench/api/notebooks.py +63 -0
  10. reader_workbench/contracts/__init__.py +18 -0
  11. reader_workbench/contracts/builtins/__init__.py +36 -0
  12. reader_workbench/contracts/builtins/cytometry.py +140 -0
  13. reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
  14. reader_workbench/contracts/builtins/generic.py +21 -0
  15. reader_workbench/contracts/builtins/logic.py +149 -0
  16. reader_workbench/contracts/builtins/plate_reader.py +47 -0
  17. reader_workbench/contracts/catalog.py +257 -0
  18. reader_workbench/contracts/model.py +109 -0
  19. reader_workbench/domains/__init__.py +1 -0
  20. reader_workbench/domains/cytometry/__init__.py +3 -0
  21. reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
  22. reader_workbench/domains/cytometry/analysis/events.py +182 -0
  23. reader_workbench/domains/cytometry/analysis/gating.py +175 -0
  24. reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
  25. reader_workbench/domains/cytometry/io/__init__.py +3 -0
  26. reader_workbench/domains/cytometry/io/fcs.py +135 -0
  27. reader_workbench/domains/cytometry/plots/__init__.py +5 -0
  28. reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
  29. reader_workbench/domains/logic/__init__.py +3 -0
  30. reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
  31. reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
  32. reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
  33. reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
  34. reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
  35. reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
  36. reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
  37. reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
  38. reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
  39. reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
  40. reader_workbench/domains/logic/four_state_vector/config.py +214 -0
  41. reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
  42. reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
  43. reader_workbench/domains/logic/four_state_vector/math.py +191 -0
  44. reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
  45. reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
  46. reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
  47. reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
  48. reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
  49. reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
  50. reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
  51. reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
  52. reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
  53. reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
  54. reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
  55. reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
  56. reader_workbench/domains/logic/treatment_columns.py +42 -0
  57. reader_workbench/domains/plate_reader/__init__.py +1 -0
  58. reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
  59. reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
  60. reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
  61. reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
  62. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
  63. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
  64. reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
  65. reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
  66. reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
  67. reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
  68. reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
  69. reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
  70. reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
  71. reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
  72. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
  73. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
  74. reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
  75. reader_workbench/domains/plate_reader/io/__init__.py +6 -0
  76. reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
  77. reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
  78. reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
  79. reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
  80. reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
  81. reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
  82. reader_workbench/domains/plate_reader/ordering.py +59 -0
  83. reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
  84. reader_workbench/domains/plate_reader/plots/_data.py +29 -0
  85. reader_workbench/domains/plate_reader/plots/common.py +346 -0
  86. reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
  87. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
  88. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
  89. reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
  90. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
  91. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
  92. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
  93. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
  94. reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
  95. reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
  96. reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
  97. reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
  98. reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
  99. reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
  100. reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
  101. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
  102. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
  103. reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
  104. reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
  105. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
  106. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
  107. reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
  108. reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
  109. reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
  110. reader_workbench/domains/time_series/__init__.py +29 -0
  111. reader_workbench/domains/time_series/aggregation.py +60 -0
  112. reader_workbench/domains/time_series/contracts.py +368 -0
  113. reader_workbench/domains/time_series/reduction.py +395 -0
  114. reader_workbench/errors.py +55 -0
  115. reader_workbench/maintenance/__init__.py +6 -0
  116. reader_workbench/maintenance/docs.py +335 -0
  117. reader_workbench/maintenance/model.py +28 -0
  118. reader_workbench/maintenance/release.py +39 -0
  119. reader_workbench/maintenance/skills.py +124 -0
  120. reader_workbench/plotting/__init__.py +20 -0
  121. reader_workbench/plotting/mpl.py +56 -0
  122. reader_workbench/plotting/sinks.py +69 -0
  123. reader_workbench/plotting/style.py +175 -0
  124. reader_workbench/plotting/utils.py +27 -0
  125. reader_workbench/plugins/__init__.py +1 -0
  126. reader_workbench/plugins/catalog.py +33 -0
  127. reader_workbench/plugins/export/__init__.py +0 -0
  128. reader_workbench/plugins/export/_paths.py +21 -0
  129. reader_workbench/plugins/export/csv.py +41 -0
  130. reader_workbench/plugins/export/xlsx.py +44 -0
  131. reader_workbench/plugins/ingest/__init__.py +0 -0
  132. reader_workbench/plugins/ingest/_discovery.py +58 -0
  133. reader_workbench/plugins/ingest/discovery_policy.py +66 -0
  134. reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
  135. reader_workbench/plugins/ingest/synergy_h1.py +234 -0
  136. reader_workbench/plugins/manifests/__init__.py +1 -0
  137. reader_workbench/plugins/manifests/export.py +29 -0
  138. reader_workbench/plugins/manifests/ingest.py +29 -0
  139. reader_workbench/plugins/manifests/plot.py +161 -0
  140. reader_workbench/plugins/manifests/transform.py +172 -0
  141. reader_workbench/plugins/manifests/validator.py +18 -0
  142. reader_workbench/plugins/plot/__init__.py +0 -0
  143. reader_workbench/plugins/plot/_shared.py +55 -0
  144. reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
  145. reader_workbench/plugins/plot/distributions.py +58 -0
  146. reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
  147. reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
  148. reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
  149. reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
  150. reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
  151. reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
  152. reader_workbench/plugins/plot/logic_symmetry.py +56 -0
  153. reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
  154. reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
  155. reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
  156. reader_workbench/plugins/plot/time_series.py +114 -0
  157. reader_workbench/plugins/plot/ts_and_snap.py +210 -0
  158. reader_workbench/plugins/transform/__init__.py +0 -0
  159. reader_workbench/plugins/transform/_four_state_vector.py +204 -0
  160. reader_workbench/plugins/transform/_labeling.py +109 -0
  161. reader_workbench/plugins/transform/alias.py +70 -0
  162. reader_workbench/plugins/transform/assay_labels.py +62 -0
  163. reader_workbench/plugins/transform/blank.py +79 -0
  164. reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
  165. reader_workbench/plugins/transform/cytometry_gating.py +120 -0
  166. reader_workbench/plugins/transform/fold_change.py +79 -0
  167. reader_workbench/plugins/transform/four_state_event_window.py +93 -0
  168. reader_workbench/plugins/transform/four_state_vector.py +62 -0
  169. reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
  170. reader_workbench/plugins/transform/logic_symmetry.py +67 -0
  171. reader_workbench/plugins/transform/outlier_filter.py +60 -0
  172. reader_workbench/plugins/transform/overflow.py +197 -0
  173. reader_workbench/plugins/transform/ratio.py +237 -0
  174. reader_workbench/plugins/transform/sample_map.py +170 -0
  175. reader_workbench/plugins/transform/sample_metadata.py +94 -0
  176. reader_workbench/plugins/validator/__init__.py +1 -0
  177. reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
  178. reader_workbench/protocols/__init__.py +80 -0
  179. reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
  180. reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
  181. reader_workbench/protocols/builtins.py +1656 -0
  182. reader_workbench/protocols/compiler.py +22 -0
  183. reader_workbench/protocols/compilers/__init__.py +1 -0
  184. reader_workbench/protocols/compilers/common.py +100 -0
  185. reader_workbench/protocols/compilers/cytometry.py +87 -0
  186. reader_workbench/protocols/compilers/generic.py +14 -0
  187. reader_workbench/protocols/compilers/logic.py +245 -0
  188. reader_workbench/protocols/compilers/plate_reader.py +937 -0
  189. reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
  190. reader_workbench/protocols/model.py +1486 -0
  191. reader_workbench/protocols/semantic_coverage.py +234 -0
  192. reader_workbench/runtime/__init__.py +12 -0
  193. reader_workbench/runtime/builtin.py +23 -0
  194. reader_workbench/runtime/model.py +42 -0
  195. reader_workbench/workbench/__init__.py +60 -0
  196. reader_workbench/workbench/assets/__init__.py +22 -0
  197. reader_workbench/workbench/assets/types.py +118 -0
  198. reader_workbench/workbench/audit/__init__.py +5 -0
  199. reader_workbench/workbench/audit/experiments.py +307 -0
  200. reader_workbench/workbench/audit/staging.py +187 -0
  201. reader_workbench/workbench/cli/__init__.py +51 -0
  202. reader_workbench/workbench/cli/_lazy.py +9 -0
  203. reader_workbench/workbench/cli/_records_view.py +150 -0
  204. reader_workbench/workbench/cli/_surface_execution.py +443 -0
  205. reader_workbench/workbench/cli/audit.py +95 -0
  206. reader_workbench/workbench/cli/automation.py +229 -0
  207. reader_workbench/workbench/cli/demo.py +46 -0
  208. reader_workbench/workbench/cli/dop.py +91 -0
  209. reader_workbench/workbench/cli/experiments.py +635 -0
  210. reader_workbench/workbench/cli/helpers.py +232 -0
  211. reader_workbench/workbench/cli/main.py +59 -0
  212. reader_workbench/workbench/cli/maintenance.py +82 -0
  213. reader_workbench/workbench/cli/notebooks.py +260 -0
  214. reader_workbench/workbench/cli/pagination.py +117 -0
  215. reader_workbench/workbench/cli/protocols.py +336 -0
  216. reader_workbench/workbench/cli/shared.py +309 -0
  217. reader_workbench/workbench/cli/surfaces.py +534 -0
  218. reader_workbench/workbench/cli/verification.py +128 -0
  219. reader_workbench/workbench/commands.py +10 -0
  220. reader_workbench/workbench/config/__init__.py +47 -0
  221. reader_workbench/workbench/config/identity.py +13 -0
  222. reader_workbench/workbench/config/load.py +405 -0
  223. reader_workbench/workbench/config/model.py +274 -0
  224. reader_workbench/workbench/context.py +26 -0
  225. reader_workbench/workbench/decl/__init__.py +31 -0
  226. reader_workbench/workbench/decl/build.py +190 -0
  227. reader_workbench/workbench/decl/model.py +81 -0
  228. reader_workbench/workbench/dop/__init__.py +12 -0
  229. reader_workbench/workbench/dop/builtins.py +261 -0
  230. reader_workbench/workbench/dop/model.py +209 -0
  231. reader_workbench/workbench/engine/__init__.py +42 -0
  232. reader_workbench/workbench/engine/_shared.py +76 -0
  233. reader_workbench/workbench/engine/contracts.py +283 -0
  234. reader_workbench/workbench/engine/execution.py +326 -0
  235. reader_workbench/workbench/engine/file_outputs.py +260 -0
  236. reader_workbench/workbench/engine/inputs.py +161 -0
  237. reader_workbench/workbench/engine/invocations.py +507 -0
  238. reader_workbench/workbench/engine/planning.py +72 -0
  239. reader_workbench/workbench/engine/runtime.py +464 -0
  240. reader_workbench/workbench/engine/setup.py +149 -0
  241. reader_workbench/workbench/engine/validation.py +684 -0
  242. reader_workbench/workbench/experiment/__init__.py +47 -0
  243. reader_workbench/workbench/experiment/model.py +381 -0
  244. reader_workbench/workbench/experiments.py +133 -0
  245. reader_workbench/workbench/graph/__init__.py +47 -0
  246. reader_workbench/workbench/graph/nodes.py +102 -0
  247. reader_workbench/workbench/graph/normalize.py +177 -0
  248. reader_workbench/workbench/graph/refs.py +148 -0
  249. reader_workbench/workbench/input_discovery.py +19 -0
  250. reader_workbench/workbench/inspection/__init__.py +3 -0
  251. reader_workbench/workbench/inspection/catalogs.py +128 -0
  252. reader_workbench/workbench/inspection/common.py +92 -0
  253. reader_workbench/workbench/inspection/dop.py +64 -0
  254. reader_workbench/workbench/inspection/experiments.py +449 -0
  255. reader_workbench/workbench/inspection/inventory.py +68 -0
  256. reader_workbench/workbench/inspection/protocols.py +368 -0
  257. reader_workbench/workbench/inspection/readiness.py +333 -0
  258. reader_workbench/workbench/inspection/reports.py +367 -0
  259. reader_workbench/workbench/inspection/results.py +166 -0
  260. reader_workbench/workbench/inspection/runtime.py +287 -0
  261. reader_workbench/workbench/inspection/semantics.py +192 -0
  262. reader_workbench/workbench/inspection/validation.py +30 -0
  263. reader_workbench/workbench/notebooks/__init__.py +17 -0
  264. reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
  265. reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
  266. reader_workbench/workbench/notebooks/components/__init__.py +21 -0
  267. reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
  268. reader_workbench/workbench/notebooks/components/overview.py +119 -0
  269. reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
  270. reader_workbench/workbench/notebooks/launch.py +274 -0
  271. reader_workbench/workbench/notebooks/presentation.py +136 -0
  272. reader_workbench/workbench/notebooks/scaffold.py +60 -0
  273. reader_workbench/workbench/ontology.py +78 -0
  274. reader_workbench/workbench/paths.py +44 -0
  275. reader_workbench/workbench/ports/__init__.py +31 -0
  276. reader_workbench/workbench/ports/model.py +168 -0
  277. reader_workbench/workbench/records/__init__.py +44 -0
  278. reader_workbench/workbench/records/epoch.py +329 -0
  279. reader_workbench/workbench/records/evidence.py +247 -0
  280. reader_workbench/workbench/records/identity.py +87 -0
  281. reader_workbench/workbench/records/locking.py +185 -0
  282. reader_workbench/workbench/records/model.py +711 -0
  283. reader_workbench/workbench/records/sources.py +73 -0
  284. reader_workbench/workbench/records/store.py +1022 -0
  285. reader_workbench/workbench/records/verification.py +998 -0
  286. reader_workbench/workbench/registry.py +333 -0
  287. reader_workbench/workbench/spec_overrides.py +215 -0
  288. reader_workbench-1.0.0.dist-info/METADATA +91 -0
  289. reader_workbench-1.0.0.dist-info/RECORD +293 -0
  290. reader_workbench-1.0.0.dist-info/WHEEL +5 -0
  291. reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
  292. reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
  293. reader_workbench-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,507 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import stat
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+ from uuid import UUID, uuid4
13
+
14
+ from reader_workbench.errors import ConfigError, ExecutionError
15
+ from reader_workbench.workbench.graph import FileRef, RecordCollectionRef, RecordRef, ResourceRef
16
+ from reader_workbench.workbench.paths import resolve_confined_sink_root
17
+ from reader_workbench.workbench.records import record_revision_digest
18
+ from reader_workbench.workbench.records.identity import BuildIdentity
19
+ from reader_workbench.workbench.records.locking import provenance_lock_scope
20
+
21
+ INVOCATION_SCHEMA = "reader.invocation/v2"
22
+ _FAILURE_REASON_LIMIT = 500
23
+ _DECLARED_INPUT_LIMIT = 4096
24
+ _SECRET_ASSIGNMENT_RE = re.compile(
25
+ r"""(?ix)
26
+ (?<![a-z0-9])(?P<quote>["']?)
27
+ (?P<key>
28
+ (?:[a-z0-9]+[._-])*
29
+ (?:
30
+ api[._-]?key
31
+ | secret[._-]?access[._-]?key
32
+ | access[._-]?key
33
+ | auth[._-]?token
34
+ | access[._-]?token
35
+ | refresh[._-]?token
36
+ | credentials?
37
+ | signature
38
+ | password
39
+ | secret
40
+ | token
41
+ )
42
+ (?:[._-][a-z0-9]+)*
43
+ )
44
+ (?P=quote)\s*[:=]\s*
45
+ (?:"[^"]*"|'[^']*'|[^\s,;&]+)
46
+ """
47
+ )
48
+ _AUTHORIZATION_RE = re.compile(
49
+ r"(?i)\bauthorization\s*:\s*(?P<scheme>[a-z][a-z0-9_-]*)\s+"
50
+ r"(?:\"[^\"]*\"|'[^']*'|[^\s,;]+)"
51
+ )
52
+ _BEARER_RE = re.compile(r"(?i)\bbearer\s+[^\s,;]+")
53
+ _URI_USERINFO_RE = re.compile(r"(?i)\b(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@")
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class InvocationAttempt:
58
+ invocation_id: str
59
+ provenance_epoch_id: str
60
+ config_digest: str
61
+ build_identity: BuildIdentity
62
+ operation: str
63
+ selected_step_ids: dict[str, list[str]]
64
+ declared_inputs: list[dict[str, Any]]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SelectedSteps:
69
+ pipeline: tuple[str, ...]
70
+ plots: tuple[str, ...]
71
+ exports: tuple[str, ...]
72
+
73
+ @classmethod
74
+ def from_mapping(cls, value: dict[str, list[str]]) -> SelectedSteps:
75
+ normalized = _normalize_selected_step_ids(value)
76
+ return cls(
77
+ pipeline=tuple(normalized["pipeline"]),
78
+ plots=tuple(normalized["plots"]),
79
+ exports=tuple(normalized["exports"]),
80
+ )
81
+
82
+ def to_dict(self) -> dict[str, list[str]]:
83
+ return {
84
+ "pipeline": list(self.pipeline),
85
+ "plots": list(self.plots),
86
+ "exports": list(self.exports),
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class ProducedRecordRevision:
92
+ record_id: str
93
+ revision: int
94
+ revision_digest: str
95
+
96
+ @classmethod
97
+ def from_payload(cls, value: dict[str, Any]) -> ProducedRecordRevision:
98
+ normalized = _normalize_revisions([value])[0]
99
+ return cls(
100
+ record_id=normalized["record_id"],
101
+ revision=normalized["revision"],
102
+ revision_digest=normalized["revision_digest"],
103
+ )
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "record_id": self.record_id,
108
+ "revision": self.revision,
109
+ "revision_digest": self.revision_digest,
110
+ }
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ExecutionResult:
115
+ invocation_id: str | None
116
+ provenance_epoch_id: str | None
117
+ operation: Literal["run", "plot", "export", "mixed"]
118
+ status: Literal["planned", "succeeded"]
119
+ dry_run: bool
120
+ selected_steps: SelectedSteps
121
+ produced_record_revisions: tuple[ProducedRecordRevision, ...]
122
+ ledger_path: Path | None
123
+
124
+
125
+ class InvocationLedger:
126
+ def __init__(
127
+ self,
128
+ *,
129
+ experiment_root: Path,
130
+ outputs_dir: Path,
131
+ provenance_epoch_id: str,
132
+ epoch_guard: Callable[[str], None] | None = None,
133
+ writer_lock: Any | None = None,
134
+ ) -> None:
135
+ self.experiment_root = Path(experiment_root).resolve(strict=False)
136
+ self.outputs_dir = Path(outputs_dir).resolve(strict=False)
137
+ if not self.outputs_dir.is_relative_to(self.experiment_root):
138
+ raise ConfigError(f"Invocation outputs directory must stay under the experiment root: {self.outputs_dir}")
139
+ self.provenance_epoch_id = _require_uuid4(provenance_epoch_id, field="provenance_epoch_id")
140
+ self._epoch_guard = epoch_guard
141
+ self._writer_lock = writer_lock
142
+ self.path = self.outputs_dir / "manifests" / "invocations" / f"{self.provenance_epoch_id}.jsonl"
143
+
144
+ @classmethod
145
+ def for_store(cls, *, store: Any) -> InvocationLedger:
146
+ """Bind a ledger to the record catalog's active provenance epoch."""
147
+
148
+ epoch_id = store.provenance_epoch_id()
149
+ store.bind_provenance_epoch(epoch_id)
150
+ return cls(
151
+ experiment_root=store.experiment_root,
152
+ outputs_dir=store.root,
153
+ provenance_epoch_id=epoch_id,
154
+ epoch_guard=store.assert_provenance_epoch,
155
+ writer_lock=store.provenance_lock,
156
+ )
157
+
158
+ def append_attempt(
159
+ self,
160
+ *,
161
+ config_digest: str,
162
+ build_identity: BuildIdentity,
163
+ operation: str,
164
+ selected_step_ids: dict[str, list[str]],
165
+ declared_inputs: list[dict[str, Any]],
166
+ ) -> InvocationAttempt:
167
+ selected = _normalize_selected_step_ids(selected_step_ids)
168
+ attempt = InvocationAttempt(
169
+ invocation_id=str(uuid4()),
170
+ provenance_epoch_id=self.provenance_epoch_id,
171
+ config_digest=_require_digest(config_digest),
172
+ build_identity=build_identity,
173
+ operation=_normalize_operation(operation),
174
+ selected_step_ids=selected,
175
+ declared_inputs=_normalize_declared_inputs(declared_inputs),
176
+ )
177
+ self._append(
178
+ self._base_event(attempt)
179
+ | {
180
+ "event": "attempt",
181
+ "status": "attempted",
182
+ "exit_status": None,
183
+ "produced_record_revisions": [],
184
+ "failure": None,
185
+ }
186
+ )
187
+ return attempt
188
+
189
+ def append_result(
190
+ self,
191
+ attempt: InvocationAttempt,
192
+ *,
193
+ exit_status: int,
194
+ produced_record_revisions: list[dict[str, Any]],
195
+ failure: BaseException | None = None,
196
+ ) -> None:
197
+ if attempt.provenance_epoch_id != self.provenance_epoch_id:
198
+ raise ExecutionError("Invocation attempt belongs to a different provenance epoch")
199
+ if not isinstance(exit_status, int) or exit_status < 0:
200
+ raise ExecutionError("Invocation exit_status must be a non-negative integer")
201
+ if exit_status == 0 and failure is not None:
202
+ raise ExecutionError("A successful invocation result must not include failure details")
203
+ if exit_status != 0 and failure is None:
204
+ raise ExecutionError("A failed invocation result must include failure details")
205
+ revisions = _normalize_revisions(produced_record_revisions)
206
+ self._append(
207
+ self._base_event(attempt)
208
+ | {
209
+ "event": "result",
210
+ "status": "succeeded" if exit_status == 0 else "failed",
211
+ "exit_status": exit_status,
212
+ "produced_record_revisions": revisions,
213
+ "failure": self._failure_payload(failure) if failure is not None else None,
214
+ }
215
+ )
216
+
217
+ def _base_event(self, attempt: InvocationAttempt) -> dict[str, Any]:
218
+ return {
219
+ "schema": INVOCATION_SCHEMA,
220
+ "invocation_id": attempt.invocation_id,
221
+ "provenance_epoch_id": attempt.provenance_epoch_id,
222
+ "timestamp": datetime.now(UTC).isoformat(),
223
+ "config_digest": attempt.config_digest,
224
+ "build_identity": attempt.build_identity.to_dict(),
225
+ "operation": attempt.operation,
226
+ "selected_step_ids": attempt.selected_step_ids,
227
+ "declared_inputs": attempt.declared_inputs,
228
+ }
229
+
230
+ def _failure_payload(self, failure: BaseException) -> dict[str, str]:
231
+ reason = " ".join(str(failure).split()) or type(failure).__name__
232
+ for sensitive_path in (self.experiment_root, Path.home().resolve(strict=False)):
233
+ reason = reason.replace(str(sensitive_path), "<redacted-path>")
234
+ reason = _URI_USERINFO_RE.sub(lambda match: f"{match.group('scheme')}<redacted>@", reason)
235
+ reason = _AUTHORIZATION_RE.sub(lambda match: f"Authorization: {match.group('scheme')} <redacted>", reason)
236
+ reason = _SECRET_ASSIGNMENT_RE.sub(lambda match: f"{match.group('key')}=<redacted>", reason)
237
+ reason = _BEARER_RE.sub("Bearer <redacted>", reason)
238
+ if len(reason) > _FAILURE_REASON_LIMIT:
239
+ reason = reason[: _FAILURE_REASON_LIMIT - 1] + "…"
240
+ return {"type": type(failure).__name__, "reason": reason}
241
+
242
+ def _append(self, event: dict[str, Any]) -> None:
243
+ if self._writer_lock is None:
244
+ self._append_locked(event)
245
+ return
246
+ with provenance_lock_scope(
247
+ self._writer_lock,
248
+ acquire_error=ExecutionError("Could not acquire the invocation writer lease"),
249
+ release_error=ExecutionError(
250
+ "The invocation event may have been committed, but Reader could not release the writer lease. "
251
+ "Inspect the active ledger and run reader verify before retrying."
252
+ ),
253
+ release_note="Reader also could not release the invocation writer lease",
254
+ ):
255
+ self._append_locked(event)
256
+
257
+ def _append_locked(self, event: dict[str, Any]) -> None:
258
+ self._assert_active_epoch()
259
+ ledger_dir = self._resolve_ledger_dir(create=True)
260
+ assert ledger_dir is not None
261
+ if self.path.is_symlink():
262
+ raise ConfigError(f"Invocation ledger must not be a symlink: {self.path}")
263
+ ledger_path = ledger_dir / self.path.name
264
+
265
+ payload = (json.dumps(event, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
266
+ flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
267
+ flags |= getattr(os, "O_CLOEXEC", 0)
268
+ flags |= getattr(os, "O_NOFOLLOW", 0)
269
+ flags |= getattr(os, "O_NONBLOCK", 0)
270
+ try:
271
+ descriptor = os.open(ledger_path, flags, 0o644)
272
+ except OSError as exc:
273
+ raise ExecutionError(f"Could not append invocation event under outputs/manifests: {exc}") from exc
274
+ try:
275
+ self._assert_active_epoch()
276
+ ledger_stat = os.fstat(descriptor)
277
+ if not stat.S_ISREG(ledger_stat.st_mode) or ledger_stat.st_nlink != 1:
278
+ raise ExecutionError("Invocation ledger must be a regular file with a single link")
279
+ initial_size = ledger_stat.st_size
280
+ try:
281
+ written = os.write(descriptor, payload)
282
+ if written != len(payload):
283
+ raise ExecutionError(
284
+ f"Invocation ledger append was incomplete: wrote {written} of {len(payload)} bytes"
285
+ )
286
+ os.fsync(descriptor)
287
+ except BaseException as exc:
288
+ try:
289
+ os.ftruncate(descriptor, initial_size)
290
+ os.fsync(descriptor)
291
+ except OSError as rollback_error:
292
+ raise ExecutionError(
293
+ "Invocation ledger append failed and Reader could not restore the previous file boundary"
294
+ ) from rollback_error
295
+ if isinstance(exc, OSError):
296
+ raise ExecutionError(f"Could not append invocation event under outputs/manifests: {exc}") from exc
297
+ raise
298
+ except OSError as exc:
299
+ raise ExecutionError(f"Could not inspect invocation ledger under outputs/manifests: {exc}") from exc
300
+ finally:
301
+ os.close(descriptor)
302
+
303
+ def _assert_active_epoch(self) -> None:
304
+ if self._epoch_guard is not None:
305
+ self._epoch_guard(self.provenance_epoch_id)
306
+
307
+ def _resolve_ledger_dir(self, *, create: bool) -> Path | None:
308
+ try:
309
+ outputs_dir = resolve_confined_sink_root(
310
+ self.outputs_dir,
311
+ root=self.experiment_root,
312
+ label="Invocation outputs",
313
+ )
314
+ except ValueError as exc:
315
+ raise ConfigError(str(exc)) from exc
316
+ manifests_dir = outputs_dir / "manifests"
317
+ ledger_dir = manifests_dir / "invocations"
318
+ if manifests_dir.is_symlink():
319
+ raise ConfigError(f"Invocation manifest directory must not be a symlink: {manifests_dir}")
320
+ if create:
321
+ manifests_dir.mkdir(parents=True, exist_ok=True)
322
+ elif not manifests_dir.exists():
323
+ return None
324
+ if manifests_dir.is_symlink():
325
+ raise ConfigError(f"Invocation manifest directory must not be a symlink: {manifests_dir}")
326
+ if ledger_dir.is_symlink():
327
+ raise ConfigError(f"Invocation ledger directory must not be a symlink: {ledger_dir}")
328
+ if create:
329
+ ledger_dir.mkdir(parents=True, exist_ok=True)
330
+ elif not ledger_dir.exists():
331
+ return None
332
+ if ledger_dir.is_symlink():
333
+ raise ConfigError(f"Invocation ledger directory must not be a symlink: {ledger_dir}")
334
+ resolved_parent = ledger_dir.resolve(strict=True)
335
+ if not resolved_parent.is_relative_to(outputs_dir.resolve(strict=True)):
336
+ raise ConfigError(f"Invocation ledger directory must stay under the outputs directory: {resolved_parent}")
337
+ return resolved_parent
338
+
339
+
340
+ def capture_revision_snapshot(store: Any) -> dict[str, dict[str, Any]]:
341
+ if not store.catalog_exists():
342
+ return {}
343
+ snapshot = store.catalog_snapshot()
344
+ return {
345
+ record.record_id: {
346
+ "record_id": record.record_id,
347
+ "revision": snapshot.revision_counts[record.record_id],
348
+ "revision_digest": record_revision_digest(record, outputs_dir=store.root),
349
+ }
350
+ for record in snapshot.latest_records
351
+ }
352
+
353
+
354
+ def declared_input_projection(
355
+ *,
356
+ steps_by_phase: dict[str, list[Any]],
357
+ experiment_root: Path,
358
+ ) -> list[dict[str, Any]]:
359
+ expected_phases = ("pipeline", "plots", "exports")
360
+ if set(steps_by_phase) != set(expected_phases):
361
+ raise ExecutionError(f"Invocation steps_by_phase must contain exactly {list(expected_phases)}")
362
+ root = Path(experiment_root).resolve(strict=False)
363
+ declared: list[dict[str, Any]] = []
364
+ for phase in expected_phases:
365
+ for step in steps_by_phase[phase]:
366
+ for port, ref in sorted((step.reads or {}).items()):
367
+ declared.append(
368
+ {
369
+ "phase": phase,
370
+ "step_id": step.id,
371
+ "port": port,
372
+ "ref": _declared_ref_payload(ref, experiment_root=root),
373
+ }
374
+ )
375
+ return _normalize_declared_inputs(declared)
376
+
377
+
378
+ def produced_record_revisions(
379
+ *,
380
+ before: dict[str, dict[str, Any]],
381
+ after: dict[str, dict[str, Any]],
382
+ ) -> list[dict[str, Any]]:
383
+ changed = [revision for record_id, revision in after.items() if before.get(record_id) != revision]
384
+ return _normalize_revisions(changed)
385
+
386
+
387
+ def _require_digest(value: str) -> str:
388
+ if not isinstance(value, str) or not value.startswith("sha256:"):
389
+ raise ExecutionError("Invocation config_digest must be a sha256 digest")
390
+ return value
391
+
392
+
393
+ def _require_uuid4(value: str, *, field: str) -> str:
394
+ if not isinstance(value, str):
395
+ raise ConfigError(f"Invocation {field} must be a canonical UUID4")
396
+ try:
397
+ parsed = UUID(value)
398
+ except ValueError as exc:
399
+ raise ConfigError(f"Invocation {field} must be a canonical UUID4") from exc
400
+ if parsed.version != 4 or str(parsed) != value:
401
+ raise ConfigError(f"Invocation {field} must be a canonical UUID4")
402
+ return value
403
+
404
+
405
+ def _normalize_operation(value: str) -> str:
406
+ allowed = {"run", "plot", "export", "mixed"}
407
+ if value not in allowed:
408
+ raise ExecutionError(f"Invocation operation must be one of {sorted(allowed)}")
409
+ return value
410
+
411
+
412
+ def _normalize_selected_step_ids(value: dict[str, list[str]]) -> dict[str, list[str]]:
413
+ expected = ("pipeline", "plots", "exports")
414
+ if not isinstance(value, dict) or set(value) != set(expected):
415
+ raise ExecutionError(f"Invocation selected_step_ids must contain exactly {list(expected)}")
416
+ normalized: dict[str, list[str]] = {}
417
+ for phase in expected:
418
+ step_ids = value[phase]
419
+ if not isinstance(step_ids, list) or any(not isinstance(step_id, str) or not step_id for step_id in step_ids):
420
+ raise ExecutionError(f"Invocation selected_step_ids.{phase} must be a list of non-empty strings")
421
+ normalized[phase] = list(step_ids)
422
+ return normalized
423
+
424
+
425
+ def _normalize_declared_inputs(value: list[dict[str, Any]]) -> list[dict[str, Any]]:
426
+ if not isinstance(value, list) or len(value) > _DECLARED_INPUT_LIMIT:
427
+ raise ExecutionError(f"Invocation declared_inputs must be a list of at most {_DECLARED_INPUT_LIMIT} items")
428
+ normalized: list[dict[str, Any]] = []
429
+ for item in value:
430
+ if not isinstance(item, dict) or set(item) != {"phase", "step_id", "port", "ref"}:
431
+ raise ExecutionError("Invocation declared input must contain exactly phase, step_id, port, and ref")
432
+ phase = item["phase"]
433
+ step_id = item["step_id"]
434
+ port = item["port"]
435
+ if phase not in {"pipeline", "plots", "exports"}:
436
+ raise ExecutionError("Invocation declared input phase is invalid")
437
+ if not isinstance(step_id, str) or not step_id or not isinstance(port, str) or not port:
438
+ raise ExecutionError("Invocation declared input step_id and port must be non-empty strings")
439
+ ref = item["ref"]
440
+ if not isinstance(ref, dict) or set(ref) not in (
441
+ {"record"},
442
+ {"file"},
443
+ {"resource", "path"},
444
+ {"record_collection"},
445
+ ):
446
+ raise ExecutionError(
447
+ "Invocation declared input ref must be a record, record collection, file, or resource reference"
448
+ )
449
+ if any(not isinstance(part, str) or not part for part in ref.values()):
450
+ raise ExecutionError("Invocation declared input reference values must be non-empty strings")
451
+ normalized.append({"phase": phase, "step_id": step_id, "port": port, "ref": dict(ref)})
452
+ return normalized
453
+
454
+
455
+ def _declared_ref_payload(ref: Any, *, experiment_root: Path) -> dict[str, str]:
456
+ if isinstance(ref, RecordRef):
457
+ return {"record": ref.record_id}
458
+ if isinstance(ref, ResourceRef):
459
+ return {
460
+ "resource": ref.resource_id,
461
+ "path": _render_declared_path(ref.path, experiment_root=experiment_root),
462
+ }
463
+ if isinstance(ref, RecordCollectionRef):
464
+ return {"record_collection": ",".join(item.resource_id for item in ref.records)}
465
+ if isinstance(ref, FileRef):
466
+ return {"file": _render_declared_path(ref.path, experiment_root=experiment_root)}
467
+ raise ExecutionError(f"Invocation declared input has unsupported reference type: {type(ref).__name__}")
468
+
469
+
470
+ def _render_declared_path(path: Path, *, experiment_root: Path) -> str:
471
+ candidate = Path(path)
472
+ resolved = (
473
+ candidate.resolve(strict=False)
474
+ if candidate.is_absolute()
475
+ else (experiment_root / candidate).resolve(strict=False)
476
+ )
477
+ if resolved.is_relative_to(experiment_root):
478
+ return resolved.relative_to(experiment_root).as_posix()
479
+ raise ExecutionError(f"Invocation declared input must stay under the experiment root: {path}")
480
+
481
+
482
+ def _normalize_revisions(value: list[dict[str, Any]]) -> list[dict[str, Any]]:
483
+ if not isinstance(value, list):
484
+ raise ExecutionError("Invocation produced_record_revisions must be a list")
485
+ normalized: list[dict[str, Any]] = []
486
+ for item in value:
487
+ if not isinstance(item, dict) or set(item) != {"record_id", "revision", "revision_digest"}:
488
+ raise ExecutionError(
489
+ "Invocation record revisions must contain exactly record_id, revision, and revision_digest"
490
+ )
491
+ record_id = item["record_id"]
492
+ revision = item["revision"]
493
+ revision_digest = item["revision_digest"]
494
+ if not isinstance(record_id, str) or not record_id:
495
+ raise ExecutionError("Invocation record revision record_id must be a non-empty string")
496
+ if not isinstance(revision, int) or revision < 1:
497
+ raise ExecutionError("Invocation record revision must be a positive integer")
498
+ if not isinstance(revision_digest, str) or not revision_digest.startswith("sha256:"):
499
+ raise ExecutionError("Invocation record revision_digest must be a sha256 digest")
500
+ normalized.append(
501
+ {
502
+ "record_id": record_id,
503
+ "revision": revision,
504
+ "revision_digest": revision_digest,
505
+ }
506
+ )
507
+ return sorted(normalized, key=lambda item: (item["record_id"], item["revision"]))
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+
5
+ from reader_workbench.plotting.mpl import ensure_mpl_cache_dir
6
+ from reader_workbench.runtime import ReaderRuntime, builtin_runtime
7
+ from reader_workbench.workbench.commands import reader_command
8
+ from reader_workbench.workbench.decl import WorkbenchDecl
9
+ from reader_workbench.workbench.graph import ensure_unique_workbench_ids, resolve_workbench
10
+ from reader_workbench.workbench.inspection.reports import workflow_explain_renderables
11
+
12
+ from ._shared import collect_categories
13
+
14
+
15
+ def build_next_steps(
16
+ decl: WorkbenchDecl,
17
+ *,
18
+ job_label: str | None = None,
19
+ runtime: ReaderRuntime | None = None,
20
+ include_plot: bool = True,
21
+ include_export: bool = True,
22
+ ) -> list[tuple[str, str]]:
23
+ runtime = runtime or builtin_runtime()
24
+ label = (job_label or "").strip()
25
+
26
+ def _cmd(base: str, tail: str = "") -> str:
27
+ return reader_command(base, label, tail)
28
+
29
+ steps: list[tuple[str, str]] = []
30
+ workbench = resolve_workbench(decl)
31
+ plot_specs = list(workbench.plots)
32
+ export_specs = list(workbench.exports)
33
+ steps.append((_cmd("records"), "Review generated records"))
34
+ if plot_specs and include_plot:
35
+ steps.append((_cmd("plot"), "Save plot files to outputs/plots"))
36
+ if export_specs and include_export:
37
+ steps.append((_cmd("export"), "Write export files to outputs/exports"))
38
+ steps.append((_cmd("notebook"), "Open the canonical experiment notebook"))
39
+ return steps
40
+
41
+
42
+ def explain(
43
+ decl: WorkbenchDecl,
44
+ *,
45
+ console: Console,
46
+ registry=None,
47
+ plot_specs=None,
48
+ export_specs=None,
49
+ runtime: ReaderRuntime | None = None,
50
+ ) -> None:
51
+ runtime = runtime or builtin_runtime()
52
+ bound_protocol = runtime.bind_protocol(decl.experiment_semantics.protocol)
53
+ workbench = resolve_workbench(decl)
54
+ pipeline_steps = list(workbench.pipeline)
55
+ plot_specs = list(plot_specs) if plot_specs is not None else list(workbench.plots)
56
+ export_specs = list(export_specs) if export_specs is not None else list(workbench.exports)
57
+ ensure_unique_workbench_ids(pipeline_steps, plot_specs, export_specs)
58
+ categories = collect_categories(list(workbench.plugin_steps()))
59
+ if "plot" in categories:
60
+ ensure_mpl_cache_dir()
61
+ registry = registry or (runtime.plugins if categories else None)
62
+ if (pipeline_steps or plot_specs or export_specs) and registry is None:
63
+ raise RuntimeError("plugin-backed workflow explanation requires a plugin registry")
64
+ for renderable in workflow_explain_renderables(
65
+ bound_protocol=bound_protocol,
66
+ decl=decl,
67
+ pipeline_steps=pipeline_steps,
68
+ plot_specs=plot_specs,
69
+ export_specs=export_specs,
70
+ registry=registry,
71
+ ):
72
+ console.print(renderable)