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.
- reader_workbench/__init__.py +22 -0
- reader_workbench/__main__.py +4 -0
- reader_workbench/_version.py +17 -0
- reader_workbench/api/__init__.py +74 -0
- reader_workbench/api/_record_reads.py +75 -0
- reader_workbench/api/artifacts.py +79 -0
- reader_workbench/api/facade.py +538 -0
- reader_workbench/api/models.py +285 -0
- reader_workbench/api/notebooks.py +63 -0
- reader_workbench/contracts/__init__.py +18 -0
- reader_workbench/contracts/builtins/__init__.py +36 -0
- reader_workbench/contracts/builtins/cytometry.py +140 -0
- reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
- reader_workbench/contracts/builtins/generic.py +21 -0
- reader_workbench/contracts/builtins/logic.py +149 -0
- reader_workbench/contracts/builtins/plate_reader.py +47 -0
- reader_workbench/contracts/catalog.py +257 -0
- reader_workbench/contracts/model.py +109 -0
- reader_workbench/domains/__init__.py +1 -0
- reader_workbench/domains/cytometry/__init__.py +3 -0
- reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
- reader_workbench/domains/cytometry/analysis/events.py +182 -0
- reader_workbench/domains/cytometry/analysis/gating.py +175 -0
- reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
- reader_workbench/domains/cytometry/io/__init__.py +3 -0
- reader_workbench/domains/cytometry/io/fcs.py +135 -0
- reader_workbench/domains/cytometry/plots/__init__.py +5 -0
- reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
- reader_workbench/domains/logic/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
- reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
- reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
- reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
- reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
- reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
- reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
- reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
- reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
- reader_workbench/domains/logic/four_state_vector/config.py +214 -0
- reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
- reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
- reader_workbench/domains/logic/four_state_vector/math.py +191 -0
- reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
- reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
- reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
- reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
- reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
- reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
- reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
- reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
- reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
- reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
- reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
- reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
- reader_workbench/domains/logic/treatment_columns.py +42 -0
- reader_workbench/domains/plate_reader/__init__.py +1 -0
- reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
- reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
- reader_workbench/domains/plate_reader/io/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
- reader_workbench/domains/plate_reader/ordering.py +59 -0
- reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
- reader_workbench/domains/plate_reader/plots/_data.py +29 -0
- reader_workbench/domains/plate_reader/plots/common.py +346 -0
- reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
- reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
- reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
- reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
- reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
- reader_workbench/domains/time_series/__init__.py +29 -0
- reader_workbench/domains/time_series/aggregation.py +60 -0
- reader_workbench/domains/time_series/contracts.py +368 -0
- reader_workbench/domains/time_series/reduction.py +395 -0
- reader_workbench/errors.py +55 -0
- reader_workbench/maintenance/__init__.py +6 -0
- reader_workbench/maintenance/docs.py +335 -0
- reader_workbench/maintenance/model.py +28 -0
- reader_workbench/maintenance/release.py +39 -0
- reader_workbench/maintenance/skills.py +124 -0
- reader_workbench/plotting/__init__.py +20 -0
- reader_workbench/plotting/mpl.py +56 -0
- reader_workbench/plotting/sinks.py +69 -0
- reader_workbench/plotting/style.py +175 -0
- reader_workbench/plotting/utils.py +27 -0
- reader_workbench/plugins/__init__.py +1 -0
- reader_workbench/plugins/catalog.py +33 -0
- reader_workbench/plugins/export/__init__.py +0 -0
- reader_workbench/plugins/export/_paths.py +21 -0
- reader_workbench/plugins/export/csv.py +41 -0
- reader_workbench/plugins/export/xlsx.py +44 -0
- reader_workbench/plugins/ingest/__init__.py +0 -0
- reader_workbench/plugins/ingest/_discovery.py +58 -0
- reader_workbench/plugins/ingest/discovery_policy.py +66 -0
- reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
- reader_workbench/plugins/ingest/synergy_h1.py +234 -0
- reader_workbench/plugins/manifests/__init__.py +1 -0
- reader_workbench/plugins/manifests/export.py +29 -0
- reader_workbench/plugins/manifests/ingest.py +29 -0
- reader_workbench/plugins/manifests/plot.py +161 -0
- reader_workbench/plugins/manifests/transform.py +172 -0
- reader_workbench/plugins/manifests/validator.py +18 -0
- reader_workbench/plugins/plot/__init__.py +0 -0
- reader_workbench/plugins/plot/_shared.py +55 -0
- reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
- reader_workbench/plugins/plot/distributions.py +58 -0
- reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
- reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
- reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
- reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
- reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
- reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
- reader_workbench/plugins/plot/logic_symmetry.py +56 -0
- reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
- reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
- reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
- reader_workbench/plugins/plot/time_series.py +114 -0
- reader_workbench/plugins/plot/ts_and_snap.py +210 -0
- reader_workbench/plugins/transform/__init__.py +0 -0
- reader_workbench/plugins/transform/_four_state_vector.py +204 -0
- reader_workbench/plugins/transform/_labeling.py +109 -0
- reader_workbench/plugins/transform/alias.py +70 -0
- reader_workbench/plugins/transform/assay_labels.py +62 -0
- reader_workbench/plugins/transform/blank.py +79 -0
- reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
- reader_workbench/plugins/transform/cytometry_gating.py +120 -0
- reader_workbench/plugins/transform/fold_change.py +79 -0
- reader_workbench/plugins/transform/four_state_event_window.py +93 -0
- reader_workbench/plugins/transform/four_state_vector.py +62 -0
- reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
- reader_workbench/plugins/transform/logic_symmetry.py +67 -0
- reader_workbench/plugins/transform/outlier_filter.py +60 -0
- reader_workbench/plugins/transform/overflow.py +197 -0
- reader_workbench/plugins/transform/ratio.py +237 -0
- reader_workbench/plugins/transform/sample_map.py +170 -0
- reader_workbench/plugins/transform/sample_metadata.py +94 -0
- reader_workbench/plugins/validator/__init__.py +1 -0
- reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
- reader_workbench/protocols/__init__.py +80 -0
- reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
- reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
- reader_workbench/protocols/builtins.py +1656 -0
- reader_workbench/protocols/compiler.py +22 -0
- reader_workbench/protocols/compilers/__init__.py +1 -0
- reader_workbench/protocols/compilers/common.py +100 -0
- reader_workbench/protocols/compilers/cytometry.py +87 -0
- reader_workbench/protocols/compilers/generic.py +14 -0
- reader_workbench/protocols/compilers/logic.py +245 -0
- reader_workbench/protocols/compilers/plate_reader.py +937 -0
- reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
- reader_workbench/protocols/model.py +1486 -0
- reader_workbench/protocols/semantic_coverage.py +234 -0
- reader_workbench/runtime/__init__.py +12 -0
- reader_workbench/runtime/builtin.py +23 -0
- reader_workbench/runtime/model.py +42 -0
- reader_workbench/workbench/__init__.py +60 -0
- reader_workbench/workbench/assets/__init__.py +22 -0
- reader_workbench/workbench/assets/types.py +118 -0
- reader_workbench/workbench/audit/__init__.py +5 -0
- reader_workbench/workbench/audit/experiments.py +307 -0
- reader_workbench/workbench/audit/staging.py +187 -0
- reader_workbench/workbench/cli/__init__.py +51 -0
- reader_workbench/workbench/cli/_lazy.py +9 -0
- reader_workbench/workbench/cli/_records_view.py +150 -0
- reader_workbench/workbench/cli/_surface_execution.py +443 -0
- reader_workbench/workbench/cli/audit.py +95 -0
- reader_workbench/workbench/cli/automation.py +229 -0
- reader_workbench/workbench/cli/demo.py +46 -0
- reader_workbench/workbench/cli/dop.py +91 -0
- reader_workbench/workbench/cli/experiments.py +635 -0
- reader_workbench/workbench/cli/helpers.py +232 -0
- reader_workbench/workbench/cli/main.py +59 -0
- reader_workbench/workbench/cli/maintenance.py +82 -0
- reader_workbench/workbench/cli/notebooks.py +260 -0
- reader_workbench/workbench/cli/pagination.py +117 -0
- reader_workbench/workbench/cli/protocols.py +336 -0
- reader_workbench/workbench/cli/shared.py +309 -0
- reader_workbench/workbench/cli/surfaces.py +534 -0
- reader_workbench/workbench/cli/verification.py +128 -0
- reader_workbench/workbench/commands.py +10 -0
- reader_workbench/workbench/config/__init__.py +47 -0
- reader_workbench/workbench/config/identity.py +13 -0
- reader_workbench/workbench/config/load.py +405 -0
- reader_workbench/workbench/config/model.py +274 -0
- reader_workbench/workbench/context.py +26 -0
- reader_workbench/workbench/decl/__init__.py +31 -0
- reader_workbench/workbench/decl/build.py +190 -0
- reader_workbench/workbench/decl/model.py +81 -0
- reader_workbench/workbench/dop/__init__.py +12 -0
- reader_workbench/workbench/dop/builtins.py +261 -0
- reader_workbench/workbench/dop/model.py +209 -0
- reader_workbench/workbench/engine/__init__.py +42 -0
- reader_workbench/workbench/engine/_shared.py +76 -0
- reader_workbench/workbench/engine/contracts.py +283 -0
- reader_workbench/workbench/engine/execution.py +326 -0
- reader_workbench/workbench/engine/file_outputs.py +260 -0
- reader_workbench/workbench/engine/inputs.py +161 -0
- reader_workbench/workbench/engine/invocations.py +507 -0
- reader_workbench/workbench/engine/planning.py +72 -0
- reader_workbench/workbench/engine/runtime.py +464 -0
- reader_workbench/workbench/engine/setup.py +149 -0
- reader_workbench/workbench/engine/validation.py +684 -0
- reader_workbench/workbench/experiment/__init__.py +47 -0
- reader_workbench/workbench/experiment/model.py +381 -0
- reader_workbench/workbench/experiments.py +133 -0
- reader_workbench/workbench/graph/__init__.py +47 -0
- reader_workbench/workbench/graph/nodes.py +102 -0
- reader_workbench/workbench/graph/normalize.py +177 -0
- reader_workbench/workbench/graph/refs.py +148 -0
- reader_workbench/workbench/input_discovery.py +19 -0
- reader_workbench/workbench/inspection/__init__.py +3 -0
- reader_workbench/workbench/inspection/catalogs.py +128 -0
- reader_workbench/workbench/inspection/common.py +92 -0
- reader_workbench/workbench/inspection/dop.py +64 -0
- reader_workbench/workbench/inspection/experiments.py +449 -0
- reader_workbench/workbench/inspection/inventory.py +68 -0
- reader_workbench/workbench/inspection/protocols.py +368 -0
- reader_workbench/workbench/inspection/readiness.py +333 -0
- reader_workbench/workbench/inspection/reports.py +367 -0
- reader_workbench/workbench/inspection/results.py +166 -0
- reader_workbench/workbench/inspection/runtime.py +287 -0
- reader_workbench/workbench/inspection/semantics.py +192 -0
- reader_workbench/workbench/inspection/validation.py +30 -0
- reader_workbench/workbench/notebooks/__init__.py +17 -0
- reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
- reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
- reader_workbench/workbench/notebooks/components/__init__.py +21 -0
- reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
- reader_workbench/workbench/notebooks/components/overview.py +119 -0
- reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
- reader_workbench/workbench/notebooks/launch.py +274 -0
- reader_workbench/workbench/notebooks/presentation.py +136 -0
- reader_workbench/workbench/notebooks/scaffold.py +60 -0
- reader_workbench/workbench/ontology.py +78 -0
- reader_workbench/workbench/paths.py +44 -0
- reader_workbench/workbench/ports/__init__.py +31 -0
- reader_workbench/workbench/ports/model.py +168 -0
- reader_workbench/workbench/records/__init__.py +44 -0
- reader_workbench/workbench/records/epoch.py +329 -0
- reader_workbench/workbench/records/evidence.py +247 -0
- reader_workbench/workbench/records/identity.py +87 -0
- reader_workbench/workbench/records/locking.py +185 -0
- reader_workbench/workbench/records/model.py +711 -0
- reader_workbench/workbench/records/sources.py +73 -0
- reader_workbench/workbench/records/store.py +1022 -0
- reader_workbench/workbench/records/verification.py +998 -0
- reader_workbench/workbench/registry.py +333 -0
- reader_workbench/workbench/spec_overrides.py +215 -0
- reader_workbench-1.0.0.dist-info/METADATA +91 -0
- reader_workbench-1.0.0.dist-info/RECORD +293 -0
- reader_workbench-1.0.0.dist-info/WHEEL +5 -0
- reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
- reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|