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,1486 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field, replace
6
+ from typing import Any, Literal
7
+
8
+ from reader_workbench.errors import ConfigError
9
+ from reader_workbench.workbench.decl.model import PluginStepDecl
10
+ from reader_workbench.workbench.ontology import PluginDomain, validate_plugin_domain
11
+
12
+ MetricStage = Literal["raw", "support", "derived", "comparison", "summary", "ranking", "qc", "burden", "leakiness"]
13
+ FigureKind = Literal["qc", "kinetics", "summary", "ranking", "architecture"]
14
+ RankingDirection = Literal["higher_is_better", "lower_is_better"]
15
+ SemanticNodeKind = Literal["control_rule", "window", "metric", "ranking"]
16
+ SemanticExecutionStatus = Literal["compiled", "descriptive_only"]
17
+ ConfigFieldKind = Literal[
18
+ "mapping",
19
+ "string",
20
+ "bool",
21
+ "number",
22
+ "integer",
23
+ "string_list",
24
+ "number_list",
25
+ "scalar_list",
26
+ "mapping_list",
27
+ "scalar",
28
+ "any",
29
+ ]
30
+ ProtocolResourceKind = Literal["file"]
31
+
32
+ _UNSET = object()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ProtocolSemanticProfileSpec:
37
+ id: str
38
+ family: str
39
+ summary: str
40
+ primary_metric: str | None = None
41
+ primary_readout: str | None = None
42
+ tags: tuple[str, ...] = ()
43
+
44
+ def __post_init__(self) -> None:
45
+ profile_id = str(self.id).strip()
46
+ family = str(self.family).strip()
47
+ summary = str(self.summary).strip()
48
+ if not profile_id:
49
+ raise ValueError("ProtocolSemanticProfileSpec.id must be a non-empty string.")
50
+ if not family:
51
+ raise ValueError("ProtocolSemanticProfileSpec.family must be a non-empty string.")
52
+ if not summary:
53
+ raise ValueError("ProtocolSemanticProfileSpec.summary must be a non-empty string.")
54
+ object.__setattr__(self, "id", profile_id)
55
+ object.__setattr__(self, "family", family)
56
+ object.__setattr__(self, "summary", summary)
57
+ if self.primary_metric is not None and not str(self.primary_metric).strip():
58
+ raise ValueError("ProtocolSemanticProfileSpec.primary_metric must be a non-empty string when provided.")
59
+ if self.primary_readout is not None and not str(self.primary_readout).strip():
60
+ raise ValueError("ProtocolSemanticProfileSpec.primary_readout must be a non-empty string when provided.")
61
+ object.__setattr__(self, "tags", tuple(str(value).strip() for value in self.tags if str(value).strip()))
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ProtocolSemanticProfileOverride:
66
+ enabled: bool = True
67
+ summary: str | None = None
68
+ formula: str | None = None
69
+ depends_on: tuple[str, ...] | None = None
70
+ value_space: str | None = None
71
+ unit: str | None = None
72
+ comparable_group: str | None = None
73
+ anchor: str | None = None
74
+ selector: str | None = None
75
+ params: dict[str, Any] | None = None
76
+ match_on: tuple[str, ...] | None = None
77
+ control_selector: str | None = None
78
+ primary_metric: str | None = None
79
+ direction: RankingDirection | None = None
80
+ penalties: tuple[str, ...] | None = None
81
+ supporting_metrics: tuple[str, ...] | None = None
82
+
83
+ def __post_init__(self) -> None:
84
+ if self.summary is not None and not str(self.summary).strip():
85
+ raise ValueError("ProtocolSemanticProfileOverride.summary must be a non-empty string when provided.")
86
+ if self.formula is not None and not str(self.formula).strip():
87
+ raise ValueError("ProtocolSemanticProfileOverride.formula must be a non-empty string when provided.")
88
+ if self.value_space is not None and not str(self.value_space).strip():
89
+ raise ValueError("ProtocolSemanticProfileOverride.value_space must be a non-empty string when provided.")
90
+ if self.unit is not None and not str(self.unit).strip():
91
+ raise ValueError("ProtocolSemanticProfileOverride.unit must be a non-empty string when provided.")
92
+ if self.comparable_group is not None and not str(self.comparable_group).strip():
93
+ raise ValueError(
94
+ "ProtocolSemanticProfileOverride.comparable_group must be a non-empty string when provided."
95
+ )
96
+ if self.anchor is not None and not str(self.anchor).strip():
97
+ raise ValueError("ProtocolSemanticProfileOverride.anchor must be a non-empty string when provided.")
98
+ if self.selector is not None and not str(self.selector).strip():
99
+ raise ValueError("ProtocolSemanticProfileOverride.selector must be a non-empty string when provided.")
100
+ if self.control_selector is not None and not str(self.control_selector).strip():
101
+ raise ValueError(
102
+ "ProtocolSemanticProfileOverride.control_selector must be a non-empty string when provided."
103
+ )
104
+ if self.primary_metric is not None and not str(self.primary_metric).strip():
105
+ raise ValueError("ProtocolSemanticProfileOverride.primary_metric must be a non-empty string when provided.")
106
+ if self.depends_on is not None:
107
+ object.__setattr__(self, "depends_on", tuple(str(value) for value in self.depends_on if str(value).strip()))
108
+ if self.params is not None:
109
+ object.__setattr__(self, "params", dict(self.params or {}))
110
+ if self.match_on is not None:
111
+ object.__setattr__(self, "match_on", tuple(str(value) for value in self.match_on if str(value).strip()))
112
+ if self.penalties is not None:
113
+ object.__setattr__(self, "penalties", tuple(str(value) for value in self.penalties if str(value).strip()))
114
+ if self.supporting_metrics is not None:
115
+ object.__setattr__(
116
+ self,
117
+ "supporting_metrics",
118
+ tuple(str(value) for value in self.supporting_metrics if str(value).strip()),
119
+ )
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class ProtocolBinding:
124
+ id: str
125
+ inputs: dict[str, Any] = field(default_factory=dict)
126
+ analysis: dict[str, Any] = field(default_factory=dict)
127
+ outputs: dict[str, Any] = field(default_factory=dict)
128
+
129
+ def __post_init__(self) -> None:
130
+ protocol_id = str(self.id).strip()
131
+ if not protocol_id:
132
+ raise ValueError("ProtocolBinding.id must be a non-empty string.")
133
+ object.__setattr__(self, "id", protocol_id)
134
+ object.__setattr__(self, "inputs", dict(self.inputs or {}))
135
+ object.__setattr__(self, "analysis", dict(self.analysis or {}))
136
+ object.__setattr__(self, "outputs", dict(self.outputs or {}))
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class ProtocolResourceSpec:
141
+ id: str
142
+ path: str
143
+ summary: str
144
+ kind: ProtocolResourceKind = "file"
145
+
146
+ def __post_init__(self) -> None:
147
+ resource_id = str(self.id).strip()
148
+ path = str(self.path).strip()
149
+ summary = str(self.summary).strip()
150
+ if not resource_id:
151
+ raise ValueError("ProtocolResourceSpec.id must be a non-empty string.")
152
+ if not path:
153
+ raise ValueError("ProtocolResourceSpec.path must be a non-empty string.")
154
+ path_parts = path.replace("\\", "/").split("/")
155
+ if path.startswith(("/", "~")) or ".." in path_parts:
156
+ raise ValueError("ProtocolResourceSpec.path must stay relative to the experiment directory.")
157
+ if not summary:
158
+ raise ValueError("ProtocolResourceSpec.summary must be a non-empty string.")
159
+ object.__setattr__(self, "id", resource_id)
160
+ object.__setattr__(self, "path", path)
161
+ object.__setattr__(self, "summary", summary)
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class ProtocolConfigFieldSpec:
166
+ key: str
167
+ summary: str
168
+ kind: ConfigFieldKind = "mapping"
169
+ required: bool = False
170
+ allow_none: bool = False
171
+ choices: tuple[str, ...] = ()
172
+ children: tuple[ProtocolConfigFieldSpec, ...] = ()
173
+ allow_unknown: bool = False
174
+ default: Any = _UNSET
175
+ example: Any = _UNSET
176
+
177
+ def __post_init__(self) -> None:
178
+ key = str(self.key).strip()
179
+ if not key:
180
+ raise ValueError("ProtocolConfigFieldSpec.key must be a non-empty string.")
181
+ summary = str(self.summary).strip()
182
+ if not summary:
183
+ raise ValueError("ProtocolConfigFieldSpec.summary must be a non-empty string.")
184
+ object.__setattr__(self, "key", key)
185
+ object.__setattr__(self, "summary", summary)
186
+ object.__setattr__(self, "choices", tuple(str(value).strip() for value in self.choices if str(value).strip()))
187
+ child_keys: set[str] = set()
188
+ for child in self.children:
189
+ if child.key in child_keys:
190
+ raise ValueError(f"Duplicate child key {child.key!r} in ProtocolConfigFieldSpec {key!r}.")
191
+ child_keys.add(child.key)
192
+ if self.kind != "mapping" and self.children:
193
+ raise ValueError("ProtocolConfigFieldSpec.children are only allowed when kind='mapping'.")
194
+
195
+ @property
196
+ def has_default(self) -> bool:
197
+ return self.default is not _UNSET
198
+
199
+ @property
200
+ def has_example(self) -> bool:
201
+ return self.example is not _UNSET
202
+
203
+ def render_default(self) -> str:
204
+ if not self.has_default:
205
+ return "—"
206
+ if self.default is None:
207
+ return "null"
208
+ if isinstance(self.default, (str, int, float, bool)):
209
+ return str(self.default)
210
+ if isinstance(self.default, list):
211
+ return "[" + ", ".join(str(item) for item in self.default) + "]"
212
+ if isinstance(self.default, tuple):
213
+ return "[" + ", ".join(str(item) for item in self.default) + "]"
214
+ if isinstance(self.default, dict):
215
+ keys = ", ".join(str(key) for key in self.default)
216
+ return "{...}" if not keys else "{" + keys + "}"
217
+ return str(self.default)
218
+
219
+ def iter_rows(self, *, prefix: str = "") -> tuple[tuple[str, str, str, str, str], ...]:
220
+ path = f"{prefix}{self.key}"
221
+ rows = [
222
+ (
223
+ path,
224
+ self.kind,
225
+ "yes" if self.required else "no",
226
+ self.render_default(),
227
+ self.summary,
228
+ )
229
+ ]
230
+ child_prefix = f"{path}."
231
+ for child in self.children:
232
+ rows.extend(child.iter_rows(prefix=child_prefix))
233
+ return tuple(rows)
234
+
235
+ def validate(self, value: Any, *, path: str) -> None:
236
+ if value is None:
237
+ if self.allow_none:
238
+ return
239
+ raise ConfigError(f"{path} must not be null")
240
+
241
+ if self.kind == "mapping":
242
+ if not isinstance(value, dict):
243
+ raise ConfigError(f"{path} must be a mapping")
244
+ allowed = {child.key: child for child in self.children}
245
+ unknown = sorted(key for key in value if key not in allowed)
246
+ if unknown and not self.allow_unknown:
247
+ options = ", ".join(sorted(allowed)) or "—"
248
+ raise ConfigError(f"{path} has unknown keys {unknown}. Allowed keys: {options}")
249
+ for child in self.children:
250
+ child_path = f"{path}.{child.key}"
251
+ if child.key not in value:
252
+ if child.required and not child.has_default:
253
+ raise ConfigError(f"{child_path} is required")
254
+ continue
255
+ child.validate(value[child.key], path=child_path)
256
+ return
257
+
258
+ if self.kind == "string":
259
+ if not isinstance(value, str) or not value.strip():
260
+ raise ConfigError(f"{path} must be a non-empty string")
261
+ if self.choices and value not in self.choices:
262
+ options = ", ".join(self.choices)
263
+ raise ConfigError(f"{path} must be one of: {options}")
264
+ return
265
+
266
+ if self.kind == "bool":
267
+ if not isinstance(value, bool):
268
+ raise ConfigError(f"{path} must be true or false")
269
+ return
270
+
271
+ if self.kind == "number":
272
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
273
+ raise ConfigError(f"{path} must be a number")
274
+ return
275
+
276
+ if self.kind == "integer":
277
+ if isinstance(value, bool) or not isinstance(value, int):
278
+ raise ConfigError(f"{path} must be an integer")
279
+ return
280
+
281
+ if self.kind == "string_list":
282
+ if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
283
+ raise ConfigError(f"{path} must be a list of non-empty strings")
284
+ if self.choices:
285
+ invalid = sorted(item for item in value if item not in self.choices)
286
+ if invalid:
287
+ options = ", ".join(self.choices)
288
+ raise ConfigError(f"{path} contains unsupported values {invalid}. Allowed values: {options}")
289
+ return
290
+
291
+ if self.kind == "number_list":
292
+ if not isinstance(value, list) or any(
293
+ isinstance(item, bool) or not isinstance(item, (int, float)) for item in value
294
+ ):
295
+ raise ConfigError(f"{path} must be a list of numbers")
296
+ return
297
+
298
+ if self.kind == "scalar_list":
299
+ if not isinstance(value, list) or any(isinstance(item, (dict, list)) for item in value):
300
+ raise ConfigError(f"{path} must be a flat list of scalar values")
301
+ return
302
+
303
+ if self.kind == "mapping_list":
304
+ if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
305
+ raise ConfigError(f"{path} must be a list of mappings")
306
+ return
307
+
308
+ if self.kind == "scalar":
309
+ if isinstance(value, (dict, list)):
310
+ raise ConfigError(f"{path} must be a scalar value")
311
+ return
312
+
313
+ if self.kind == "any":
314
+ return
315
+
316
+ raise ConfigError(f"{path} uses unsupported field kind {self.kind!r}")
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class ProtocolBindingValueRef:
321
+ key: str
322
+ default: Any = _UNSET
323
+
324
+ def __post_init__(self) -> None:
325
+ if not isinstance(self.key, str) or not self.key.strip():
326
+ raise ValueError("ProtocolBindingValueRef.key must be a non-empty string.")
327
+ object.__setattr__(self, "key", self.key.strip())
328
+
329
+ @property
330
+ def has_default(self) -> bool:
331
+ return self.default is not _UNSET
332
+
333
+
334
+ def binding_value(key: str, default: Any = _UNSET) -> ProtocolBindingValueRef:
335
+ return ProtocolBindingValueRef(key=key, default=default)
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class ProtocolAnalysisChoiceRef:
340
+ key: str
341
+ cases: dict[str, Any]
342
+ default: Any = _UNSET
343
+
344
+ def __post_init__(self) -> None:
345
+ if not isinstance(self.key, str) or not self.key.strip():
346
+ raise ValueError("ProtocolAnalysisChoiceRef.key must be a non-empty string.")
347
+ if not isinstance(self.cases, dict) or not self.cases:
348
+ raise ValueError("ProtocolAnalysisChoiceRef.cases must be a non-empty mapping.")
349
+ object.__setattr__(self, "key", self.key.strip())
350
+ object.__setattr__(self, "cases", {str(case_key): deepcopy(value) for case_key, value in self.cases.items()})
351
+
352
+ @property
353
+ def has_default(self) -> bool:
354
+ return self.default is not _UNSET
355
+
356
+
357
+ def analysis_choice(key: str, cases: dict[str, Any], default: Any = _UNSET) -> ProtocolAnalysisChoiceRef:
358
+ return ProtocolAnalysisChoiceRef(key=key, cases=cases, default=default)
359
+
360
+
361
+ @dataclass(frozen=True)
362
+ class ProtocolFactorSpec:
363
+ name: str
364
+ role: str
365
+ summary: str
366
+ required: bool = True
367
+ repeatable: bool = False
368
+
369
+ def __post_init__(self) -> None:
370
+ if not str(self.name).strip():
371
+ raise ValueError("ProtocolFactorSpec.name must be a non-empty string.")
372
+ if not str(self.role).strip():
373
+ raise ValueError("ProtocolFactorSpec.role must be a non-empty string.")
374
+ if not str(self.summary).strip():
375
+ raise ValueError("ProtocolFactorSpec.summary must be a non-empty string.")
376
+
377
+
378
+ @dataclass(frozen=True)
379
+ class ProtocolControlRule:
380
+ id: str
381
+ summary: str
382
+ match_on: tuple[str, ...] = ()
383
+ control_selector: str | None = None
384
+ profiles: tuple[str, ...] = ()
385
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride] = field(default_factory=dict)
386
+
387
+ def __post_init__(self) -> None:
388
+ if not str(self.id).strip():
389
+ raise ValueError("ProtocolControlRule.id must be a non-empty string.")
390
+ if not str(self.summary).strip():
391
+ raise ValueError("ProtocolControlRule.summary must be a non-empty string.")
392
+ object.__setattr__(self, "match_on", tuple(str(value) for value in self.match_on))
393
+ if self.control_selector is not None and not str(self.control_selector).strip():
394
+ raise ValueError("ProtocolControlRule.control_selector must be a non-empty string when provided.")
395
+ object.__setattr__(self, "profiles", tuple(str(value).strip() for value in self.profiles if str(value).strip()))
396
+ object.__setattr__(self, "profile_overrides", dict(self.profile_overrides or {}))
397
+
398
+
399
+ @dataclass(frozen=True)
400
+ class ProtocolWindowSpec:
401
+ id: str
402
+ summary: str
403
+ anchor: str
404
+ selector: str
405
+ params: dict[str, Any] = field(default_factory=dict)
406
+ profiles: tuple[str, ...] = ()
407
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride] = field(default_factory=dict)
408
+
409
+ def __post_init__(self) -> None:
410
+ if not str(self.id).strip():
411
+ raise ValueError("ProtocolWindowSpec.id must be a non-empty string.")
412
+ if not str(self.summary).strip():
413
+ raise ValueError("ProtocolWindowSpec.summary must be a non-empty string.")
414
+ if not str(self.anchor).strip():
415
+ raise ValueError("ProtocolWindowSpec.anchor must be a non-empty string.")
416
+ if not str(self.selector).strip():
417
+ raise ValueError("ProtocolWindowSpec.selector must be a non-empty string.")
418
+ object.__setattr__(self, "params", dict(self.params or {}))
419
+ object.__setattr__(self, "profiles", tuple(str(value).strip() for value in self.profiles if str(value).strip()))
420
+ object.__setattr__(self, "profile_overrides", dict(self.profile_overrides or {}))
421
+
422
+
423
+ @dataclass(frozen=True)
424
+ class ProtocolMetricSpec:
425
+ id: str
426
+ stage: MetricStage
427
+ summary: str
428
+ formula: str
429
+ depends_on: tuple[str, ...] = ()
430
+ value_space: str | None = None
431
+ unit: str | None = None
432
+ comparable_group: str | None = None
433
+ notes: tuple[str, ...] = ()
434
+ profiles: tuple[str, ...] = ()
435
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride] = field(default_factory=dict)
436
+
437
+ def __post_init__(self) -> None:
438
+ if not str(self.id).strip():
439
+ raise ValueError("ProtocolMetricSpec.id must be a non-empty string.")
440
+ if not str(self.summary).strip():
441
+ raise ValueError("ProtocolMetricSpec.summary must be a non-empty string.")
442
+ if not str(self.formula).strip():
443
+ raise ValueError("ProtocolMetricSpec.formula must be a non-empty string.")
444
+ if self.value_space is not None and not str(self.value_space).strip():
445
+ raise ValueError("ProtocolMetricSpec.value_space must be a non-empty string when provided.")
446
+ if self.unit is not None and not str(self.unit).strip():
447
+ raise ValueError("ProtocolMetricSpec.unit must be a non-empty string when provided.")
448
+ if self.comparable_group is not None and not str(self.comparable_group).strip():
449
+ raise ValueError("ProtocolMetricSpec.comparable_group must be a non-empty string when provided.")
450
+ object.__setattr__(self, "depends_on", tuple(str(value) for value in self.depends_on))
451
+ object.__setattr__(self, "notes", tuple(str(value) for value in self.notes))
452
+ object.__setattr__(self, "profiles", tuple(str(value).strip() for value in self.profiles if str(value).strip()))
453
+ object.__setattr__(self, "profile_overrides", dict(self.profile_overrides or {}))
454
+
455
+
456
+ @dataclass(frozen=True)
457
+ class ProtocolEffectSignSpec:
458
+ target: str
459
+ expected_sign: Literal["positive", "negative"]
460
+ summary: str
461
+
462
+ def __post_init__(self) -> None:
463
+ if not str(self.target).strip():
464
+ raise ValueError("ProtocolEffectSignSpec.target must be a non-empty string.")
465
+ if not str(self.summary).strip():
466
+ raise ValueError("ProtocolEffectSignSpec.summary must be a non-empty string.")
467
+
468
+
469
+ @dataclass(frozen=True)
470
+ class ProtocolFigureSpec:
471
+ id: str
472
+ kind: FigureKind
473
+ summary: str
474
+ primary: bool = False
475
+
476
+ def __post_init__(self) -> None:
477
+ if not str(self.id).strip():
478
+ raise ValueError("ProtocolFigureSpec.id must be a non-empty string.")
479
+ if not str(self.summary).strip():
480
+ raise ValueError("ProtocolFigureSpec.summary must be a non-empty string.")
481
+
482
+
483
+ @dataclass(frozen=True)
484
+ class ProtocolPlotProfileSpec:
485
+ id: str
486
+ summary: str
487
+ figures: tuple[str, ...] = ()
488
+
489
+ def __post_init__(self) -> None:
490
+ if not str(self.id).strip():
491
+ raise ValueError("ProtocolPlotProfileSpec.id must be a non-empty string.")
492
+ if not str(self.summary).strip():
493
+ raise ValueError("ProtocolPlotProfileSpec.summary must be a non-empty string.")
494
+ object.__setattr__(self, "figures", tuple(str(value) for value in self.figures))
495
+
496
+
497
+ @dataclass(frozen=True)
498
+ class ProtocolArtifactSpec:
499
+ id: str
500
+ summary: str
501
+ default: bool = False
502
+
503
+ def __post_init__(self) -> None:
504
+ if not str(self.id).strip():
505
+ raise ValueError("ProtocolArtifactSpec.id must be a non-empty string.")
506
+ if not str(self.summary).strip():
507
+ raise ValueError("ProtocolArtifactSpec.summary must be a non-empty string.")
508
+
509
+
510
+ @dataclass(frozen=True)
511
+ class ProtocolRankingSpec:
512
+ primary_metric: str
513
+ direction: RankingDirection
514
+ penalties: tuple[str, ...] = ()
515
+ supporting_metrics: tuple[str, ...] = ()
516
+ summary: str = ""
517
+ profiles: tuple[str, ...] = ()
518
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride] = field(default_factory=dict)
519
+
520
+ def __post_init__(self) -> None:
521
+ if not str(self.primary_metric).strip():
522
+ raise ValueError("ProtocolRankingSpec.primary_metric must be a non-empty string.")
523
+ if not str(self.summary).strip():
524
+ raise ValueError("ProtocolRankingSpec.summary must be a non-empty string.")
525
+ object.__setattr__(self, "penalties", tuple(str(value) for value in self.penalties))
526
+ object.__setattr__(self, "supporting_metrics", tuple(str(value) for value in self.supporting_metrics))
527
+ object.__setattr__(self, "profiles", tuple(str(value).strip() for value in self.profiles if str(value).strip()))
528
+ object.__setattr__(self, "profile_overrides", dict(self.profile_overrides or {}))
529
+
530
+
531
+ @dataclass(frozen=True)
532
+ class ProtocolSemanticExecution:
533
+ status: SemanticExecutionStatus = "descriptive_only"
534
+ step_ids: tuple[str, ...] = ()
535
+ plugin_ids: tuple[str, ...] = ()
536
+ record_ids: tuple[str, ...] = ()
537
+ config_paths: tuple[str, ...] = ()
538
+ note: str = ""
539
+
540
+ def __post_init__(self) -> None:
541
+ status = str(self.status).strip()
542
+ if status not in {"compiled", "descriptive_only"}:
543
+ raise ValueError("ProtocolSemanticExecution.status must be 'compiled' or 'descriptive_only'.")
544
+ object.__setattr__(self, "status", status)
545
+ object.__setattr__(self, "step_ids", tuple(str(value) for value in self.step_ids if str(value).strip()))
546
+ object.__setattr__(self, "plugin_ids", tuple(str(value) for value in self.plugin_ids if str(value).strip()))
547
+ object.__setattr__(self, "record_ids", tuple(str(value) for value in self.record_ids if str(value).strip()))
548
+ object.__setattr__(
549
+ self,
550
+ "config_paths",
551
+ tuple(str(value) for value in self.config_paths if str(value).strip()),
552
+ )
553
+ object.__setattr__(self, "note", str(self.note).strip())
554
+
555
+
556
+ @dataclass(frozen=True)
557
+ class ProtocolSemanticNode:
558
+ id: str
559
+ kind: SemanticNodeKind
560
+ summary: str
561
+ profiles: tuple[str, ...] = ()
562
+ stage: MetricStage | None = None
563
+ formula: str | None = None
564
+ depends_on: tuple[str, ...] = ()
565
+ value_space: str | None = None
566
+ unit: str | None = None
567
+ comparable_group: str | None = None
568
+ anchor: str | None = None
569
+ selector: str | None = None
570
+ params: dict[str, Any] = field(default_factory=dict)
571
+ match_on: tuple[str, ...] = ()
572
+ control_selector: str | None = None
573
+ primary_metric: str | None = None
574
+ direction: RankingDirection | None = None
575
+ penalties: tuple[str, ...] = ()
576
+ supporting_metrics: tuple[str, ...] = ()
577
+ execution: ProtocolSemanticExecution = field(default_factory=ProtocolSemanticExecution)
578
+
579
+ def __post_init__(self) -> None:
580
+ if not str(self.id).strip():
581
+ raise ValueError("ProtocolSemanticNode.id must be a non-empty string.")
582
+ if not str(self.summary).strip():
583
+ raise ValueError("ProtocolSemanticNode.summary must be a non-empty string.")
584
+ if self.value_space is not None and not str(self.value_space).strip():
585
+ raise ValueError("ProtocolSemanticNode.value_space must be a non-empty string when provided.")
586
+ if self.unit is not None and not str(self.unit).strip():
587
+ raise ValueError("ProtocolSemanticNode.unit must be a non-empty string when provided.")
588
+ if self.comparable_group is not None and not str(self.comparable_group).strip():
589
+ raise ValueError("ProtocolSemanticNode.comparable_group must be a non-empty string when provided.")
590
+ object.__setattr__(self, "profiles", tuple(str(value).strip() for value in self.profiles if str(value).strip()))
591
+ object.__setattr__(self, "depends_on", tuple(str(value) for value in self.depends_on if str(value).strip()))
592
+ object.__setattr__(self, "params", dict(self.params or {}))
593
+ object.__setattr__(self, "match_on", tuple(str(value) for value in self.match_on if str(value).strip()))
594
+ object.__setattr__(self, "penalties", tuple(str(value) for value in self.penalties if str(value).strip()))
595
+ object.__setattr__(
596
+ self,
597
+ "supporting_metrics",
598
+ tuple(str(value) for value in self.supporting_metrics if str(value).strip()),
599
+ )
600
+
601
+ def with_execution(self, execution: ProtocolSemanticExecution) -> ProtocolSemanticNode:
602
+ return replace(self, execution=execution)
603
+
604
+
605
+ @dataclass(frozen=True)
606
+ class ProtocolSemanticProgram:
607
+ protocol: str
608
+ profiles: tuple[ProtocolSemanticProfileSpec, ...] = ()
609
+ active_profile: str | None = None
610
+ controls: tuple[ProtocolSemanticNode, ...] = ()
611
+ windows: tuple[ProtocolSemanticNode, ...] = ()
612
+ metrics: tuple[ProtocolSemanticNode, ...] = ()
613
+ ranking: ProtocolSemanticNode | None = None
614
+
615
+ def __post_init__(self) -> None:
616
+ if not str(self.protocol).strip():
617
+ raise ValueError("ProtocolSemanticProgram.protocol must be a non-empty string.")
618
+ profile_ids: set[str] = set()
619
+ for profile in self.profiles:
620
+ if profile.id in profile_ids:
621
+ raise ValueError(f"Duplicate semantic profile {profile.id!r}.")
622
+ profile_ids.add(profile.id)
623
+ if self.active_profile is not None and self.active_profile not in profile_ids:
624
+ raise ValueError(f"Unknown active semantic profile {self.active_profile!r}.")
625
+
626
+ def _assert_known_profiles(node_profiles: tuple[str, ...], *, where: str) -> None:
627
+ unknown_profiles = sorted(set(node_profiles) - profile_ids)
628
+ if unknown_profiles:
629
+ options = ", ".join(sorted(profile_ids)) or "—"
630
+ raise ValueError(
631
+ f"{where} references unknown semantic profiles: {', '.join(unknown_profiles)}. "
632
+ f"Known profiles: {options}."
633
+ )
634
+
635
+ for group_name, nodes in (
636
+ ("controls", self.controls),
637
+ ("windows", self.windows),
638
+ ("metrics", self.metrics),
639
+ ):
640
+ seen: set[str] = set()
641
+ for node in nodes:
642
+ if node.id in seen:
643
+ raise ValueError(f"Duplicate semantic node {node.id!r} in {group_name}.")
644
+ seen.add(node.id)
645
+ _assert_known_profiles(node.profiles, where=f"ProtocolSemanticProgram.{group_name} item {node.id!r}")
646
+ if self.ranking is not None and self.ranking.kind != "ranking":
647
+ raise ValueError("ProtocolSemanticProgram.ranking must have kind='ranking'.")
648
+ if self.ranking is not None:
649
+ _assert_known_profiles(self.ranking.profiles, where="ProtocolSemanticProgram.ranking")
650
+
651
+ node_ids = {node.id for node in (*self.controls, *self.windows, *self.metrics)}
652
+ metric_ids = {node.id for node in self.metrics}
653
+ for node in self.metrics:
654
+ for dependency_id in node.depends_on:
655
+ if dependency_id not in node_ids:
656
+ options = ", ".join(sorted(node_ids)) or "—"
657
+ raise ValueError(
658
+ f"ProtocolSemanticProgram metric {node.id!r} depends on unknown node {dependency_id!r}. "
659
+ f"Known semantic ids: {options}."
660
+ )
661
+ if self.ranking is not None:
662
+ referenced_metrics = (
663
+ self.ranking.primary_metric,
664
+ *self.ranking.penalties,
665
+ *self.ranking.supporting_metrics,
666
+ )
667
+ for metric_id in referenced_metrics:
668
+ if metric_id == "domain_defined":
669
+ continue
670
+ if metric_id not in metric_ids:
671
+ options = ", ".join(sorted(metric_ids)) or "—"
672
+ raise ValueError(
673
+ f"ProtocolSemanticProgram ranking references unknown metric {metric_id!r}. "
674
+ f"Known metrics: {options}."
675
+ )
676
+
677
+ @property
678
+ def has_nodes(self) -> bool:
679
+ return bool(self.controls or self.windows or self.metrics or self.ranking is not None)
680
+
681
+ def with_execution_overrides(
682
+ self,
683
+ overrides: dict[str, ProtocolSemanticExecution] | None,
684
+ *,
685
+ protocol_id: str | None = None,
686
+ ) -> ProtocolSemanticProgram:
687
+ overrides = dict(overrides or {})
688
+ if not overrides:
689
+ return self
690
+ valid_ids = {
691
+ *(node.id for node in self.controls),
692
+ *(node.id for node in self.windows),
693
+ *(node.id for node in self.metrics),
694
+ }
695
+ if self.ranking is not None:
696
+ valid_ids.add(self.ranking.id)
697
+ unknown_override_ids = sorted(set(overrides) - valid_ids)
698
+ if unknown_override_ids:
699
+ options = ", ".join(sorted(valid_ids)) or "—"
700
+ protocol_label = protocol_id or self.protocol
701
+ raise ConfigError(
702
+ f"Semantic execution overrides reference unknown ids {unknown_override_ids} for protocol "
703
+ f"{protocol_label!r}. Known semantic ids: {options}"
704
+ )
705
+
706
+ def _apply(nodes: tuple[ProtocolSemanticNode, ...]) -> tuple[ProtocolSemanticNode, ...]:
707
+ return tuple(node.with_execution(overrides.get(node.id, node.execution)) for node in nodes)
708
+
709
+ ranking = self.ranking
710
+ if ranking is not None:
711
+ ranking = ranking.with_execution(overrides.get(ranking.id, ranking.execution))
712
+ return replace(
713
+ self,
714
+ controls=_apply(self.controls),
715
+ windows=_apply(self.windows),
716
+ metrics=_apply(self.metrics),
717
+ ranking=ranking,
718
+ )
719
+
720
+
721
+ @dataclass(frozen=True)
722
+ class ProtocolPluginDefaultsSpec:
723
+ plugin: str
724
+ summary: str
725
+ with_: dict[str, Any] = field(default_factory=dict)
726
+
727
+ def __post_init__(self) -> None:
728
+ if not isinstance(self.plugin, str) or not self.plugin.strip():
729
+ raise ValueError("ProtocolPluginDefaultsSpec.plugin must be a non-empty string.")
730
+ if "/" not in self.plugin:
731
+ raise ValueError("ProtocolPluginDefaultsSpec.plugin must be 'category/key'.")
732
+ if not isinstance(self.summary, str) or not self.summary.strip():
733
+ raise ValueError("ProtocolPluginDefaultsSpec.summary must be a non-empty string.")
734
+ object.__setattr__(self, "plugin", self.plugin.strip())
735
+ object.__setattr__(self, "with_", dict(self.with_ or {}))
736
+
737
+
738
+ @dataclass(frozen=True)
739
+ class CompiledProtocolPlan:
740
+ semantic_program: ProtocolSemanticProgram
741
+ runtime: dict[str, Any] = field(default_factory=dict)
742
+ pipeline: tuple[PluginStepDecl, ...] = ()
743
+ plots: tuple[PluginStepDecl, ...] = ()
744
+ exports: tuple[PluginStepDecl, ...] = ()
745
+
746
+ def __post_init__(self) -> None:
747
+ if not isinstance(self.semantic_program, ProtocolSemanticProgram):
748
+ raise ValueError("CompiledProtocolPlan.semantic_program must be a ProtocolSemanticProgram instance.")
749
+ object.__setattr__(self, "runtime", dict(self.runtime or {}))
750
+ object.__setattr__(self, "pipeline", tuple(self.pipeline or ()))
751
+ object.__setattr__(self, "plots", tuple(self.plots or ()))
752
+ object.__setattr__(self, "exports", tuple(self.exports or ()))
753
+
754
+
755
+ ProtocolCompiler = Callable[["BoundProtocol"], CompiledProtocolPlan]
756
+
757
+
758
+ @dataclass(frozen=True)
759
+ class ProtocolExecutionPlan:
760
+ plugin_defaults: tuple[ProtocolPluginDefaultsSpec, ...] = ()
761
+ compiler: ProtocolCompiler | None = None
762
+
763
+ def __post_init__(self) -> None:
764
+ seen: set[str] = set()
765
+ for item in self.plugin_defaults:
766
+ if item.plugin in seen:
767
+ raise ValueError(f"Duplicate protocol plugin defaults for {item.plugin!r}.")
768
+ seen.add(item.plugin)
769
+ if self.compiler is not None and not callable(self.compiler):
770
+ raise ValueError("ProtocolExecutionPlan.compiler must be callable when provided.")
771
+
772
+
773
+ @dataclass(frozen=True)
774
+ class ProtocolDescriptor:
775
+ protocol: str
776
+ domain: PluginDomain
777
+ family: str
778
+ summary: str
779
+ execution: ProtocolExecutionPlan
780
+ tags: tuple[str, ...] = ()
781
+ resources: tuple[ProtocolResourceSpec, ...] = ()
782
+ example_annotations: dict[str, Any] = field(default_factory=dict)
783
+ input_fields: tuple[ProtocolConfigFieldSpec, ...] = ()
784
+ analysis_fields: tuple[ProtocolConfigFieldSpec, ...] = ()
785
+ factors: tuple[ProtocolFactorSpec, ...] = ()
786
+ control_rules: tuple[ProtocolControlRule, ...] = ()
787
+ windows: tuple[ProtocolWindowSpec, ...] = ()
788
+ metrics: tuple[ProtocolMetricSpec, ...] = ()
789
+ effect_signs: tuple[ProtocolEffectSignSpec, ...] = ()
790
+ semantic_profiles: tuple[ProtocolSemanticProfileSpec, ...] = ()
791
+ figures: tuple[ProtocolFigureSpec, ...] = ()
792
+ plot_profiles: tuple[ProtocolPlotProfileSpec, ...] = ()
793
+ default_plot_profile: str | None = None
794
+ artifacts: tuple[ProtocolArtifactSpec, ...] = ()
795
+ ranking: ProtocolRankingSpec | None = None
796
+
797
+ def __post_init__(self) -> None:
798
+ object.__setattr__(self, "domain", validate_plugin_domain(self.domain))
799
+ if not str(self.protocol).strip():
800
+ raise ValueError("ProtocolDescriptor.protocol must be a non-empty string.")
801
+ if not str(self.family).strip():
802
+ raise ValueError("ProtocolDescriptor.family must be a non-empty string.")
803
+ if not str(self.summary).strip():
804
+ raise ValueError("ProtocolDescriptor.summary must be a non-empty string.")
805
+ object.__setattr__(self, "tags", tuple(str(value) for value in self.tags))
806
+ resource_ids: set[str] = set()
807
+ for item in self.resources:
808
+ if item.id in resource_ids:
809
+ raise ValueError(f"Duplicate protocol resource {item.id!r}.")
810
+ resource_ids.add(item.id)
811
+ object.__setattr__(self, "resources", tuple(self.resources))
812
+ object.__setattr__(self, "example_annotations", deepcopy(self.example_annotations or {}))
813
+ semantic_profile_ids: set[str] = set()
814
+ for item in self.semantic_profiles:
815
+ if item.id in semantic_profile_ids:
816
+ raise ValueError(f"Duplicate protocol semantic profile {item.id!r}.")
817
+ semantic_profile_ids.add(item.id)
818
+ figure_ids: set[str] = set()
819
+ for item in self.figures:
820
+ if item.id in figure_ids:
821
+ raise ValueError(f"Duplicate protocol figure {item.id!r}.")
822
+ figure_ids.add(item.id)
823
+ plot_profile_ids: set[str] = set()
824
+ for item in self.plot_profiles:
825
+ if item.id in plot_profile_ids:
826
+ raise ValueError(f"Duplicate protocol plot profile {item.id!r}.")
827
+ plot_profile_ids.add(item.id)
828
+ unknown = sorted(set(item.figures) - figure_ids)
829
+ if unknown:
830
+ raise ValueError(f"Protocol plot profile {item.id!r} references unknown figures: {', '.join(unknown)}.")
831
+ if self.default_plot_profile is not None:
832
+ default_plot_profile = str(self.default_plot_profile).strip()
833
+ if not default_plot_profile:
834
+ raise ValueError("ProtocolDescriptor.default_plot_profile must be a non-empty string when provided.")
835
+ if default_plot_profile not in plot_profile_ids:
836
+ raise ValueError(
837
+ f"ProtocolDescriptor.default_plot_profile {default_plot_profile!r} is not defined in plot_profiles."
838
+ )
839
+ object.__setattr__(self, "default_plot_profile", default_plot_profile)
840
+ artifact_ids: set[str] = set()
841
+ for item in self.artifacts:
842
+ if item.id in artifact_ids:
843
+ raise ValueError(f"Duplicate protocol artifact {item.id!r}.")
844
+ artifact_ids.add(item.id)
845
+ self._validate_semantic_profile_references(semantic_profile_ids)
846
+ self._validate_ranking_metric_references()
847
+
848
+ def _validate_semantic_profile_references(self, profile_ids: set[str]) -> None:
849
+ if not profile_ids:
850
+ return
851
+ groups = (
852
+ ("control_rules", self.control_rules),
853
+ ("windows", self.windows),
854
+ ("metrics", self.metrics),
855
+ )
856
+ for group_name, items in groups:
857
+ for item in items:
858
+ unknown = sorted(set(item.profiles) - profile_ids)
859
+ if unknown:
860
+ raise ValueError(
861
+ f"ProtocolDescriptor.{group_name} item {item.id!r} references unknown semantic profiles: "
862
+ f"{', '.join(unknown)}."
863
+ )
864
+ unknown_overrides = sorted(set(item.profile_overrides) - profile_ids)
865
+ if unknown_overrides:
866
+ raise ValueError(
867
+ f"ProtocolDescriptor.{group_name} item {item.id!r} overrides unknown semantic profiles: "
868
+ f"{', '.join(unknown_overrides)}."
869
+ )
870
+ if self.ranking is not None:
871
+ unknown = sorted(set(self.ranking.profiles) - profile_ids)
872
+ if unknown:
873
+ raise ValueError(
874
+ "ProtocolDescriptor.ranking references unknown semantic profiles: " + ", ".join(unknown) + "."
875
+ )
876
+ unknown_overrides = sorted(set(self.ranking.profile_overrides) - profile_ids)
877
+ if unknown_overrides:
878
+ raise ValueError(
879
+ "ProtocolDescriptor.ranking overrides unknown semantic profiles: "
880
+ + ", ".join(unknown_overrides)
881
+ + "."
882
+ )
883
+
884
+ def _validate_ranking_metric_references(self) -> None:
885
+ if self.ranking is None:
886
+ return
887
+ metric_ids = {item.id for item in self.metrics}
888
+
889
+ def _assert_known(metric_id: str, *, where: str) -> None:
890
+ if metric_id == "domain_defined":
891
+ return
892
+ if metric_id not in metric_ids:
893
+ options = ", ".join(sorted(metric_ids)) or "—"
894
+ raise ValueError(f"{where} references unknown metric {metric_id!r}. Known metrics: {options}.")
895
+
896
+ _assert_known(self.ranking.primary_metric, where="ProtocolDescriptor.ranking.primary_metric")
897
+ for metric_id in self.ranking.penalties:
898
+ _assert_known(metric_id, where="ProtocolDescriptor.ranking.penalties")
899
+ for metric_id in self.ranking.supporting_metrics:
900
+ _assert_known(metric_id, where="ProtocolDescriptor.ranking.supporting_metrics")
901
+ for profile_id, override in self.ranking.profile_overrides.items():
902
+ if override.primary_metric is not None:
903
+ _assert_known(
904
+ override.primary_metric,
905
+ where=f"ProtocolDescriptor.ranking.profile_overrides[{profile_id!r}].primary_metric",
906
+ )
907
+ if override.penalties is not None:
908
+ for metric_id in override.penalties:
909
+ _assert_known(
910
+ metric_id,
911
+ where=f"ProtocolDescriptor.ranking.profile_overrides[{profile_id!r}].penalties",
912
+ )
913
+ if override.supporting_metrics is not None:
914
+ for metric_id in override.supporting_metrics:
915
+ _assert_known(
916
+ metric_id,
917
+ where=f"ProtocolDescriptor.ranking.profile_overrides[{profile_id!r}].supporting_metrics",
918
+ )
919
+
920
+ def validate_authoring(self, *, inputs: dict[str, Any], analysis: dict[str, Any]) -> None:
921
+ _validate_protocol_surface(inputs, fields=self.input_fields, path="protocol.inputs", protocol_id=self.protocol)
922
+ _validate_protocol_surface(
923
+ analysis,
924
+ fields=self.analysis_fields,
925
+ path="protocol.analysis",
926
+ protocol_id=self.protocol,
927
+ )
928
+
929
+ def semantic_program(self, *, active_profile: str | None = None) -> ProtocolSemanticProgram:
930
+ profile_ids = tuple(item.id for item in self.semantic_profiles)
931
+ if active_profile is not None and active_profile not in profile_ids:
932
+ raise ValueError(f"Unknown semantic profile {active_profile!r} for protocol {self.protocol!r}.")
933
+
934
+ def _profile_enabled(
935
+ *,
936
+ profiles: tuple[str, ...],
937
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride],
938
+ ) -> bool:
939
+ if active_profile is None:
940
+ return True
941
+ if profiles and active_profile not in profiles:
942
+ return False
943
+ override = profile_overrides.get(active_profile)
944
+ return not (override is not None and not override.enabled)
945
+
946
+ def _override(
947
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride],
948
+ ) -> ProtocolSemanticProfileOverride | None:
949
+ if active_profile is None:
950
+ return None
951
+ return profile_overrides.get(active_profile)
952
+
953
+ def _node_profiles(
954
+ profiles: tuple[str, ...],
955
+ profile_overrides: dict[str, ProtocolSemanticProfileOverride],
956
+ ) -> tuple[str, ...]:
957
+ if profiles:
958
+ return profiles
959
+ if profile_ids:
960
+ return profile_ids
961
+ if profile_overrides:
962
+ return tuple(sorted(profile_overrides))
963
+ return ()
964
+
965
+ return ProtocolSemanticProgram(
966
+ protocol=self.protocol,
967
+ profiles=self.semantic_profiles,
968
+ active_profile=active_profile,
969
+ controls=tuple(
970
+ ProtocolSemanticNode(
971
+ id=item.id,
972
+ kind="control_rule",
973
+ summary=(
974
+ _override(item.profile_overrides).summary
975
+ if _override(item.profile_overrides) is not None
976
+ and _override(item.profile_overrides).summary is not None
977
+ else item.summary
978
+ ),
979
+ profiles=_node_profiles(item.profiles, item.profile_overrides),
980
+ match_on=(
981
+ _override(item.profile_overrides).match_on
982
+ if _override(item.profile_overrides) is not None
983
+ and _override(item.profile_overrides).match_on is not None
984
+ else item.match_on
985
+ ),
986
+ control_selector=(
987
+ _override(item.profile_overrides).control_selector
988
+ if _override(item.profile_overrides) is not None
989
+ and _override(item.profile_overrides).control_selector is not None
990
+ else item.control_selector
991
+ ),
992
+ )
993
+ for item in self.control_rules
994
+ if _profile_enabled(profiles=item.profiles, profile_overrides=item.profile_overrides)
995
+ ),
996
+ windows=tuple(
997
+ ProtocolSemanticNode(
998
+ id=item.id,
999
+ kind="window",
1000
+ summary=(
1001
+ _override(item.profile_overrides).summary
1002
+ if _override(item.profile_overrides) is not None
1003
+ and _override(item.profile_overrides).summary is not None
1004
+ else item.summary
1005
+ ),
1006
+ profiles=_node_profiles(item.profiles, item.profile_overrides),
1007
+ anchor=(
1008
+ _override(item.profile_overrides).anchor
1009
+ if _override(item.profile_overrides) is not None
1010
+ and _override(item.profile_overrides).anchor is not None
1011
+ else item.anchor
1012
+ ),
1013
+ selector=(
1014
+ _override(item.profile_overrides).selector
1015
+ if _override(item.profile_overrides) is not None
1016
+ and _override(item.profile_overrides).selector is not None
1017
+ else item.selector
1018
+ ),
1019
+ params=(
1020
+ _override(item.profile_overrides).params
1021
+ if _override(item.profile_overrides) is not None
1022
+ and _override(item.profile_overrides).params is not None
1023
+ else item.params
1024
+ ),
1025
+ )
1026
+ for item in self.windows
1027
+ if _profile_enabled(profiles=item.profiles, profile_overrides=item.profile_overrides)
1028
+ ),
1029
+ metrics=tuple(
1030
+ ProtocolSemanticNode(
1031
+ id=item.id,
1032
+ kind="metric",
1033
+ summary=(
1034
+ _override(item.profile_overrides).summary
1035
+ if _override(item.profile_overrides) is not None
1036
+ and _override(item.profile_overrides).summary is not None
1037
+ else item.summary
1038
+ ),
1039
+ profiles=_node_profiles(item.profiles, item.profile_overrides),
1040
+ stage=item.stage,
1041
+ formula=(
1042
+ _override(item.profile_overrides).formula
1043
+ if _override(item.profile_overrides) is not None
1044
+ and _override(item.profile_overrides).formula is not None
1045
+ else item.formula
1046
+ ),
1047
+ depends_on=(
1048
+ _override(item.profile_overrides).depends_on
1049
+ if _override(item.profile_overrides) is not None
1050
+ and _override(item.profile_overrides).depends_on is not None
1051
+ else item.depends_on
1052
+ ),
1053
+ value_space=(
1054
+ _override(item.profile_overrides).value_space
1055
+ if _override(item.profile_overrides) is not None
1056
+ and _override(item.profile_overrides).value_space is not None
1057
+ else item.value_space
1058
+ ),
1059
+ unit=(
1060
+ _override(item.profile_overrides).unit
1061
+ if _override(item.profile_overrides) is not None
1062
+ and _override(item.profile_overrides).unit is not None
1063
+ else item.unit
1064
+ ),
1065
+ comparable_group=(
1066
+ _override(item.profile_overrides).comparable_group
1067
+ if _override(item.profile_overrides) is not None
1068
+ and _override(item.profile_overrides).comparable_group is not None
1069
+ else item.comparable_group
1070
+ ),
1071
+ )
1072
+ for item in self.metrics
1073
+ if _profile_enabled(profiles=item.profiles, profile_overrides=item.profile_overrides)
1074
+ ),
1075
+ ranking=(
1076
+ ProtocolSemanticNode(
1077
+ id="ranking",
1078
+ kind="ranking",
1079
+ summary=(
1080
+ _override(self.ranking.profile_overrides).summary
1081
+ if _override(self.ranking.profile_overrides) is not None
1082
+ and _override(self.ranking.profile_overrides).summary is not None
1083
+ else self.ranking.summary
1084
+ ),
1085
+ profiles=_node_profiles(self.ranking.profiles, self.ranking.profile_overrides),
1086
+ primary_metric=(
1087
+ _override(self.ranking.profile_overrides).primary_metric
1088
+ if _override(self.ranking.profile_overrides) is not None
1089
+ and _override(self.ranking.profile_overrides).primary_metric is not None
1090
+ else self.ranking.primary_metric
1091
+ ),
1092
+ direction=(
1093
+ _override(self.ranking.profile_overrides).direction
1094
+ if _override(self.ranking.profile_overrides) is not None
1095
+ and _override(self.ranking.profile_overrides).direction is not None
1096
+ else self.ranking.direction
1097
+ ),
1098
+ penalties=(
1099
+ _override(self.ranking.profile_overrides).penalties
1100
+ if _override(self.ranking.profile_overrides) is not None
1101
+ and _override(self.ranking.profile_overrides).penalties is not None
1102
+ else self.ranking.penalties
1103
+ ),
1104
+ supporting_metrics=(
1105
+ _override(self.ranking.profile_overrides).supporting_metrics
1106
+ if _override(self.ranking.profile_overrides) is not None
1107
+ and _override(self.ranking.profile_overrides).supporting_metrics is not None
1108
+ else self.ranking.supporting_metrics
1109
+ ),
1110
+ )
1111
+ if self.ranking is not None
1112
+ and _profile_enabled(
1113
+ profiles=self.ranking.profiles,
1114
+ profile_overrides=self.ranking.profile_overrides,
1115
+ )
1116
+ else None
1117
+ ),
1118
+ )
1119
+
1120
+
1121
+ @dataclass(frozen=True)
1122
+ class BoundProtocol:
1123
+ descriptor: ProtocolDescriptor
1124
+ inputs: dict[str, Any] = field(default_factory=dict)
1125
+ analysis: dict[str, Any] = field(default_factory=dict)
1126
+ outputs: dict[str, Any] = field(default_factory=dict)
1127
+
1128
+ def __post_init__(self) -> None:
1129
+ object.__setattr__(self, "inputs", dict(self.inputs or {}))
1130
+ object.__setattr__(self, "analysis", dict(self.analysis or {}))
1131
+ object.__setattr__(self, "outputs", dict(self.outputs or {}))
1132
+ self.descriptor.validate_authoring(inputs=self.inputs, analysis=self.analysis)
1133
+
1134
+ @property
1135
+ def id(self) -> str:
1136
+ return self.descriptor.protocol
1137
+
1138
+ @property
1139
+ def domain(self) -> PluginDomain:
1140
+ return self.descriptor.domain
1141
+
1142
+ @property
1143
+ def family(self) -> str:
1144
+ return self.descriptor.family
1145
+
1146
+ @property
1147
+ def summary(self) -> str:
1148
+ return self.descriptor.summary
1149
+
1150
+ @property
1151
+ def execution(self) -> ProtocolExecutionPlan:
1152
+ return self.descriptor.execution
1153
+
1154
+ @property
1155
+ def default_plot_profile(self) -> str | None:
1156
+ return self.descriptor.default_plot_profile
1157
+
1158
+ def effective_inputs(self) -> dict[str, Any]:
1159
+ return self._effective_authoring_surface(section="inputs")
1160
+
1161
+ def effective_analysis(self) -> dict[str, Any]:
1162
+ return self._effective_authoring_surface(section="analysis")
1163
+
1164
+ def _effective_authoring_surface(self, *, section: Literal["inputs", "analysis"]) -> dict[str, Any]:
1165
+ fields = self.descriptor.input_fields if section == "inputs" else self.descriptor.analysis_fields
1166
+ authored = self.inputs if section == "inputs" else self.analysis
1167
+ defaults = {field.key: deepcopy(field.default) for field in fields if field.has_default}
1168
+ return _deep_merge(defaults, authored)
1169
+
1170
+ def semantic_program(
1171
+ self,
1172
+ *,
1173
+ active_profile: str | None = None,
1174
+ execution_overrides: dict[str, ProtocolSemanticExecution] | None = None,
1175
+ ) -> ProtocolSemanticProgram:
1176
+ program = self.descriptor.semantic_program(active_profile=active_profile)
1177
+ return program.with_execution_overrides(execution_overrides, protocol_id=self.id)
1178
+
1179
+ def compile(self) -> CompiledProtocolPlan:
1180
+ compiler = self.execution.compiler
1181
+ if compiler is None:
1182
+ raise ConfigError(f"Protocol {self.id!r} does not define an executable compiler.")
1183
+ plan = compiler(self)
1184
+ if not isinstance(plan, CompiledProtocolPlan):
1185
+ raise ConfigError(
1186
+ f"Protocol {self.id!r} compiler returned {type(plan).__name__}, expected CompiledProtocolPlan."
1187
+ )
1188
+ if plan.semantic_program.protocol != self.id:
1189
+ raise ConfigError(
1190
+ f"Protocol {self.id!r} compiler returned semantic program for {plan.semantic_program.protocol!r}."
1191
+ )
1192
+ return CompiledProtocolPlan(
1193
+ semantic_program=plan.semantic_program,
1194
+ runtime=plan.runtime,
1195
+ pipeline=plan.pipeline,
1196
+ plots=plan.plots,
1197
+ exports=plan.exports,
1198
+ )
1199
+
1200
+ def effective_plugin_config(self, *, plugin_id: str, step_with: dict[str, Any] | None = None) -> dict[str, Any]:
1201
+ defaults = self._protocol_plugin_defaults(plugin_id)
1202
+ return _deep_merge(defaults, dict(step_with or {}))
1203
+
1204
+ def select_plot_outputs(
1205
+ self,
1206
+ *,
1207
+ default_profile: str | None = None,
1208
+ allowed: set[str],
1209
+ ) -> tuple[str, ...]:
1210
+ block = self._output_block("plots")
1211
+ profile = block.get("profile", default_profile or self.descriptor.default_plot_profile or "none")
1212
+ profile_ids = self._plot_profile_members(profile=profile, allowed=allowed)
1213
+ include = self._validate_deliverable_ids(
1214
+ block.get("include", ()),
1215
+ where="protocol.outputs.plots.include",
1216
+ allowed=allowed,
1217
+ )
1218
+ exclude = set(
1219
+ self._validate_deliverable_ids(
1220
+ block.get("exclude", ()),
1221
+ where="protocol.outputs.plots.exclude",
1222
+ allowed=allowed,
1223
+ )
1224
+ )
1225
+ for figure_id in self._validate_named_output_configs(section="plots", key="views", allowed=allowed):
1226
+ if figure_id not in allowed:
1227
+ options = ", ".join(sorted(allowed)) or "—"
1228
+ raise ConfigError(
1229
+ f"protocol.outputs.plots.views.{figure_id!r} is unknown for {self.id!r}. Available ids: {options}"
1230
+ )
1231
+ selected: list[str] = []
1232
+ for figure_id in (*profile_ids, *include):
1233
+ if figure_id in exclude:
1234
+ continue
1235
+ if figure_id not in selected:
1236
+ selected.append(figure_id)
1237
+ return tuple(selected)
1238
+
1239
+ def plot_view_config(self, *, figure_id: str) -> dict[str, Any]:
1240
+ block = self._output_block("plots")
1241
+ views = block.get("views", {})
1242
+ if not isinstance(views, dict):
1243
+ raise ConfigError(f"protocol.outputs.plots.views for {self.id!r} must be a mapping")
1244
+ configured = views.get(figure_id, {})
1245
+ if configured is None:
1246
+ return {}
1247
+ if not isinstance(configured, dict):
1248
+ raise ConfigError(f"protocol.outputs.plots.views.{figure_id!r} for {self.id!r} must be a mapping")
1249
+ return deepcopy(configured)
1250
+
1251
+ def select_export_outputs(
1252
+ self,
1253
+ *,
1254
+ defaults: tuple[str, ...],
1255
+ allowed: set[str],
1256
+ ) -> tuple[str, ...]:
1257
+ block = self._output_block("exports")
1258
+ include = self._validate_deliverable_ids(
1259
+ block.get("include", ()),
1260
+ where="protocol.outputs.exports.include",
1261
+ allowed=allowed,
1262
+ )
1263
+ exclude = set(
1264
+ self._validate_deliverable_ids(
1265
+ block.get("exclude", ()),
1266
+ where="protocol.outputs.exports.exclude",
1267
+ allowed=allowed,
1268
+ )
1269
+ )
1270
+ for artifact_id in self._validate_named_output_configs(section="exports", key="artifacts", allowed=allowed):
1271
+ if artifact_id not in allowed:
1272
+ options = ", ".join(sorted(allowed)) or "—"
1273
+ raise ConfigError(
1274
+ f"protocol.outputs.exports.artifacts.{artifact_id!r} is unknown for {self.id!r}. "
1275
+ f"Available ids: {options}"
1276
+ )
1277
+ selected: list[str] = []
1278
+ for artifact_id in (*defaults, *include):
1279
+ if artifact_id in exclude:
1280
+ continue
1281
+ if artifact_id not in selected:
1282
+ selected.append(artifact_id)
1283
+ return tuple(selected)
1284
+
1285
+ def export_artifact_config(self, *, artifact_id: str) -> dict[str, Any]:
1286
+ block = self._output_block("exports")
1287
+ artifacts = block.get("artifacts", {})
1288
+ if not isinstance(artifacts, dict):
1289
+ raise ConfigError(f"protocol.outputs.exports.artifacts for {self.id!r} must be a mapping")
1290
+ configured = artifacts.get(artifact_id, {})
1291
+ if configured is None:
1292
+ return {}
1293
+ if not isinstance(configured, dict):
1294
+ raise ConfigError(f"protocol.outputs.exports.artifacts.{artifact_id!r} for {self.id!r} must be a mapping")
1295
+ return deepcopy(configured)
1296
+
1297
+ def _protocol_plugin_defaults(self, plugin_id: str) -> dict[str, Any]:
1298
+ for item in self.execution.plugin_defaults:
1299
+ if item.plugin == plugin_id:
1300
+ return self._resolve_binding_refs(item.with_, where=f"protocol {self.id} plugin {plugin_id}")
1301
+ return {}
1302
+
1303
+ def _output_block(self, section: str) -> dict[str, Any]:
1304
+ raw = self.outputs.get(section, {})
1305
+ if raw is None:
1306
+ return {}
1307
+ if not isinstance(raw, dict):
1308
+ raise ConfigError(f"protocol.outputs.{section} for {self.id!r} must be a mapping")
1309
+ return dict(raw)
1310
+
1311
+ def _plot_profile_members(self, *, profile: Any, allowed: set[str]) -> tuple[str, ...]:
1312
+ if not isinstance(profile, str) or not profile.strip():
1313
+ raise ConfigError(f"protocol.outputs.plots.profile for {self.id!r} must be a non-empty string")
1314
+ profile_id = profile.strip()
1315
+ if profile_id == "none":
1316
+ return ()
1317
+ for item in self.descriptor.plot_profiles:
1318
+ if item.id == profile_id:
1319
+ members = tuple(figure_id for figure_id in item.figures if figure_id in allowed)
1320
+ if len(members) != len(item.figures):
1321
+ unknown = sorted(set(item.figures) - allowed)
1322
+ raise ConfigError(
1323
+ f"protocol.outputs.plots.profile {profile_id!r} includes unsupported figures "
1324
+ f"for {self.id!r}: {', '.join(unknown)}"
1325
+ )
1326
+ return members
1327
+ options = ", ".join(["none", *[item.id for item in self.descriptor.plot_profiles]]) or "—"
1328
+ raise ConfigError(f"protocol.outputs.plots.profile must be one of: {options}")
1329
+
1330
+ def _validate_deliverable_ids(self, raw: Any, *, where: str, allowed: set[str]) -> tuple[str, ...]:
1331
+ if raw in (None, ()):
1332
+ return ()
1333
+ if not isinstance(raw, list):
1334
+ raise ConfigError(f"{where} for {self.id!r} must be a list of deliverable ids")
1335
+ values: list[str] = []
1336
+ for item in raw:
1337
+ if not isinstance(item, str) or not item.strip():
1338
+ raise ConfigError(f"{where} for {self.id!r} must contain only non-empty deliverable ids")
1339
+ deliverable_id = item.strip()
1340
+ if deliverable_id not in allowed:
1341
+ options = ", ".join(sorted(allowed)) or "—"
1342
+ raise ConfigError(f"{where} contains unknown deliverable {deliverable_id!r}. Available ids: {options}")
1343
+ values.append(deliverable_id)
1344
+ return tuple(values)
1345
+
1346
+ def _validate_named_output_configs(self, *, section: str, key: str, allowed: set[str]) -> tuple[str, ...]:
1347
+ block = self._output_block(section)
1348
+ settings = block.get(key, {})
1349
+ if settings in (None, {}):
1350
+ return ()
1351
+ if not isinstance(settings, dict):
1352
+ raise ConfigError(f"protocol.outputs.{section}.{key} for {self.id!r} must be a mapping")
1353
+ ids: list[str] = []
1354
+ for deliverable_id, config in settings.items():
1355
+ if not isinstance(deliverable_id, str) or not deliverable_id.strip():
1356
+ raise ConfigError(f"protocol.outputs.{section}.{key} for {self.id!r} must use non-empty ids")
1357
+ if not isinstance(config, dict):
1358
+ raise ConfigError(
1359
+ f"protocol.outputs.{section}.{key}.{deliverable_id!r} for {self.id!r} must be a mapping"
1360
+ )
1361
+ if deliverable_id.strip() not in allowed:
1362
+ options = ", ".join(sorted(allowed)) or "—"
1363
+ raise ConfigError(
1364
+ f"protocol.outputs.{section}.{key}.{deliverable_id!r} is unknown for {self.id!r}. "
1365
+ f"Available ids: {options}"
1366
+ )
1367
+ ids.append(deliverable_id.strip())
1368
+ return tuple(ids)
1369
+
1370
+ def _resolve_binding_refs(self, value: Any, *, where: str) -> Any:
1371
+ if isinstance(value, ProtocolBindingValueRef):
1372
+ found, resolved = self._lookup_parameter_value(value.key)
1373
+ if found:
1374
+ return deepcopy(resolved)
1375
+ if value.has_default:
1376
+ return self._resolve_binding_refs(value.default, where=where)
1377
+ raise ConfigError(f"{where} requires protocol.inputs.{value.key}")
1378
+ if isinstance(value, ProtocolAnalysisChoiceRef):
1379
+ found, resolved = self._lookup_analysis_value(value.key)
1380
+ if found:
1381
+ selected = value.cases.get(str(resolved))
1382
+ if selected is not None:
1383
+ return deepcopy(selected)
1384
+ if value.has_default:
1385
+ return deepcopy(value.default)
1386
+ raise ConfigError(f"{where} requires protocol.analysis.{value.key}")
1387
+ if isinstance(value, dict):
1388
+ return {key: self._resolve_binding_refs(item, where=where) for key, item in value.items()}
1389
+ if isinstance(value, list):
1390
+ return [self._resolve_binding_refs(item, where=where) for item in value]
1391
+ if isinstance(value, tuple):
1392
+ return tuple(self._resolve_binding_refs(item, where=where) for item in value)
1393
+ return deepcopy(value)
1394
+
1395
+ def _lookup_parameter_value(self, key: str) -> tuple[bool, Any]:
1396
+ current: Any = self.inputs
1397
+ for part in key.split("."):
1398
+ if not isinstance(current, dict) or part not in current:
1399
+ return False, None
1400
+ current = current[part]
1401
+ return True, current
1402
+
1403
+ def _lookup_analysis_value(self, key: str) -> tuple[bool, Any]:
1404
+ current: Any = self.analysis
1405
+ for part in key.split("."):
1406
+ if not isinstance(current, dict) or part not in current:
1407
+ return False, None
1408
+ current = current[part]
1409
+ return True, current
1410
+
1411
+ def authoring_rows(self, *, section: Literal["inputs", "analysis"]) -> tuple[tuple[str, str, str, str, str], ...]:
1412
+ fields = self.descriptor.input_fields if section == "inputs" else self.descriptor.analysis_fields
1413
+ rows: list[tuple[str, str, str, str, str]] = []
1414
+ for field_spec in fields:
1415
+ rows.extend(field_spec.iter_rows())
1416
+ return tuple(rows)
1417
+
1418
+
1419
+ def _deep_merge(*mappings: dict[str, Any]) -> dict[str, Any]:
1420
+ merged: dict[str, Any] = {}
1421
+ for mapping in mappings:
1422
+ for key, value in mapping.items():
1423
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
1424
+ merged[key] = _deep_merge(merged[key], value)
1425
+ continue
1426
+ merged[key] = deepcopy(value)
1427
+ return merged
1428
+
1429
+
1430
+ def _validate_protocol_surface(
1431
+ raw: dict[str, Any],
1432
+ *,
1433
+ fields: tuple[ProtocolConfigFieldSpec, ...],
1434
+ path: str,
1435
+ protocol_id: str,
1436
+ ) -> None:
1437
+ if not isinstance(raw, dict):
1438
+ raise ConfigError(f"{path} for {protocol_id!r} must be a mapping")
1439
+ allowed = {field.key: field for field in fields}
1440
+ unknown = sorted(key for key in raw if key not in allowed)
1441
+ if unknown:
1442
+ options = ", ".join(sorted(allowed)) or "—"
1443
+ raise ConfigError(f"{path} for {protocol_id!r} has unknown keys {unknown}. Allowed keys: {options}")
1444
+ for field_spec in fields:
1445
+ field_path = f"{path}.{field_spec.key}"
1446
+ if field_spec.key not in raw:
1447
+ if field_spec.required and not field_spec.has_default:
1448
+ raise ConfigError(f"{field_path} for {protocol_id!r} is required")
1449
+ continue
1450
+ field_spec.validate(raw[field_spec.key], path=field_path)
1451
+
1452
+
1453
+ class ProtocolCatalog:
1454
+ def __init__(self, descriptors: list[ProtocolDescriptor]):
1455
+ self._descriptors = tuple(sorted(descriptors, key=lambda item: (item.domain, item.family, item.protocol)))
1456
+ by_id: dict[str, ProtocolDescriptor] = {}
1457
+ for item in self._descriptors:
1458
+ if item.protocol in by_id:
1459
+ raise ConfigError(f"Duplicate protocol {item.protocol!r}.")
1460
+ by_id[item.protocol] = item
1461
+ self._by_id = by_id
1462
+
1463
+ def all(self) -> tuple[ProtocolDescriptor, ...]:
1464
+ return self._descriptors
1465
+
1466
+ def resolve(self, protocol_id: str) -> ProtocolDescriptor:
1467
+ try:
1468
+ return self._by_id[protocol_id]
1469
+ except KeyError:
1470
+ options = ", ".join(sorted(item.protocol for item in self._descriptors)) or "—"
1471
+ raise ConfigError(f"Unknown protocol {protocol_id!r}. Available protocols: {options}") from None
1472
+
1473
+ def bind(self, binding: ProtocolBinding) -> BoundProtocol:
1474
+ return BoundProtocol(
1475
+ descriptor=self.resolve(binding.id),
1476
+ inputs=binding.inputs,
1477
+ analysis=binding.analysis,
1478
+ outputs=binding.outputs,
1479
+ )
1480
+
1481
+ def list(self, *, domain: str | None = None, family: str | None = None) -> list[tuple[str, str]]:
1482
+ return [
1483
+ (item.protocol, item.summary)
1484
+ for item in self._descriptors
1485
+ if (domain is None or item.domain == domain) and (family is None or item.family == family)
1486
+ ]