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,684 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import replace
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from rich import box
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from reader_workbench.errors import ConfigError, ExecutionError, ReaderError
13
+ from reader_workbench.plotting.mpl import ensure_mpl_cache_dir
14
+ from reader_workbench.runtime import ReaderRuntime, builtin_runtime
15
+ from reader_workbench.workbench.commands import reader_command
16
+ from reader_workbench.workbench.decl import WorkbenchDecl
17
+ from reader_workbench.workbench.experiment import ExperimentSemantics
18
+ from reader_workbench.workbench.graph import (
19
+ FileRef,
20
+ RecordCollectionRef,
21
+ RecordRef,
22
+ ResourceRef,
23
+ ensure_unique_workbench_ids,
24
+ input_ref_display,
25
+ resolve_workbench,
26
+ )
27
+ from reader_workbench.workbench.paths import resolve_path_within_root
28
+ from reader_workbench.workbench.records import DataFrameArtifactRecord, resolve_source_record
29
+ from reader_workbench.workbench.registry import Plugin, PreflightIssue
30
+
31
+ from ._shared import collect_categories
32
+ from .contracts import _resolve_output_labels
33
+
34
+
35
+ def _validate_reads_shape(*, label: str, step: Any, plugin_cls: Any) -> None:
36
+ try:
37
+ required = plugin_cls.input_ports()
38
+ expected = set(required)
39
+ mandatory = {name for name, port in required.items() if not port.optional}
40
+ provided = set((step.reads or {}).keys())
41
+ missing = sorted(mandatory - provided)
42
+ extra = sorted(provided - expected)
43
+ if not missing and not extra:
44
+ return
45
+ parts = []
46
+ if missing:
47
+ parts.append(f"missing inputs: {missing}")
48
+ if extra:
49
+ parts.append(f"unexpected inputs: {extra}")
50
+ raise ConfigError(f"{label} {step.id}: reads do not match plugin inputs ({'; '.join(parts)})")
51
+ except ConfigError:
52
+ raise
53
+ except Exception as err:
54
+ raise ConfigError(f"{label} {step.id}: could not validate reads: {err}") from err
55
+
56
+
57
+ def _assert_known_read_targets(*, label: str, step: Any, available_labels: set[str], plugin_cls: Any) -> None:
58
+ input_ports = plugin_cls.input_ports()
59
+ for key, target in (step.reads or {}).items():
60
+ port = input_ports.get(key)
61
+ if port is None:
62
+ continue
63
+ if port.kind in {"dataframe", "file_bundle"} and isinstance(target, FileRef | ResourceRef):
64
+ raise ConfigError(
65
+ f"{label} {step.id}: reads '{key}' targets '{input_ref_display(target)}', "
66
+ f"but the plugin expects a {port.kind} record."
67
+ )
68
+ if port.kind == "record_collection" and not isinstance(target, RecordCollectionRef):
69
+ raise ConfigError(f"{label} {step.id}: reads '{key}' must target a declared record collection.")
70
+ if isinstance(target, RecordCollectionRef):
71
+ if port.kind != "record_collection":
72
+ raise ConfigError(
73
+ f"{label} {step.id}: reads '{key}' targets a record collection, "
74
+ f"but the plugin expects {port.kind!r}."
75
+ )
76
+ continue
77
+ if port.kind in {"file_path", "file_set"} and isinstance(target, RecordRef):
78
+ raise ConfigError(
79
+ f"{label} {step.id}: reads '{key}' targets record '{target.record_id}', "
80
+ "but the plugin expects an explicit file/resource binding."
81
+ )
82
+ if isinstance(target, FileRef | ResourceRef):
83
+ continue
84
+ if isinstance(target, RecordRef) and target.record_id in available_labels:
85
+ continue
86
+ preview = sorted(available_labels)
87
+ shown = ", ".join(preview[:12]) if preview else "—"
88
+ tail = " …" if len(preview) > 12 else ""
89
+ source = "pipeline" if label in {"plot", "export"} else "any prior step"
90
+ raise ConfigError(
91
+ f"{label} {step.id}: reads '{key}' → '{input_ref_display(target)}', which is not produced by {source}. "
92
+ f"Known labels{' so far' if label == 'pipeline' else ''}: {shown}{tail}. "
93
+ "Check writes/reads aliases or use explicit file/resource bindings."
94
+ )
95
+
96
+
97
+ def _effective_step_with(*, protocol: Any, step: Any) -> dict[str, Any]:
98
+ return protocol.effective_plugin_config(plugin_id=step.plugin, step_with=(step.with_ or {}))
99
+
100
+
101
+ def _validate_step_config(*, label: str, step: Any, plugin_cls: Any, protocol: Any) -> dict[str, Any]:
102
+ with_block = _effective_step_with(protocol=protocol, step=step)
103
+ try:
104
+ plugin_cls.ConfigModel.model_validate(with_block)
105
+ except Exception as err:
106
+ raise ConfigError(f"{label} {step.id}: invalid config for {step.plugin}: {err}") from err
107
+ return with_block
108
+
109
+
110
+ def _validate_plot_semantic_refs(*, step: Any, experiment: ExperimentSemantics, with_block: dict[str, Any]) -> None:
111
+ partition = with_block.get("partition")
112
+ if partition is not None:
113
+ try:
114
+ experiment.annotations.resolve_plot_partition(partition=partition)
115
+ except Exception as err:
116
+ raise ConfigError(f"plot {step.id}: invalid plot partition: {err}") from err
117
+
118
+ x_column = with_block.get("x")
119
+ y_column = with_block.get("y")
120
+ try:
121
+ experiment.annotations.resolve_order_arg(
122
+ order=with_block.get("order_x"),
123
+ order_ref=with_block.get("order_x_ref"),
124
+ column=(x_column if isinstance(x_column, str) else None),
125
+ arg_name="order_x",
126
+ )
127
+ experiment.annotations.resolve_order_arg(
128
+ order=with_block.get("order_y"),
129
+ order_ref=with_block.get("order_y_ref"),
130
+ column=(y_column if isinstance(y_column, str) else None),
131
+ arg_name="order_y",
132
+ )
133
+ if isinstance(with_block.get("state_map_ref"), str):
134
+ experiment.annotations.resolve_ordered_state_space(ref=with_block["state_map_ref"])
135
+ except Exception as err:
136
+ raise ConfigError(f"plot {step.id}: invalid ordering semantic reference: {err}") from err
137
+
138
+
139
+ def _validate_pipeline_semantic_refs(*, step: Any, experiment: ExperimentSemantics, with_block: dict[str, Any]) -> None:
140
+ try:
141
+ if isinstance(with_block.get("state_map_ref"), str):
142
+ experiment.annotations.resolve_ordered_state_space(ref=with_block["state_map_ref"])
143
+ except Exception as err:
144
+ raise ConfigError(f"pipeline {step.id}: invalid annotation semantic reference: {err}") from err
145
+
146
+
147
+ def _validate_pipeline_steps(
148
+ items: list[Any], *, registry: Any, experiment: ExperimentSemantics, protocol: Any
149
+ ) -> set[str]:
150
+ available: set[str] = set()
151
+ produced: set[str] = set()
152
+ for step in items:
153
+ try:
154
+ plugin_cls = registry.resolve(step.plugin)
155
+ except Exception as err:
156
+ raise ConfigError(f"pipeline {step.id}: {err}") from err
157
+ _validate_reads_shape(label="pipeline", step=step, plugin_cls=plugin_cls)
158
+ _assert_known_read_targets(
159
+ label="pipeline",
160
+ step=step,
161
+ available_labels=available | produced,
162
+ plugin_cls=plugin_cls,
163
+ )
164
+ with_block = _validate_step_config(label="pipeline", step=step, plugin_cls=plugin_cls, protocol=protocol)
165
+ _validate_pipeline_semantic_refs(step=step, experiment=experiment, with_block=with_block)
166
+ try:
167
+ output_labels = _resolve_output_labels(
168
+ step_id=step.id,
169
+ output_ports=plugin_cls.output_ports(),
170
+ writes=(step.writes or {}),
171
+ )
172
+ except ExecutionError as err:
173
+ raise ConfigError(f"pipeline {step.id}: {err}") from err
174
+ for out_name, out_label in output_labels.items():
175
+ if plugin_cls.output_ports()[out_name].kind != "dataframe":
176
+ continue
177
+ if out_label.record_id in available or out_label.record_id in produced:
178
+ raise ConfigError(
179
+ f"pipeline {step.id}: output label '{out_label.record_id}' is already produced by another step. "
180
+ "Use a unique writes mapping to avoid clobbering dataframe records."
181
+ )
182
+ produced.add(out_label.record_id)
183
+ return available | produced
184
+
185
+
186
+ def _validate_specs(
187
+ items: list[Any],
188
+ *,
189
+ label: str,
190
+ registry: Any,
191
+ available_labels: set[str],
192
+ protocol: Any,
193
+ experiment: ExperimentSemantics | None = None,
194
+ ) -> None:
195
+ for spec_item in items:
196
+ try:
197
+ plugin_cls = registry.resolve(spec_item.plugin)
198
+ except Exception as err:
199
+ raise ConfigError(f"{label} {spec_item.id}: {err}") from err
200
+ _validate_reads_shape(label=label, step=spec_item, plugin_cls=plugin_cls)
201
+ _assert_known_read_targets(
202
+ label=label,
203
+ step=spec_item,
204
+ available_labels=available_labels,
205
+ plugin_cls=plugin_cls,
206
+ )
207
+ with_block = _validate_step_config(label=label, step=spec_item, plugin_cls=plugin_cls, protocol=protocol)
208
+ if label == "plot":
209
+ if experiment is None:
210
+ raise ConfigError("plot validation requires experiment semantics")
211
+ _validate_plot_semantic_refs(
212
+ step=spec_item,
213
+ experiment=experiment,
214
+ with_block=with_block,
215
+ )
216
+ try:
217
+ cfg = plugin_cls.ConfigModel.model_validate(with_block)
218
+ plugin_cls.validate_semantic_references(experiment=experiment, cfg=cfg)
219
+ except Exception as err:
220
+ raise ConfigError(f"plot {spec_item.id}: invalid plugin semantic reference: {err}") from err
221
+ data_outputs = {
222
+ key: port.render() for key, port in plugin_cls.output_ports().items() if port.kind == "dataframe"
223
+ }
224
+ if data_outputs:
225
+ raise ConfigError(
226
+ f"{label} {spec_item.id}: plugins must not declare data outputs (got {data_outputs}). "
227
+ "Move data outputs into the pipeline."
228
+ )
229
+
230
+
231
+ def _configured_output_steps(*, protocol: Any) -> tuple[tuple[Any, ...], tuple[Any, ...]]:
232
+ """Compile explicitly configured output blocks without selecting them for execution."""
233
+
234
+ outputs = deepcopy(protocol.outputs)
235
+ configured_ids: dict[str, tuple[str, ...]] = {"plots": (), "exports": ()}
236
+ output_keys = {"plots": "views", "exports": "artifacts"}
237
+ for section, config_key in output_keys.items():
238
+ block = protocol.outputs.get(section, {}) or {}
239
+ named_configs = block.get(config_key, {}) or {}
240
+ configured_ids[section] = tuple(named_configs)
241
+ if not configured_ids[section]:
242
+ continue
243
+ selected_block = dict(outputs.get(section, {}) or {})
244
+ selected_block["include"] = list(configured_ids[section])
245
+ selected_block["exclude"] = [
246
+ output_id for output_id in (selected_block.get("exclude") or ()) if output_id not in configured_ids[section]
247
+ ]
248
+ if section == "plots":
249
+ selected_block["profile"] = "none"
250
+ outputs[section] = selected_block
251
+ if not any(configured_ids.values()):
252
+ return (), ()
253
+
254
+ plan = replace(protocol, outputs=outputs).compile()
255
+ configured: dict[str, tuple[Any, ...]] = {}
256
+ for section, config_key in output_keys.items():
257
+ steps = tuple(step for step in getattr(plan, section) if step.id in configured_ids[section])
258
+ for output_id in configured_ids[section]:
259
+ if sum(step.id == output_id for step in steps) != 1:
260
+ where = f"protocol.outputs.{section}.{config_key}.{output_id}"
261
+ raise ConfigError(f"{where} did not compile to exactly one plugin-backed output")
262
+ configured[section] = steps
263
+ return configured["plots"], configured["exports"]
264
+
265
+
266
+ def _validate_configured_output_steps(
267
+ *,
268
+ plot_steps: tuple[Any, ...],
269
+ export_steps: tuple[Any, ...],
270
+ registry: Any,
271
+ protocol: Any,
272
+ experiment: ExperimentSemantics,
273
+ ) -> None:
274
+ for section, config_key, steps in (
275
+ ("plots", "views", plot_steps),
276
+ ("exports", "artifacts", export_steps),
277
+ ):
278
+ for step in steps:
279
+ where = f"protocol.outputs.{section}.{config_key}.{step.id}"
280
+ try:
281
+ plugin_cls = registry.resolve(step.plugin)
282
+ except Exception as err:
283
+ raise ConfigError(f"{where}: {err}") from err
284
+ with_block = _effective_step_with(protocol=protocol, step=step)
285
+ try:
286
+ cfg = plugin_cls.ConfigModel.model_validate(with_block)
287
+ except Exception as err:
288
+ raise ConfigError(f"{where}: invalid config for {step.plugin}: {err}") from err
289
+ if section == "plots":
290
+ _validate_plot_semantic_refs(step=step, experiment=experiment, with_block=with_block)
291
+ try:
292
+ plugin_cls.validate_semantic_references(experiment=experiment, cfg=cfg)
293
+ except Exception as err:
294
+ raise ConfigError(f"{where}: invalid plugin semantic reference: {err}") from err
295
+
296
+
297
+ def _resolve_exp_path(path: Path, *, exp_root: Path | None) -> Path:
298
+ if exp_root is None:
299
+ return path
300
+ try:
301
+ return resolve_path_within_root(path, root=exp_root)
302
+ except ValueError as err:
303
+ raise ConfigError("Declared paths must stay under the experiment root after resolving symlinks.") from err
304
+
305
+
306
+ def _render_rel(path: Path, *, exp_root: Path | None) -> Path:
307
+ if exp_root is None:
308
+ return path
309
+ try:
310
+ return path.relative_to(exp_root)
311
+ except Exception:
312
+ return path
313
+
314
+
315
+ def _plugin_preflight_issues(
316
+ *,
317
+ items: list[Any],
318
+ label: str,
319
+ registry: Any,
320
+ protocol: Any,
321
+ exp_root: Path | None,
322
+ ) -> list[tuple[str, str, PreflightIssue]]:
323
+ if exp_root is None:
324
+ return []
325
+ issues: list[tuple[str, str, PreflightIssue]] = []
326
+ for step in items:
327
+ plugin_cls = registry.resolve(step.plugin)
328
+ if getattr(plugin_cls.preflight_readiness, "__func__", plugin_cls.preflight_readiness) is getattr(
329
+ Plugin.preflight_readiness,
330
+ "__func__",
331
+ Plugin.preflight_readiness,
332
+ ):
333
+ continue
334
+ cfg = plugin_cls.ConfigModel.model_validate(_effective_step_with(protocol=protocol, step=step))
335
+ for issue in plugin_cls.preflight_readiness(exp_dir=exp_root, cfg=cfg, reads=(step.reads or {})):
336
+ issues.append((label, step.id, issue))
337
+ return issues
338
+
339
+
340
+ def _source_record_preflight_issues(
341
+ *,
342
+ items: list[Any],
343
+ registry: Any,
344
+ contracts: Any,
345
+ ) -> tuple[int, list[str]]:
346
+ declared = 0
347
+ issues: list[str] = []
348
+ for step in items:
349
+ plugin_cls = registry.resolve(step.plugin)
350
+ ports = plugin_cls.input_ports()
351
+ for label, ref in (step.reads or {}).items():
352
+ if not isinstance(ref, RecordCollectionRef):
353
+ continue
354
+ declared += len(ref.records)
355
+ if not ref.records:
356
+ issues.append(f"pipeline:{step.id} • {label} → no source records declared")
357
+ continue
358
+ expected_contract = ports[label].contract
359
+ for source_ref in ref.records:
360
+ source_label = f"{source_ref.experiment_id}:{source_ref.record_id}"
361
+ try:
362
+ resolved = resolve_source_record(source_ref, contracts=contracts)
363
+ record = resolved.record
364
+ if not isinstance(record, DataFrameArtifactRecord):
365
+ raise ConfigError(f"source record is {record.kind!r}, not a dataframe artifact")
366
+ if expected_contract is not None and not contracts.satisfies(
367
+ actual=record.contract_id,
368
+ expected=expected_contract,
369
+ ):
370
+ raise ConfigError(f"contract {record.contract_id!r} does not satisfy {expected_contract!r}")
371
+ resolved.verify_artifact_integrity()
372
+ except (OSError, ReaderError) as exc:
373
+ issues.append(f"pipeline:{step.id} • {label} → {source_label}: {exc}")
374
+ return declared, issues
375
+
376
+
377
+ def _planned_output_record_ids(
378
+ *,
379
+ pipeline_items: list[Any],
380
+ plot_items: list[Any],
381
+ export_items: list[Any],
382
+ registry: Any,
383
+ ) -> set[str]:
384
+ planned = {f"plot:{step.id}" for step in plot_items}
385
+ planned.update(f"export:{step.id}" for step in export_items)
386
+ for step in pipeline_items:
387
+ plugin_cls = registry.resolve(step.plugin)
388
+ planned.update(
389
+ ref.record_id
390
+ for ref in _resolve_output_labels(
391
+ step_id=step.id,
392
+ output_ports=plugin_cls.output_ports(),
393
+ writes=(step.writes or {}),
394
+ ).values()
395
+ )
396
+ return planned
397
+
398
+
399
+ def _assert_no_source_record_output_collisions(
400
+ *,
401
+ items: list[Any],
402
+ planned_record_ids: set[str],
403
+ experiment_root: Path,
404
+ ) -> None:
405
+ for step in items:
406
+ for label, ref in (step.reads or {}).items():
407
+ if not isinstance(ref, RecordCollectionRef):
408
+ continue
409
+ for source_ref in ref.records:
410
+ if source_ref.experiment_root == experiment_root and source_ref.record_id in planned_record_ids:
411
+ source_label = f"{source_ref.experiment_id}:{source_ref.record_id}"
412
+ raise ConfigError(
413
+ f"pipeline {step.id}: reads '{label}' source record {source_label!r} targets the same "
414
+ f"experiment and collides with planned output {source_ref.record_id!r}. Choose a source "
415
+ "owned by another experiment or a distinct output record id."
416
+ )
417
+
418
+
419
+ def validation_summary(
420
+ decl: WorkbenchDecl,
421
+ *,
422
+ check_files: bool = False,
423
+ exp_root: Path | None = None,
424
+ runtime: ReaderRuntime | None = None,
425
+ plot_specs_override: list[Any] | None = None,
426
+ export_specs_override: list[Any] | None = None,
427
+ ) -> dict[str, Any]:
428
+ runtime = runtime or builtin_runtime()
429
+ workbench = resolve_workbench(decl)
430
+ pipeline_steps = list(workbench.pipeline)
431
+ plot_specs = list(workbench.plots) if plot_specs_override is None else list(plot_specs_override)
432
+ export_specs = list(workbench.exports) if export_specs_override is None else list(export_specs_override)
433
+ ensure_unique_workbench_ids(pipeline_steps, plot_specs, export_specs)
434
+ bound_protocol = runtime.bind_protocol(decl.experiment_semantics.protocol)
435
+ configured_plot_steps, configured_export_steps = _configured_output_steps(protocol=bound_protocol)
436
+ categories = collect_categories([*workbench.plugin_steps(), *configured_plot_steps, *configured_export_steps])
437
+ if "plot" in categories:
438
+ ensure_mpl_cache_dir()
439
+ registry = runtime.plugins if categories else None
440
+
441
+ experiment_semantics = decl.experiment_semantics
442
+ pipeline_labels = _validate_pipeline_steps(
443
+ pipeline_steps,
444
+ registry=registry,
445
+ experiment=experiment_semantics,
446
+ protocol=bound_protocol,
447
+ )
448
+ if registry is not None:
449
+ planned_record_ids = _planned_output_record_ids(
450
+ pipeline_items=pipeline_steps,
451
+ plot_items=plot_specs,
452
+ export_items=export_specs,
453
+ registry=registry,
454
+ )
455
+ _assert_no_source_record_output_collisions(
456
+ items=pipeline_steps,
457
+ planned_record_ids=planned_record_ids,
458
+ experiment_root=decl.experiment.root,
459
+ )
460
+ if plot_specs:
461
+ if registry is None:
462
+ raise ConfigError("plot validation requires plugin-backed workbench specs")
463
+ _validate_specs(
464
+ plot_specs,
465
+ label="plot",
466
+ registry=registry,
467
+ available_labels=pipeline_labels,
468
+ protocol=bound_protocol,
469
+ experiment=experiment_semantics,
470
+ )
471
+ if export_specs:
472
+ if registry is None:
473
+ raise ConfigError("export validation requires plugin-backed workbench specs")
474
+ _validate_specs(
475
+ export_specs,
476
+ label="export",
477
+ registry=registry,
478
+ available_labels=pipeline_labels,
479
+ protocol=bound_protocol,
480
+ )
481
+ configured_plot_steps = tuple(step for step in configured_plot_steps if step.id not in {s.id for s in plot_specs})
482
+ configured_export_steps = tuple(
483
+ step for step in configured_export_steps if step.id not in {s.id for s in export_specs}
484
+ )
485
+ if configured_plot_steps or configured_export_steps:
486
+ if registry is None:
487
+ raise ConfigError("configured output validation requires plugin-backed workbench specs")
488
+ _validate_configured_output_steps(
489
+ plot_steps=configured_plot_steps,
490
+ export_steps=configured_export_steps,
491
+ registry=registry,
492
+ protocol=bound_protocol,
493
+ experiment=experiment_semantics,
494
+ )
495
+ declared_entries: list[tuple[str, str, str, Path]] = []
496
+ declared_roots: list[tuple[str, str, Path]] = []
497
+ if exp_root is not None:
498
+ for label, items in (("pipeline", pipeline_steps), ("plot", plot_specs), ("export", export_specs)):
499
+ for step in items:
500
+ for key, target in (step.reads or {}).items():
501
+ if isinstance(target, FileRef | ResourceRef):
502
+ declared_entries.append(
503
+ (label, step.id, key, _resolve_exp_path(target.path, exp_root=exp_root))
504
+ )
505
+ with_block = _effective_step_with(protocol=bound_protocol, step=step) if hasattr(step, "with_") else {}
506
+ auto_roots = with_block.get("auto_roots")
507
+ if isinstance(auto_roots, list):
508
+ for root in auto_roots:
509
+ declared_roots.append((label, step.id, _resolve_exp_path(Path(root), exp_root=exp_root)))
510
+
511
+ files_checked = None
512
+ file_issues: list[str] = []
513
+ dependency_issues: list[str] = []
514
+ source_record_issues: list[str] = []
515
+ source_records_declared = 0
516
+ readiness_errors: list[str] = []
517
+ if check_files:
518
+ files_checked = (len(declared_entries), len(declared_roots))
519
+ for label, step_id, key, path in declared_entries:
520
+ if not path.exists():
521
+ file_issues.append(f"{label}:{step_id} • {key} → {_render_rel(path, exp_root=exp_root)}")
522
+ for label, step_id, root in declared_roots:
523
+ if not root.exists():
524
+ file_issues.append(f"{label}:{step_id} • auto_roots → {_render_rel(root, exp_root=exp_root)}")
525
+ if registry is not None:
526
+ source_records_declared, source_record_issues = _source_record_preflight_issues(
527
+ items=pipeline_steps,
528
+ registry=registry,
529
+ contracts=runtime.contracts,
530
+ )
531
+ for label, step_id, issue in (
532
+ _plugin_preflight_issues(
533
+ items=pipeline_steps,
534
+ label="pipeline",
535
+ registry=registry,
536
+ protocol=bound_protocol,
537
+ exp_root=exp_root,
538
+ )
539
+ + _plugin_preflight_issues(
540
+ items=plot_specs,
541
+ label="plot",
542
+ registry=registry,
543
+ protocol=bound_protocol,
544
+ exp_root=exp_root,
545
+ )
546
+ + _plugin_preflight_issues(
547
+ items=export_specs,
548
+ label="export",
549
+ registry=registry,
550
+ protocol=bound_protocol,
551
+ exp_root=exp_root,
552
+ )
553
+ ):
554
+ rendered = f"{label}:{step_id} • {issue.message}"
555
+ if issue.kind == "dependency":
556
+ dependency_issues.append(rendered)
557
+ else:
558
+ file_issues.append(rendered)
559
+ readiness_errors = [*file_issues, *dependency_issues, *source_record_issues]
560
+
561
+ file_total, root_total = files_checked or (len(declared_entries), len(declared_roots))
562
+ if not check_files:
563
+ files_payload = {
564
+ "mode": "skipped",
565
+ "checked": False,
566
+ "declared": {"file_inputs": file_total, "auto_roots": root_total},
567
+ "issues": 0,
568
+ "summary": "skipped (--no-files)",
569
+ }
570
+ elif file_total == 0 and root_total == 0 and not file_issues:
571
+ files_payload = {
572
+ "mode": "none_declared",
573
+ "checked": True,
574
+ "declared": {"file_inputs": 0, "auto_roots": 0},
575
+ "issues": 0,
576
+ "summary": "none declared",
577
+ }
578
+ else:
579
+ parts = []
580
+ if file_total:
581
+ parts.append(f"{file_total} file input(s)")
582
+ if root_total:
583
+ parts.append(f"{root_total} auto_root(s)")
584
+ if file_issues:
585
+ parts.append(f"{len(file_issues)} issue(s)")
586
+ files_payload = {
587
+ "mode": "error" if file_issues else "ok",
588
+ "checked": True,
589
+ "declared": {"file_inputs": file_total, "auto_roots": root_total},
590
+ "issues": len(file_issues),
591
+ "summary": (f"error ({', '.join(parts)})" if file_issues else f"ok ({', '.join(parts)})"),
592
+ }
593
+
594
+ if not check_files:
595
+ dependencies_payload = {
596
+ "checked": False,
597
+ "issues": 0,
598
+ "summary": "skipped (--no-files)",
599
+ }
600
+ else:
601
+ dependencies_payload = {
602
+ "checked": True,
603
+ "issues": len(dependency_issues),
604
+ "summary": (f"error ({len(dependency_issues)} issue(s))" if dependency_issues else "ok"),
605
+ }
606
+
607
+ source_records_payload = {
608
+ "checked": check_files,
609
+ "declared": source_records_declared,
610
+ "issues": len(source_record_issues),
611
+ "summary": (
612
+ "skipped (--no-files)"
613
+ if not check_files
614
+ else (
615
+ f"error ({len(source_record_issues)} issue(s))"
616
+ if source_record_issues
617
+ else f"ok ({source_records_declared} record(s))"
618
+ )
619
+ ),
620
+ }
621
+
622
+ checks = [
623
+ "schema",
624
+ "plugin availability",
625
+ "reads",
626
+ "output labels",
627
+ "plugin config",
628
+ ]
629
+ if check_files:
630
+ checks.extend(("runtime readiness", "source record revisions"))
631
+
632
+ return {
633
+ "status": "error" if readiness_errors else "ok",
634
+ "protocol": decl.experiment_semantics.protocol.id,
635
+ "counts": {
636
+ "pipeline": len(pipeline_steps),
637
+ "plots": len(plot_specs),
638
+ "exports": len(export_specs),
639
+ },
640
+ "checks": checks,
641
+ "files": files_payload,
642
+ "dependencies": dependencies_payload,
643
+ "source_records": source_records_payload,
644
+ "errors": readiness_errors,
645
+ "tip": (
646
+ f"fix readiness issues or use '{reader_command('validate', '--no-files')}' for config-only checks"
647
+ if readiness_errors
648
+ else "use 'reader explain' to see inputs/outputs"
649
+ ),
650
+ }
651
+
652
+
653
+ def validate(
654
+ decl: WorkbenchDecl,
655
+ *,
656
+ console: Console,
657
+ check_files: bool = False,
658
+ exp_root: Path | None = None,
659
+ runtime: ReaderRuntime | None = None,
660
+ ) -> dict[str, Any]:
661
+ summary = validation_summary(
662
+ decl,
663
+ check_files=check_files,
664
+ exp_root=exp_root,
665
+ runtime=runtime,
666
+ )
667
+ ok = summary["status"] == "ok"
668
+ lines = [
669
+ "[green]✓ Config validated[/green]" if ok else "[error]✗ Validation failed[/error]",
670
+ f"[dim]protocol[/dim]: {summary['protocol']}",
671
+ f"[dim]pipeline[/dim]: {summary['counts']['pipeline']}",
672
+ f"[dim]plots[/dim]: {summary['counts']['plots']}",
673
+ f"[dim]exports[/dim]: {summary['counts']['exports']}",
674
+ f"[dim]checks[/dim]: {', '.join(summary['checks'])}",
675
+ ]
676
+ lines.append(f"[dim]files[/dim]: {summary['files']['summary']}")
677
+ lines.append(f"[dim]dependencies[/dim]: {summary['dependencies']['summary']}")
678
+ lines.append(f"[dim]source records[/dim]: {summary['source_records']['summary']}")
679
+ if summary["errors"]:
680
+ lines.append("[dim]errors[/dim]:")
681
+ lines.extend(f"- {item}" for item in summary["errors"])
682
+ lines.append(f"[dim]tip[/dim]: {summary['tip']}")
683
+ console.print(Panel.fit("\n".join(lines), border_style=("green" if ok else "error"), box=box.ROUNDED))
684
+ return summary