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