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,998 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from uuid import UUID
|
|
12
|
+
|
|
13
|
+
from filelock import Timeout
|
|
14
|
+
|
|
15
|
+
from reader_workbench.errors import ProvenanceEpochChangedError, RecordError
|
|
16
|
+
from reader_workbench.workbench.graph import RecordRef, SourceRecordRef
|
|
17
|
+
from reader_workbench.workbench.records.model import (
|
|
18
|
+
DataFrameArtifactRecord,
|
|
19
|
+
FileBundleRecord,
|
|
20
|
+
record_revision_digest,
|
|
21
|
+
sha256_file,
|
|
22
|
+
verify_record_artifact_integrity,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .identity import BuildIdentity, current_build_identity, digest_json
|
|
26
|
+
from .locking import provenance_lock_scope
|
|
27
|
+
|
|
28
|
+
_UNVERIFIABLE_CODES = frozenset({"build.identity_mismatch", "config.digest_mismatch"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class RecordVerificationScope:
|
|
33
|
+
"""Current record identities owned by one compiled workbench declaration."""
|
|
34
|
+
|
|
35
|
+
record_ids: frozenset[str] = field(default_factory=frozenset)
|
|
36
|
+
|
|
37
|
+
def includes(self, record: DataFrameArtifactRecord | FileBundleRecord) -> bool:
|
|
38
|
+
return record.record_id in self.record_ids
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _issue(*, code: str, field: str, reason: str, remediation: str, retryable: bool = False) -> dict[str, object]:
|
|
42
|
+
return {
|
|
43
|
+
"code": code,
|
|
44
|
+
"field": field,
|
|
45
|
+
"reason": reason,
|
|
46
|
+
"remediation": remediation,
|
|
47
|
+
"retryable": retryable,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _file_io_issue(*, path: Path, field: str, code_prefix: str, error: OSError) -> dict[str, object]:
|
|
52
|
+
return _issue(
|
|
53
|
+
code=f"{code_prefix}.io_error",
|
|
54
|
+
field=field,
|
|
55
|
+
reason=f"Recorded artifact could not be read: {path}: {error}",
|
|
56
|
+
remediation="Restore a readable artifact or regenerate it from source inputs.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _verify_file(
|
|
61
|
+
path: Path,
|
|
62
|
+
*,
|
|
63
|
+
expected_size: int,
|
|
64
|
+
expected_digest: str,
|
|
65
|
+
field: str,
|
|
66
|
+
code_prefix: str = "artifact",
|
|
67
|
+
) -> list[dict[str, object]]:
|
|
68
|
+
try:
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return [
|
|
71
|
+
_issue(
|
|
72
|
+
code=f"{code_prefix}.missing",
|
|
73
|
+
field=field,
|
|
74
|
+
reason=f"Recorded artifact is missing: {path}",
|
|
75
|
+
remediation="Regenerate the record from its source inputs.",
|
|
76
|
+
)
|
|
77
|
+
]
|
|
78
|
+
if not path.is_file():
|
|
79
|
+
return [
|
|
80
|
+
_issue(
|
|
81
|
+
code=f"{code_prefix}.not_file",
|
|
82
|
+
field=field,
|
|
83
|
+
reason=f"Recorded artifact is not a regular file: {path}",
|
|
84
|
+
remediation="Remove the conflicting path and regenerate the record.",
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
actual_size = path.stat().st_size
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
return [_file_io_issue(path=path, field=field, code_prefix=code_prefix, error=exc)]
|
|
90
|
+
if actual_size != expected_size:
|
|
91
|
+
return [
|
|
92
|
+
_issue(
|
|
93
|
+
code=f"{code_prefix}.size_mismatch",
|
|
94
|
+
field=field,
|
|
95
|
+
reason=f"Recorded size {expected_size} does not match current size {actual_size}: {path}",
|
|
96
|
+
remediation="Restore the recorded artifact or regenerate it from source inputs.",
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
try:
|
|
100
|
+
actual_digest = sha256_file(path)
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
return [_file_io_issue(path=path, field=field, code_prefix=code_prefix, error=exc)]
|
|
103
|
+
if actual_digest != expected_digest:
|
|
104
|
+
return [
|
|
105
|
+
_issue(
|
|
106
|
+
code=f"{code_prefix}.digest_mismatch",
|
|
107
|
+
field=field,
|
|
108
|
+
reason=f"Recorded digest {expected_digest} does not match {actual_digest}: {path}",
|
|
109
|
+
remediation="Restore the recorded artifact or regenerate it from source inputs.",
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _confine_input(path: Path, *, experiment_root: Path, field: str) -> tuple[Path | None, list[dict[str, object]]]:
|
|
116
|
+
try:
|
|
117
|
+
resolved = path.resolve(strict=False)
|
|
118
|
+
resolved.relative_to(experiment_root.resolve(strict=False))
|
|
119
|
+
except (OSError, RuntimeError, ValueError):
|
|
120
|
+
return None, [
|
|
121
|
+
_issue(
|
|
122
|
+
code="input.outside_root",
|
|
123
|
+
field=field,
|
|
124
|
+
reason=f"Recorded input resolves outside the experiment root: {path}",
|
|
125
|
+
remediation="Restore the confined source path and rerun the producing surface.",
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
return resolved, []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_INVOCATION_EVENT_FIELDS = frozenset(
|
|
132
|
+
{
|
|
133
|
+
"schema",
|
|
134
|
+
"invocation_id",
|
|
135
|
+
"provenance_epoch_id",
|
|
136
|
+
"timestamp",
|
|
137
|
+
"config_digest",
|
|
138
|
+
"build_identity",
|
|
139
|
+
"operation",
|
|
140
|
+
"selected_step_ids",
|
|
141
|
+
"declared_inputs",
|
|
142
|
+
"event",
|
|
143
|
+
"status",
|
|
144
|
+
"exit_status",
|
|
145
|
+
"produced_record_revisions",
|
|
146
|
+
"failure",
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
_INVOCATION_IDENTITY_FIELDS = (
|
|
150
|
+
"provenance_epoch_id",
|
|
151
|
+
"config_digest",
|
|
152
|
+
"build_identity",
|
|
153
|
+
"operation",
|
|
154
|
+
"selected_step_ids",
|
|
155
|
+
"declared_inputs",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _valid_invocation_id(value: object) -> bool:
|
|
160
|
+
if not isinstance(value, str):
|
|
161
|
+
return False
|
|
162
|
+
try:
|
|
163
|
+
parsed = UUID(value)
|
|
164
|
+
except ValueError:
|
|
165
|
+
return False
|
|
166
|
+
return parsed.version == 4 and str(parsed) == value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _valid_invocation_timestamp(value: object) -> bool:
|
|
170
|
+
if not isinstance(value, str):
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
parsed = datetime.fromisoformat(value)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return False
|
|
176
|
+
return parsed.tzinfo is not None and parsed.utcoffset() == UTC.utcoffset(parsed)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _valid_invocation_digest(value: object) -> bool:
|
|
180
|
+
"""Match the normalization contract used by InvocationLedger."""
|
|
181
|
+
|
|
182
|
+
return isinstance(value, str) and value.startswith("sha256:")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _valid_selected_steps(value: object) -> bool:
|
|
186
|
+
if not isinstance(value, dict) or set(value) != {"pipeline", "plots", "exports"}:
|
|
187
|
+
return False
|
|
188
|
+
return all(
|
|
189
|
+
isinstance(value[phase], list) and all(isinstance(step_id, str) and bool(step_id) for step_id in value[phase])
|
|
190
|
+
for phase in ("pipeline", "plots", "exports")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _valid_declared_inputs(value: object) -> bool:
|
|
195
|
+
if not isinstance(value, list) or len(value) > 4096:
|
|
196
|
+
return False
|
|
197
|
+
allowed_refs = ({"record"}, {"file"}, {"resource", "path"}, {"record_collection"})
|
|
198
|
+
for item in value:
|
|
199
|
+
if not isinstance(item, dict) or set(item) != {"phase", "step_id", "port", "ref"}:
|
|
200
|
+
return False
|
|
201
|
+
if item["phase"] not in {"pipeline", "plots", "exports"}:
|
|
202
|
+
return False
|
|
203
|
+
if not isinstance(item["step_id"], str) or not item["step_id"]:
|
|
204
|
+
return False
|
|
205
|
+
if not isinstance(item["port"], str) or not item["port"]:
|
|
206
|
+
return False
|
|
207
|
+
ref = item["ref"]
|
|
208
|
+
if not isinstance(ref, dict) or set(ref) not in allowed_refs:
|
|
209
|
+
return False
|
|
210
|
+
if any(not isinstance(part, str) or not part for part in ref.values()):
|
|
211
|
+
return False
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _valid_revision(value: object) -> bool:
|
|
216
|
+
return (
|
|
217
|
+
isinstance(value, dict)
|
|
218
|
+
and set(value) == {"record_id", "revision", "revision_digest"}
|
|
219
|
+
and isinstance(value["record_id"], str)
|
|
220
|
+
and bool(value["record_id"])
|
|
221
|
+
and type(value["revision"]) is int
|
|
222
|
+
and value["revision"] >= 1
|
|
223
|
+
and _valid_invocation_digest(value["revision_digest"])
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _invocation_event_problem(event: dict[str, object], *, expected_epoch_id: str) -> str | None:
|
|
228
|
+
if set(event) != _INVOCATION_EVENT_FIELDS:
|
|
229
|
+
return "The invocation event fields do not match reader.invocation/v2."
|
|
230
|
+
if event["schema"] != "reader.invocation/v2":
|
|
231
|
+
return "The invocation event schema is not reader.invocation/v2."
|
|
232
|
+
if not _valid_invocation_id(event["invocation_id"]):
|
|
233
|
+
return "The invocation id is not a canonical UUID4."
|
|
234
|
+
if not _valid_invocation_id(event["provenance_epoch_id"]):
|
|
235
|
+
return "The invocation provenance_epoch_id is not a canonical UUID4."
|
|
236
|
+
if event["provenance_epoch_id"] != expected_epoch_id:
|
|
237
|
+
return "The invocation event belongs to a different provenance epoch."
|
|
238
|
+
if not _valid_invocation_timestamp(event["timestamp"]):
|
|
239
|
+
return "The invocation timestamp is not an ISO-8601 UTC timestamp."
|
|
240
|
+
if not _valid_invocation_digest(event["config_digest"]):
|
|
241
|
+
return "The invocation config_digest is not a sha256 digest."
|
|
242
|
+
try:
|
|
243
|
+
BuildIdentity.from_dict(event["build_identity"])
|
|
244
|
+
except RecordError:
|
|
245
|
+
return "The invocation build_identity is invalid."
|
|
246
|
+
if event["operation"] not in {"run", "plot", "export", "mixed"}:
|
|
247
|
+
return "The invocation operation is invalid."
|
|
248
|
+
if not _valid_selected_steps(event["selected_step_ids"]):
|
|
249
|
+
return "The invocation selected_step_ids are invalid."
|
|
250
|
+
if not _valid_declared_inputs(event["declared_inputs"]):
|
|
251
|
+
return "The invocation declared_inputs are invalid."
|
|
252
|
+
if event["event"] not in {"attempt", "result"}:
|
|
253
|
+
return "The invocation event kind is invalid."
|
|
254
|
+
|
|
255
|
+
revisions = event["produced_record_revisions"]
|
|
256
|
+
if not isinstance(revisions, list) or any(not _valid_revision(item) for item in revisions):
|
|
257
|
+
return "The invocation produced_record_revisions are invalid."
|
|
258
|
+
if revisions != sorted(revisions, key=lambda item: (item["record_id"], item["revision"])):
|
|
259
|
+
return "The invocation produced_record_revisions are not in canonical order."
|
|
260
|
+
if event["event"] == "attempt":
|
|
261
|
+
if (
|
|
262
|
+
event["status"] != "attempted"
|
|
263
|
+
or event["exit_status"] is not None
|
|
264
|
+
or revisions
|
|
265
|
+
or event["failure"] is not None
|
|
266
|
+
):
|
|
267
|
+
return "The invocation attempt status, exit_status, revisions, and failure are inconsistent."
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
exit_status = event["exit_status"]
|
|
271
|
+
failure = event["failure"]
|
|
272
|
+
if type(exit_status) is not int or exit_status < 0:
|
|
273
|
+
return "The invocation result exit_status is invalid."
|
|
274
|
+
if exit_status == 0:
|
|
275
|
+
if event["status"] != "succeeded" or failure is not None:
|
|
276
|
+
return "The successful invocation result has inconsistent status or failure details."
|
|
277
|
+
return None
|
|
278
|
+
if event["status"] != "failed":
|
|
279
|
+
return "The failed invocation result has an inconsistent status."
|
|
280
|
+
if (
|
|
281
|
+
not isinstance(failure, dict)
|
|
282
|
+
or set(failure) != {"type", "reason"}
|
|
283
|
+
or any(not isinstance(value, str) or not value for value in failure.values())
|
|
284
|
+
or len(failure["reason"]) > 500
|
|
285
|
+
):
|
|
286
|
+
return "The failed invocation result has invalid failure details."
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _verify_invocation_ledger(
|
|
291
|
+
path: Path,
|
|
292
|
+
*,
|
|
293
|
+
expected_epoch_id: str,
|
|
294
|
+
store=None,
|
|
295
|
+
required: bool,
|
|
296
|
+
) -> tuple[list[dict[str, object]], int]:
|
|
297
|
+
ledger_field = f"outputs/{path.relative_to(store.root).as_posix()}" if store is not None else str(path)
|
|
298
|
+
manifests_dir = store.manifests_dir if store is not None else path.parent.parent
|
|
299
|
+
if manifests_dir.is_symlink() or path.parent.is_symlink() or path.is_symlink():
|
|
300
|
+
return [
|
|
301
|
+
_issue(
|
|
302
|
+
code="invocation.ledger_unconfined",
|
|
303
|
+
field=ledger_field,
|
|
304
|
+
reason="The active invocation ledger or one of its parent directories is a symlink.",
|
|
305
|
+
remediation="Restore a regular, experiment-owned invocation ledger before handoff.",
|
|
306
|
+
)
|
|
307
|
+
], 0
|
|
308
|
+
if not path.exists():
|
|
309
|
+
if required:
|
|
310
|
+
return [
|
|
311
|
+
_issue(
|
|
312
|
+
code="invocation.ledger_missing",
|
|
313
|
+
field=ledger_field,
|
|
314
|
+
reason="The active provenance epoch has records but no invocation ledger.",
|
|
315
|
+
remediation="Restore the active ledger or rerun the complete experiment with --reset-records.",
|
|
316
|
+
)
|
|
317
|
+
], 0
|
|
318
|
+
return [], 0
|
|
319
|
+
descriptor = -1
|
|
320
|
+
try:
|
|
321
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
322
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
323
|
+
descriptor = os.open(path, flags)
|
|
324
|
+
metadata = os.fstat(descriptor)
|
|
325
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
326
|
+
return [
|
|
327
|
+
_issue(
|
|
328
|
+
code="invocation.ledger_unreadable",
|
|
329
|
+
field=ledger_field,
|
|
330
|
+
reason="The active invocation ledger is not a regular file.",
|
|
331
|
+
remediation="Restore a regular invocation ledger before handoff.",
|
|
332
|
+
)
|
|
333
|
+
], 0
|
|
334
|
+
if metadata.st_nlink != 1:
|
|
335
|
+
return [
|
|
336
|
+
_issue(
|
|
337
|
+
code="invocation.ledger_unconfined",
|
|
338
|
+
field=ledger_field,
|
|
339
|
+
reason="The active invocation ledger must have exactly one hard link.",
|
|
340
|
+
remediation="Restore an experiment-owned invocation ledger before handoff.",
|
|
341
|
+
)
|
|
342
|
+
], 0
|
|
343
|
+
with os.fdopen(descriptor, mode="r", encoding="utf-8") as stream:
|
|
344
|
+
descriptor = -1
|
|
345
|
+
lines = stream.read().splitlines()
|
|
346
|
+
except (OSError, UnicodeError) as exc:
|
|
347
|
+
return [
|
|
348
|
+
_issue(
|
|
349
|
+
code="invocation.ledger_unreadable",
|
|
350
|
+
field=ledger_field,
|
|
351
|
+
reason=f"The invocation ledger could not be read: {type(exc).__name__}.",
|
|
352
|
+
remediation="Restore a readable ledger before handing off the experiment.",
|
|
353
|
+
)
|
|
354
|
+
], 0
|
|
355
|
+
finally:
|
|
356
|
+
if descriptor >= 0:
|
|
357
|
+
os.close(descriptor)
|
|
358
|
+
|
|
359
|
+
if not lines:
|
|
360
|
+
return [
|
|
361
|
+
_issue(
|
|
362
|
+
code="invocation.ledger_empty",
|
|
363
|
+
field=ledger_field,
|
|
364
|
+
reason="The active invocation ledger contains no events.",
|
|
365
|
+
remediation="Restore the active ledger or rerun the complete experiment with --reset-records.",
|
|
366
|
+
)
|
|
367
|
+
], 0
|
|
368
|
+
|
|
369
|
+
issues: list[dict[str, object]] = []
|
|
370
|
+
events_by_invocation: dict[str, list[tuple[int, dict[str, object]]]] = {}
|
|
371
|
+
valid_event_lines: set[int] = set()
|
|
372
|
+
for line_number, line in enumerate(lines, start=1):
|
|
373
|
+
if not line.strip():
|
|
374
|
+
issues.append(
|
|
375
|
+
_issue(
|
|
376
|
+
code="invocation.ledger_invalid",
|
|
377
|
+
field=f"{ledger_field}:{line_number}",
|
|
378
|
+
reason="The invocation ledger contains a blank event line.",
|
|
379
|
+
remediation="Restore the ledger from a known-good copy before handoff.",
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
continue
|
|
383
|
+
try:
|
|
384
|
+
event = json.loads(line)
|
|
385
|
+
except json.JSONDecodeError:
|
|
386
|
+
issues.append(
|
|
387
|
+
_issue(
|
|
388
|
+
code="invocation.ledger_invalid",
|
|
389
|
+
field=f"{ledger_field}:{line_number}",
|
|
390
|
+
reason="The invocation ledger contains an invalid JSON event.",
|
|
391
|
+
remediation="Restore the ledger from a known-good copy before handoff.",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
continue
|
|
395
|
+
if not isinstance(event, dict):
|
|
396
|
+
issues.append(
|
|
397
|
+
_issue(
|
|
398
|
+
code="invocation.event_invalid",
|
|
399
|
+
field=f"{ledger_field}:{line_number}",
|
|
400
|
+
reason="The invocation event does not match reader.invocation/v2.",
|
|
401
|
+
remediation="Restore a valid invocation event before handoff.",
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
continue
|
|
405
|
+
problem = _invocation_event_problem(event, expected_epoch_id=expected_epoch_id)
|
|
406
|
+
if problem is not None:
|
|
407
|
+
issues.append(
|
|
408
|
+
_issue(
|
|
409
|
+
code="invocation.event_invalid",
|
|
410
|
+
field=f"{ledger_field}:{line_number}",
|
|
411
|
+
reason=problem,
|
|
412
|
+
remediation="Restore a valid invocation event before handoff.",
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
valid_event_lines.add(line_number)
|
|
417
|
+
invocation_id = event.get("invocation_id")
|
|
418
|
+
if not isinstance(invocation_id, str) or not invocation_id:
|
|
419
|
+
continue
|
|
420
|
+
events_by_invocation.setdefault(invocation_id, []).append((line_number, event))
|
|
421
|
+
|
|
422
|
+
for invocation_id, positioned_events in sorted(events_by_invocation.items()):
|
|
423
|
+
attempts = [(line, event) for line, event in positioned_events if event.get("event") == "attempt"]
|
|
424
|
+
results = [(line, event) for line, event in positioned_events if event.get("event") == "result"]
|
|
425
|
+
unknown = [event for _, event in positioned_events if event.get("event") not in {"attempt", "result"}]
|
|
426
|
+
if unknown or len(attempts) != 1:
|
|
427
|
+
issues.append(
|
|
428
|
+
_issue(
|
|
429
|
+
code="invocation.lifecycle_invalid",
|
|
430
|
+
field=f"invocations.{invocation_id}",
|
|
431
|
+
reason="The invocation must contain exactly one attempt and only known event kinds.",
|
|
432
|
+
remediation="Restore the invocation ledger from a known-good copy before handoff.",
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
if len(results) == 0 and len(attempts) == 1:
|
|
436
|
+
issues.append(
|
|
437
|
+
_issue(
|
|
438
|
+
code="invocation.finalization_unconfirmed",
|
|
439
|
+
field=f"invocations.{invocation_id}",
|
|
440
|
+
reason="The invocation has no terminal result; its final status is unconfirmed.",
|
|
441
|
+
remediation="Retain the recorded evidence and resolve the incomplete invocation before handoff.",
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
elif len(results) > 1:
|
|
445
|
+
issues.append(
|
|
446
|
+
_issue(
|
|
447
|
+
code="invocation.terminal_conflict",
|
|
448
|
+
field=f"invocations.{invocation_id}",
|
|
449
|
+
reason="The invocation has more than one terminal result.",
|
|
450
|
+
remediation="Restore an unambiguous invocation ledger before handoff.",
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
elif len(results) == 1 and not attempts:
|
|
454
|
+
issues.append(
|
|
455
|
+
_issue(
|
|
456
|
+
code="invocation.orphan_result",
|
|
457
|
+
field=f"invocations.{invocation_id}",
|
|
458
|
+
reason="The invocation result has no matching attempt.",
|
|
459
|
+
remediation="Restore the missing attempt or a known-good ledger before handoff.",
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
if len(attempts) != 1 or len(results) != 1:
|
|
463
|
+
continue
|
|
464
|
+
attempt_line, attempt = attempts[0]
|
|
465
|
+
result_line, result = results[0]
|
|
466
|
+
if result_line < attempt_line:
|
|
467
|
+
issues.append(
|
|
468
|
+
_issue(
|
|
469
|
+
code="invocation.order_invalid",
|
|
470
|
+
field=f"invocations.{invocation_id}",
|
|
471
|
+
reason="The invocation result appears before its attempt.",
|
|
472
|
+
remediation="Restore the invocation ledger in append order before handoff.",
|
|
473
|
+
)
|
|
474
|
+
)
|
|
475
|
+
changed_fields = [field for field in _INVOCATION_IDENTITY_FIELDS if result.get(field) != attempt.get(field)]
|
|
476
|
+
if changed_fields:
|
|
477
|
+
issues.append(
|
|
478
|
+
_issue(
|
|
479
|
+
code="invocation.identity_mismatch",
|
|
480
|
+
field=f"invocations.{invocation_id}",
|
|
481
|
+
reason="The invocation result changed immutable attempt fields: " + ", ".join(changed_fields) + ".",
|
|
482
|
+
remediation="Restore a result written from the matching invocation attempt.",
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
if (
|
|
486
|
+
store is None
|
|
487
|
+
or result_line not in valid_event_lines
|
|
488
|
+
or not isinstance(result.get("produced_record_revisions"), list)
|
|
489
|
+
):
|
|
490
|
+
continue
|
|
491
|
+
for revision in result["produced_record_revisions"]:
|
|
492
|
+
if not _valid_revision(revision):
|
|
493
|
+
continue
|
|
494
|
+
try:
|
|
495
|
+
history = store.record_history(revision["record_id"])
|
|
496
|
+
except ProvenanceEpochChangedError:
|
|
497
|
+
raise
|
|
498
|
+
except RecordError:
|
|
499
|
+
continue
|
|
500
|
+
revision_number = revision["revision"]
|
|
501
|
+
if revision_number > len(history):
|
|
502
|
+
issues.append(
|
|
503
|
+
_issue(
|
|
504
|
+
code="invocation.revision_missing",
|
|
505
|
+
field=f"invocations.{invocation_id}.produced_record_revisions",
|
|
506
|
+
reason=(f"Record {revision['record_id']!r} has no catalog history revision {revision_number}."),
|
|
507
|
+
remediation="Restore the matching records catalog history or invocation ledger.",
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
continue
|
|
511
|
+
actual_digest = record_revision_digest(history[revision_number - 1], outputs_dir=store.root)
|
|
512
|
+
if actual_digest != revision["revision_digest"]:
|
|
513
|
+
issues.append(
|
|
514
|
+
_issue(
|
|
515
|
+
code="invocation.revision_mismatch",
|
|
516
|
+
field=f"invocations.{invocation_id}.produced_record_revisions",
|
|
517
|
+
reason=(
|
|
518
|
+
f"Record {revision['record_id']!r} revision {revision_number} does not match "
|
|
519
|
+
"the invocation revision digest."
|
|
520
|
+
),
|
|
521
|
+
remediation="Restore the matching records catalog history or invocation ledger.",
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
if store is not None:
|
|
525
|
+
claims: dict[tuple[str, int, str], int] = {}
|
|
526
|
+
for positioned_events in events_by_invocation.values():
|
|
527
|
+
for line_number, event in positioned_events:
|
|
528
|
+
if line_number not in valid_event_lines or event.get("event") != "result":
|
|
529
|
+
continue
|
|
530
|
+
revisions = event.get("produced_record_revisions")
|
|
531
|
+
if not isinstance(revisions, list):
|
|
532
|
+
continue
|
|
533
|
+
for revision in revisions:
|
|
534
|
+
if not _valid_revision(revision):
|
|
535
|
+
continue
|
|
536
|
+
key = (revision["record_id"], revision["revision"], revision["revision_digest"])
|
|
537
|
+
claims[key] = claims.get(key, 0) + 1
|
|
538
|
+
for record in store.iter_latest_records():
|
|
539
|
+
for revision_number, revision in enumerate(store.record_history(record.record_id), start=1):
|
|
540
|
+
key = (
|
|
541
|
+
record.record_id,
|
|
542
|
+
revision_number,
|
|
543
|
+
record_revision_digest(revision, outputs_dir=store.root),
|
|
544
|
+
)
|
|
545
|
+
claim_count = claims.get(key, 0)
|
|
546
|
+
if claim_count == 0:
|
|
547
|
+
issues.append(
|
|
548
|
+
_issue(
|
|
549
|
+
code="invocation.revision_unclaimed",
|
|
550
|
+
field=f"records.{record.record_id}.history.{revision_number}",
|
|
551
|
+
reason="The catalog revision is not claimed by an active-epoch invocation result.",
|
|
552
|
+
remediation="Restore the matching invocation evidence or rerun with --reset-records.",
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
elif claim_count > 1:
|
|
556
|
+
issues.append(
|
|
557
|
+
_issue(
|
|
558
|
+
code="invocation.revision_claim_conflict",
|
|
559
|
+
field=f"records.{record.record_id}.history.{revision_number}",
|
|
560
|
+
reason="The catalog revision is claimed by more than one invocation result.",
|
|
561
|
+
remediation="Restore an unambiguous active-epoch invocation ledger.",
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
return issues, len(events_by_invocation)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _ledger_snapshot(path: Path, *, manifests_dir: Path) -> tuple[object, ...] | None:
|
|
568
|
+
if manifests_dir.is_symlink() or path.parent.is_symlink() or path.is_symlink():
|
|
569
|
+
return ("unconfined",)
|
|
570
|
+
descriptor = -1
|
|
571
|
+
try:
|
|
572
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
573
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
574
|
+
descriptor = os.open(path, flags)
|
|
575
|
+
except FileNotFoundError:
|
|
576
|
+
return None
|
|
577
|
+
except (OSError, NotImplementedError) as exc:
|
|
578
|
+
return ("unreadable", type(exc).__name__)
|
|
579
|
+
metadata = None
|
|
580
|
+
try:
|
|
581
|
+
metadata = os.fstat(descriptor)
|
|
582
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
583
|
+
return ("nonregular", metadata.st_mode, metadata.st_size, metadata.st_mtime_ns)
|
|
584
|
+
if metadata.st_nlink != 1:
|
|
585
|
+
return ("unconfined", "hardlink", metadata.st_nlink)
|
|
586
|
+
digest = hashlib.sha256()
|
|
587
|
+
with os.fdopen(descriptor, mode="rb") as stream:
|
|
588
|
+
descriptor = -1
|
|
589
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
590
|
+
digest.update(chunk)
|
|
591
|
+
except (OSError, NotImplementedError) as exc:
|
|
592
|
+
if metadata is None:
|
|
593
|
+
return ("unreadable", type(exc).__name__)
|
|
594
|
+
return ("unreadable", metadata.st_mode, metadata.st_size, metadata.st_mtime_ns, type(exc).__name__)
|
|
595
|
+
finally:
|
|
596
|
+
if descriptor >= 0:
|
|
597
|
+
with suppress(OSError):
|
|
598
|
+
os.close(descriptor)
|
|
599
|
+
return ("regular", metadata.st_size, metadata.st_mtime_ns, "sha256:" + digest.hexdigest())
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _concurrent_change_issue(*, phase: str) -> dict[str, object]:
|
|
603
|
+
if phase == "inspection":
|
|
604
|
+
reason = "The active provenance epoch changed during verification."
|
|
605
|
+
else:
|
|
606
|
+
action = "establish" if phase == "start" else "confirm"
|
|
607
|
+
reason = f"Reader could not {action} a stable provenance snapshot at the {phase} of verification."
|
|
608
|
+
return _issue(
|
|
609
|
+
code="verification.concurrent_change",
|
|
610
|
+
field="outputs/manifests",
|
|
611
|
+
reason=reason,
|
|
612
|
+
remediation="Retry verification after the active writer finishes.",
|
|
613
|
+
retryable=True,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _missing_catalog_report() -> dict[str, object]:
|
|
618
|
+
return {
|
|
619
|
+
"schema": "reader.verify/v1",
|
|
620
|
+
"status": "failed",
|
|
621
|
+
"summary": {
|
|
622
|
+
"checked": 0,
|
|
623
|
+
"failed": 1,
|
|
624
|
+
"unverifiable": 0,
|
|
625
|
+
"invocations_checked": 0,
|
|
626
|
+
"invocation_failures": 0,
|
|
627
|
+
},
|
|
628
|
+
"issues": [
|
|
629
|
+
_issue(
|
|
630
|
+
code="catalog.missing",
|
|
631
|
+
field="outputs/manifests/records.json",
|
|
632
|
+
reason="The experiment has no records catalog.",
|
|
633
|
+
remediation="Run the experiment to produce schema-v6 records, then verify again.",
|
|
634
|
+
),
|
|
635
|
+
],
|
|
636
|
+
"records": [],
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def verify_record_store(
|
|
641
|
+
store,
|
|
642
|
+
*,
|
|
643
|
+
experiment_root: Path,
|
|
644
|
+
expected_config_digest: str,
|
|
645
|
+
expected_build_identity: BuildIdentity | None = None,
|
|
646
|
+
scope: RecordVerificationScope | None = None,
|
|
647
|
+
) -> dict[str, object]:
|
|
648
|
+
current_build = expected_build_identity or current_build_identity()
|
|
649
|
+
# Observe the catalog first and the non-followed lock entry second. If both
|
|
650
|
+
# are absent, this is the non-mutating first-run linearization point. A
|
|
651
|
+
# writer starting between the checks exposes its lock entry and is awaited.
|
|
652
|
+
if not store.catalog_exists() and not store.provenance_lock_exists():
|
|
653
|
+
return _missing_catalog_report()
|
|
654
|
+
epoch_observed = False
|
|
655
|
+
try:
|
|
656
|
+
with provenance_lock_scope(
|
|
657
|
+
store.provenance_lock,
|
|
658
|
+
acquire_error=OSError("Could not acquire the verification snapshot lease"),
|
|
659
|
+
release_error=OSError("Could not release the verification snapshot lease"),
|
|
660
|
+
release_note="Reader also could not release the verification snapshot lease",
|
|
661
|
+
):
|
|
662
|
+
if not store.catalog_exists():
|
|
663
|
+
return _missing_catalog_report()
|
|
664
|
+
provenance_epoch_id = store.provenance_epoch_id()
|
|
665
|
+
epoch_observed = True
|
|
666
|
+
store.bind_provenance_epoch(provenance_epoch_id)
|
|
667
|
+
starting_catalog_digest = digest_json(store._read_catalog())
|
|
668
|
+
invocation_ledger_path = store.invocation_ledger_path()
|
|
669
|
+
starting_ledger_snapshot = _ledger_snapshot(
|
|
670
|
+
invocation_ledger_path,
|
|
671
|
+
manifests_dir=store.manifests_dir,
|
|
672
|
+
)
|
|
673
|
+
catalog_records = store.iter_latest_records()
|
|
674
|
+
except (Timeout, OSError, NotImplementedError):
|
|
675
|
+
return {
|
|
676
|
+
"schema": "reader.verify/v1",
|
|
677
|
+
"status": "failed",
|
|
678
|
+
"summary": {
|
|
679
|
+
"checked": 0,
|
|
680
|
+
"failed": 0,
|
|
681
|
+
"unverifiable": 0,
|
|
682
|
+
"invocations_checked": 0,
|
|
683
|
+
"invocation_failures": 1,
|
|
684
|
+
},
|
|
685
|
+
"issues": [_concurrent_change_issue(phase="start")],
|
|
686
|
+
"records": [],
|
|
687
|
+
}
|
|
688
|
+
except RecordError as exc:
|
|
689
|
+
if epoch_observed:
|
|
690
|
+
return {
|
|
691
|
+
"schema": "reader.verify/v1",
|
|
692
|
+
"status": "failed",
|
|
693
|
+
"summary": {
|
|
694
|
+
"checked": 0,
|
|
695
|
+
"failed": 0,
|
|
696
|
+
"unverifiable": 0,
|
|
697
|
+
"invocations_checked": 0,
|
|
698
|
+
"invocation_failures": 1,
|
|
699
|
+
},
|
|
700
|
+
"issues": [_concurrent_change_issue(phase="start")],
|
|
701
|
+
"records": [],
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
"schema": "reader.verify/v1",
|
|
705
|
+
"status": "failed",
|
|
706
|
+
"summary": {
|
|
707
|
+
"checked": 0,
|
|
708
|
+
"failed": 1,
|
|
709
|
+
"unverifiable": 0,
|
|
710
|
+
"invocations_checked": 0,
|
|
711
|
+
"invocation_failures": 0,
|
|
712
|
+
},
|
|
713
|
+
"issues": [
|
|
714
|
+
_issue(
|
|
715
|
+
code="catalog.invalid",
|
|
716
|
+
field="outputs/manifests/records.json",
|
|
717
|
+
reason=str(exc),
|
|
718
|
+
remediation="Restore a valid records catalog or rerun the experiment from source inputs.",
|
|
719
|
+
),
|
|
720
|
+
],
|
|
721
|
+
"records": [],
|
|
722
|
+
}
|
|
723
|
+
try:
|
|
724
|
+
invocation_issues, invocations_checked = _verify_invocation_ledger(
|
|
725
|
+
invocation_ledger_path,
|
|
726
|
+
expected_epoch_id=provenance_epoch_id,
|
|
727
|
+
store=store,
|
|
728
|
+
required=bool(catalog_records),
|
|
729
|
+
)
|
|
730
|
+
except ProvenanceEpochChangedError:
|
|
731
|
+
return {
|
|
732
|
+
"schema": "reader.verify/v1",
|
|
733
|
+
"status": "failed",
|
|
734
|
+
"summary": {
|
|
735
|
+
"checked": 0,
|
|
736
|
+
"failed": 0,
|
|
737
|
+
"unverifiable": 0,
|
|
738
|
+
"invocations_checked": 0,
|
|
739
|
+
"invocation_failures": 1,
|
|
740
|
+
},
|
|
741
|
+
"issues": [_concurrent_change_issue(phase="inspection")],
|
|
742
|
+
"records": [],
|
|
743
|
+
}
|
|
744
|
+
latest_records_by_id = {record.record_id: record for record in catalog_records}
|
|
745
|
+
records = catalog_records
|
|
746
|
+
if scope is not None:
|
|
747
|
+
records = tuple(record for record in records if scope.includes(record))
|
|
748
|
+
results: list[dict[str, object]] = []
|
|
749
|
+
failed = 0
|
|
750
|
+
unverifiable = 0
|
|
751
|
+
for record in records:
|
|
752
|
+
issues: list[dict[str, object]] = []
|
|
753
|
+
schema_version = record.schema_version
|
|
754
|
+
record_status = "ok"
|
|
755
|
+
if record.config_digest != expected_config_digest:
|
|
756
|
+
record_status = "unverifiable"
|
|
757
|
+
issues.append(
|
|
758
|
+
_issue(
|
|
759
|
+
code="config.digest_mismatch",
|
|
760
|
+
field="config_digest",
|
|
761
|
+
reason="The current normalized experiment config does not match the record.",
|
|
762
|
+
remediation="Restore the recorded config or rerun the affected surfaces.",
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
if record.build_identity != current_build:
|
|
766
|
+
record_status = "unverifiable"
|
|
767
|
+
issues.append(
|
|
768
|
+
_issue(
|
|
769
|
+
code="build.identity_mismatch",
|
|
770
|
+
field="build_identity",
|
|
771
|
+
reason="The current Reader build does not match the build that produced this record.",
|
|
772
|
+
remediation="Use the recorded build or rerun the affected surfaces with the current Reader build.",
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
if (
|
|
776
|
+
isinstance(record, DataFrameArtifactRecord)
|
|
777
|
+
and record.build_identity is not None
|
|
778
|
+
and record.code_digest != record.build_identity.source_digest
|
|
779
|
+
):
|
|
780
|
+
issues.append(
|
|
781
|
+
_issue(
|
|
782
|
+
code="build.code_digest_mismatch",
|
|
783
|
+
field="code_digest",
|
|
784
|
+
reason="The dataframe code digest does not match its recorded Reader build identity.",
|
|
785
|
+
remediation="Restore a valid catalog or rerun the producing surface.",
|
|
786
|
+
)
|
|
787
|
+
)
|
|
788
|
+
for item in record.inputs:
|
|
789
|
+
if isinstance(item.ref, RecordRef):
|
|
790
|
+
upstream = latest_records_by_id.get(item.ref.record_id)
|
|
791
|
+
if upstream is None:
|
|
792
|
+
issues.append(
|
|
793
|
+
_issue(
|
|
794
|
+
code="input.record_missing",
|
|
795
|
+
field=f"inputs.{item.label}",
|
|
796
|
+
reason=f"Upstream record {item.ref.record_id!r} is missing.",
|
|
797
|
+
remediation="Reproduce the upstream record, then rerun this producer.",
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
continue
|
|
801
|
+
actual_revision = record_revision_digest(upstream, outputs_dir=store.root)
|
|
802
|
+
if actual_revision != item.record_revision_digest:
|
|
803
|
+
issues.append(
|
|
804
|
+
_issue(
|
|
805
|
+
code="input.record_revision_mismatch",
|
|
806
|
+
field=f"inputs.{item.label}",
|
|
807
|
+
reason=f"Upstream record {item.ref.record_id!r} no longer matches the consumed revision.",
|
|
808
|
+
remediation="Rerun this producer against the current upstream record.",
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
try:
|
|
812
|
+
verify_record_artifact_integrity(upstream, outputs_dir=store.root)
|
|
813
|
+
except RecordError as exc:
|
|
814
|
+
issues.append(
|
|
815
|
+
_issue(
|
|
816
|
+
code="input.record_artifact_invalid",
|
|
817
|
+
field=f"inputs.{item.label}",
|
|
818
|
+
reason=str(exc),
|
|
819
|
+
remediation=(
|
|
820
|
+
"Restore the upstream artifact that matches its catalog revision, "
|
|
821
|
+
"or reproduce the upstream record and rerun this producer."
|
|
822
|
+
),
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
continue
|
|
826
|
+
if isinstance(item.ref, SourceRecordRef):
|
|
827
|
+
from reader_workbench.workbench.records.sources import resolve_source_record # noqa: PLC0415
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
upstream = resolve_source_record(item.ref, contracts=store.contracts)
|
|
831
|
+
except RecordError as exc:
|
|
832
|
+
issues.append(
|
|
833
|
+
_issue(
|
|
834
|
+
code="input.source_record_missing",
|
|
835
|
+
field=f"inputs.{item.label}",
|
|
836
|
+
reason=str(exc),
|
|
837
|
+
remediation="Restore or reproduce the source experiment record, then rerun this producer.",
|
|
838
|
+
)
|
|
839
|
+
)
|
|
840
|
+
continue
|
|
841
|
+
if upstream.revision_digest != item.record_revision_digest:
|
|
842
|
+
issues.append(
|
|
843
|
+
_issue(
|
|
844
|
+
code="input.source_record_revision_mismatch",
|
|
845
|
+
field=f"inputs.{item.label}",
|
|
846
|
+
reason=(
|
|
847
|
+
f"Source record {item.ref.experiment_id}:{item.ref.record_id} no longer "
|
|
848
|
+
"matches the consumed revision."
|
|
849
|
+
),
|
|
850
|
+
remediation="Rerun this producer against the current source record revisions.",
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
try:
|
|
854
|
+
upstream.verify_artifact_integrity()
|
|
855
|
+
except RecordError as exc:
|
|
856
|
+
issues.append(
|
|
857
|
+
_issue(
|
|
858
|
+
code="input.source_record_artifact_invalid",
|
|
859
|
+
field=f"inputs.{item.label}",
|
|
860
|
+
reason=str(exc),
|
|
861
|
+
remediation=(
|
|
862
|
+
"Restore the source artifact that matches its catalog revision, "
|
|
863
|
+
"or reproduce the source record and rerun this producer."
|
|
864
|
+
),
|
|
865
|
+
)
|
|
866
|
+
)
|
|
867
|
+
continue
|
|
868
|
+
if item.artifact is None:
|
|
869
|
+
issues.append(
|
|
870
|
+
_issue(
|
|
871
|
+
code="input.evidence_missing",
|
|
872
|
+
field=f"inputs.{item.label}",
|
|
873
|
+
reason="The file input has no artifact evidence.",
|
|
874
|
+
remediation="Rerun the producing surface to emit schema-v6 evidence.",
|
|
875
|
+
)
|
|
876
|
+
)
|
|
877
|
+
continue
|
|
878
|
+
source_path = experiment_root.resolve(strict=False) / item.artifact.relative_path
|
|
879
|
+
confined_source, confinement_issues = _confine_input(
|
|
880
|
+
source_path,
|
|
881
|
+
experiment_root=experiment_root,
|
|
882
|
+
field=f"inputs.{item.label}",
|
|
883
|
+
)
|
|
884
|
+
issues.extend(confinement_issues)
|
|
885
|
+
if confined_source is None:
|
|
886
|
+
continue
|
|
887
|
+
issues.extend(
|
|
888
|
+
_verify_file(
|
|
889
|
+
confined_source,
|
|
890
|
+
expected_size=item.artifact.size_bytes,
|
|
891
|
+
expected_digest=item.artifact.content_digest,
|
|
892
|
+
field=f"inputs.{item.label}",
|
|
893
|
+
code_prefix="input",
|
|
894
|
+
)
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
if isinstance(record, DataFrameArtifactRecord):
|
|
898
|
+
issues.extend(
|
|
899
|
+
_verify_file(
|
|
900
|
+
record.path,
|
|
901
|
+
expected_size=record.size_bytes or -1,
|
|
902
|
+
expected_digest=record.content_digest,
|
|
903
|
+
field="path",
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
elif isinstance(record, FileBundleRecord):
|
|
907
|
+
evidence_by_path = {item.relative_path: item for item in record.file_evidence}
|
|
908
|
+
for path in record.files:
|
|
909
|
+
relative = path.resolve(strict=False).relative_to(store.root.resolve(strict=False))
|
|
910
|
+
evidence = evidence_by_path.get(relative)
|
|
911
|
+
if evidence is None:
|
|
912
|
+
issues.append(
|
|
913
|
+
_issue(
|
|
914
|
+
code="artifact.evidence_missing",
|
|
915
|
+
field="file_evidence",
|
|
916
|
+
reason=f"No file evidence is recorded for {relative}.",
|
|
917
|
+
remediation="Rerun the producing surface to emit schema-v6 evidence.",
|
|
918
|
+
)
|
|
919
|
+
)
|
|
920
|
+
continue
|
|
921
|
+
issues.extend(
|
|
922
|
+
_verify_file(
|
|
923
|
+
path,
|
|
924
|
+
expected_size=evidence.size_bytes,
|
|
925
|
+
expected_digest=evidence.content_digest,
|
|
926
|
+
field=f"files.{relative.as_posix()}",
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
if issues and any(item["code"] not in _UNVERIFIABLE_CODES for item in issues):
|
|
930
|
+
record_status = "failed"
|
|
931
|
+
if record_status == "failed":
|
|
932
|
+
failed += 1
|
|
933
|
+
elif record_status == "unverifiable":
|
|
934
|
+
unverifiable += 1
|
|
935
|
+
results.append(
|
|
936
|
+
{
|
|
937
|
+
"record_id": record.record_id,
|
|
938
|
+
"kind": record.kind,
|
|
939
|
+
"schema_version": schema_version,
|
|
940
|
+
"status": record_status,
|
|
941
|
+
"issues": issues,
|
|
942
|
+
}
|
|
943
|
+
)
|
|
944
|
+
stability_issues: list[dict[str, object]] = []
|
|
945
|
+
try:
|
|
946
|
+
with provenance_lock_scope(
|
|
947
|
+
store.provenance_lock,
|
|
948
|
+
acquire_error=OSError("Could not acquire the verification snapshot lease"),
|
|
949
|
+
release_error=OSError("Could not release the verification snapshot lease"),
|
|
950
|
+
release_note="Reader also could not release the verification snapshot lease",
|
|
951
|
+
):
|
|
952
|
+
ending_catalog_digest = digest_json(store._read_catalog())
|
|
953
|
+
ending_ledger_snapshot = _ledger_snapshot(
|
|
954
|
+
invocation_ledger_path,
|
|
955
|
+
manifests_dir=store.manifests_dir,
|
|
956
|
+
)
|
|
957
|
+
if ending_catalog_digest != starting_catalog_digest or ending_ledger_snapshot != starting_ledger_snapshot:
|
|
958
|
+
stability_issues.append(
|
|
959
|
+
_issue(
|
|
960
|
+
code="verification.concurrent_change",
|
|
961
|
+
field="outputs/manifests",
|
|
962
|
+
reason="The active catalog or invocation ledger changed during verification.",
|
|
963
|
+
remediation="Retry verification after the active writer finishes.",
|
|
964
|
+
retryable=True,
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
except (Timeout, OSError, NotImplementedError, RecordError):
|
|
968
|
+
stability_issues.append(_concurrent_change_issue(phase="end"))
|
|
969
|
+
status = (
|
|
970
|
+
"failed"
|
|
971
|
+
if failed or invocation_issues or stability_issues
|
|
972
|
+
else ("unverifiable" if unverifiable or not records else "ok")
|
|
973
|
+
)
|
|
974
|
+
catalog_issues = (
|
|
975
|
+
[
|
|
976
|
+
_issue(
|
|
977
|
+
code="catalog.empty",
|
|
978
|
+
field="outputs/manifests/records.json",
|
|
979
|
+
reason="The records catalog contains no current records.",
|
|
980
|
+
remediation="Run at least one producing surface, then verify again.",
|
|
981
|
+
)
|
|
982
|
+
]
|
|
983
|
+
if not records
|
|
984
|
+
else []
|
|
985
|
+
)
|
|
986
|
+
return {
|
|
987
|
+
"schema": "reader.verify/v1",
|
|
988
|
+
"status": status,
|
|
989
|
+
"summary": {
|
|
990
|
+
"checked": len(records),
|
|
991
|
+
"failed": failed,
|
|
992
|
+
"unverifiable": unverifiable,
|
|
993
|
+
"invocations_checked": invocations_checked,
|
|
994
|
+
"invocation_failures": len(invocation_issues) + len(stability_issues),
|
|
995
|
+
},
|
|
996
|
+
"issues": [*catalog_issues, *invocation_issues, *stability_issues],
|
|
997
|
+
"records": results,
|
|
998
|
+
}
|