process-bigraph 1.4.18__tar.gz → 1.7.1__tar.gz
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.
- {process_bigraph-1.4.18/process_bigraph.egg-info → process_bigraph-1.7.1}/PKG-INFO +21 -2
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/README.md +19 -0
- process_bigraph-1.7.1/process_bigraph/__init__.py +59 -0
- process_bigraph-1.7.1/process_bigraph/artifacts.py +393 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/composite.py +175 -1
- process_bigraph-1.7.1/process_bigraph/composite_spec.py +494 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/emitter.py +216 -9
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/__init__.py +2 -0
- process_bigraph-1.7.1/process_bigraph/processes/cached.py +67 -0
- process_bigraph-1.7.1/process_bigraph/processes/simulation.py +94 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/__init__.py +3 -1
- process_bigraph-1.7.1/process_bigraph/protocols/_git_worker.py +164 -0
- process_bigraph-1.7.1/process_bigraph/protocols/git.py +678 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/scheduling.py +7 -1
- process_bigraph-1.7.1/process_bigraph/templates.py +374 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/types/process.py +104 -3
- {process_bigraph-1.4.18 → process_bigraph-1.7.1/process_bigraph.egg-info}/PKG-INFO +21 -2
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph.egg-info/SOURCES.txt +7 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph.egg-info/requires.txt +1 -1
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/pyproject.toml +8 -5
- process_bigraph-1.4.18/process_bigraph/__init__.py +0 -39
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/AUTHORS.md +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/LICENSE +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/bundle.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/experiments/__init__.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/experiments/minimal_gillespie.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/nextflow.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/plumbing.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/dynamic_structure.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/examples.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/growth_division.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/parameter_scan.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/processes/reaction.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/clusters/__init__.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/parallel.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/pool.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/ray.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/rest.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/protocols/session.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/run.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/run_step.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/server/__init__.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/server/rest.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/server/start.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph/types/__init__.py +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph.egg-info/dependency_links.txt +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/process_bigraph.egg-info/top_level.txt +0 -0
- {process_bigraph-1.4.18 → process_bigraph-1.7.1}/setup.cfg +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: process-bigraph
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.1
|
|
4
4
|
Summary: protocol and execution for compositional systems biology
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
License-File: AUTHORS.md
|
|
9
|
-
Requires-Dist: bigraph-schema>=1.4.
|
|
9
|
+
Requires-Dist: bigraph-schema>=1.4.5
|
|
10
10
|
Requires-Dist: matplotlib
|
|
11
11
|
Requires-Dist: pint>=0.24.4
|
|
12
12
|
Requires-Dist: scipy>=1.8
|
|
@@ -114,14 +114,33 @@ rendered to HTML and published automatically on GitHub Pages.
|
|
|
114
114
|
*Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
|
|
115
115
|
https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
|
|
116
116
|
|
|
117
|
+
- **Tutorial 4 — Composing a Biological Model**
|
|
118
|
+
*The central dogma from four small processes: composition over shared molecular state, biological units in the port types, and adding regulation by adding a wire*
|
|
119
|
+
https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_4.html
|
|
120
|
+
|
|
117
121
|
More tutorials are added continuously and appear automatically in the index.
|
|
118
122
|
|
|
123
|
+
### Architecture
|
|
124
|
+
|
|
125
|
+
- **The framework, end to end** — *start here to understand the whole system:
|
|
126
|
+
what the objects are (documents, sites, handles), how they compose, the
|
|
127
|
+
higher-order DAG, templates and gating, content-addressed artifacts, the
|
|
128
|
+
`git:` protocol, and the laws everything else follows from.*
|
|
129
|
+
[doc/architecture.md](doc/architecture.md)
|
|
130
|
+
|
|
119
131
|
### Topic Guides
|
|
120
132
|
|
|
121
133
|
- **Emitters — Recording Simulation Results**
|
|
122
134
|
*Built-in emitters (RAM, console, JSON, SQLite), how to wire them, retrieve results, store runs long-term, and write your own.*
|
|
123
135
|
[doc/emitters.md](doc/emitters.md)
|
|
124
136
|
|
|
137
|
+
- **Tick lifecycle** — *how a step network is ordered and advanced, and how a
|
|
138
|
+
protocol runtime batches remote dispatch.*
|
|
139
|
+
[doc/tick_lifecycle.md](doc/tick_lifecycle.md)
|
|
140
|
+
|
|
141
|
+
- **Distributed lifecycles** — *running parts of a composite off-process.*
|
|
142
|
+
[doc/distributed_lifecycles.md](doc/distributed_lifecycles.md)
|
|
143
|
+
|
|
125
144
|
---
|
|
126
145
|
|
|
127
146
|
## 🧪 Reference Implementation: spatio-flux
|
|
@@ -88,14 +88,33 @@ rendered to HTML and published automatically on GitHub Pages.
|
|
|
88
88
|
*Defining mathematical relationships, signal pipelines, and events using `MathExpressionStep`*
|
|
89
89
|
https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_3.html
|
|
90
90
|
|
|
91
|
+
- **Tutorial 4 — Composing a Biological Model**
|
|
92
|
+
*The central dogma from four small processes: composition over shared molecular state, biological units in the port types, and adding regulation by adding a wire*
|
|
93
|
+
https://vivarium-collective.github.io/process-bigraph/notebooks/tutorial_4.html
|
|
94
|
+
|
|
91
95
|
More tutorials are added continuously and appear automatically in the index.
|
|
92
96
|
|
|
97
|
+
### Architecture
|
|
98
|
+
|
|
99
|
+
- **The framework, end to end** — *start here to understand the whole system:
|
|
100
|
+
what the objects are (documents, sites, handles), how they compose, the
|
|
101
|
+
higher-order DAG, templates and gating, content-addressed artifacts, the
|
|
102
|
+
`git:` protocol, and the laws everything else follows from.*
|
|
103
|
+
[doc/architecture.md](doc/architecture.md)
|
|
104
|
+
|
|
93
105
|
### Topic Guides
|
|
94
106
|
|
|
95
107
|
- **Emitters — Recording Simulation Results**
|
|
96
108
|
*Built-in emitters (RAM, console, JSON, SQLite), how to wire them, retrieve results, store runs long-term, and write your own.*
|
|
97
109
|
[doc/emitters.md](doc/emitters.md)
|
|
98
110
|
|
|
111
|
+
- **Tick lifecycle** — *how a step network is ordered and advanced, and how a
|
|
112
|
+
protocol runtime batches remote dispatch.*
|
|
113
|
+
[doc/tick_lifecycle.md](doc/tick_lifecycle.md)
|
|
114
|
+
|
|
115
|
+
- **Distributed lifecycles** — *running parts of a composite off-process.*
|
|
116
|
+
[doc/distributed_lifecycles.md](doc/distributed_lifecycles.md)
|
|
117
|
+
|
|
99
118
|
---
|
|
100
119
|
|
|
101
120
|
## 🧪 Reference Implementation: spatio-flux
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from bigraph_schema import allocate_core # noqa: F401
|
|
2
|
+
|
|
3
|
+
# The installed distribution's version, exposed so a run manifest can record
|
|
4
|
+
# *which* engine produced an artifact. Read from installed metadata rather
|
|
5
|
+
# than hardcoded, so the two can never disagree — and note the trap that
|
|
6
|
+
# makes it worth a test: an editable install freezes this at install time, so
|
|
7
|
+
# every later commit reports the version that was in `pyproject.toml` when
|
|
8
|
+
# `pip install -e` ran. `tests.py::test_version_matches_pyproject` fails
|
|
9
|
+
# loudly when they drift instead of letting a manifest lie.
|
|
10
|
+
try:
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
12
|
+
try:
|
|
13
|
+
__version__ = _version('process-bigraph')
|
|
14
|
+
except PackageNotFoundError: # not installed (running from a tree)
|
|
15
|
+
__version__ = '0+unknown'
|
|
16
|
+
except ImportError: # pragma: no cover — py<3.8
|
|
17
|
+
__version__ = '0+unknown'
|
|
18
|
+
|
|
19
|
+
from process_bigraph.composite import ( # noqa: F401
|
|
20
|
+
Process, Step, Composite, TimingSummary, interval_time_precision, wire_step_layers,
|
|
21
|
+
)
|
|
22
|
+
from process_bigraph.composite_spec import ( # noqa: F401
|
|
23
|
+
CompositeSpec, discover_specs, regenerate_default_state,
|
|
24
|
+
normalize_type, CANONICAL_TYPES,
|
|
25
|
+
)
|
|
26
|
+
from process_bigraph.bundle import load_bundle # noqa: F401
|
|
27
|
+
from process_bigraph.emitter import Emitter, gather_emitter_results, generate_emitter_state # noqa: F401
|
|
28
|
+
from process_bigraph.types import StepLink, ProcessLink, CompositeLink # noqa: F401
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
import pprint
|
|
32
|
+
pretty = pprint.PrettyPrinter(indent=2)
|
|
33
|
+
|
|
34
|
+
def pp(x):
|
|
35
|
+
"""Print ``x`` in a pretty format."""
|
|
36
|
+
pretty.pprint(x)
|
|
37
|
+
|
|
38
|
+
def pf(x):
|
|
39
|
+
"""Format ``x`` for display."""
|
|
40
|
+
return pretty.pformat(x)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_types(core):
|
|
44
|
+
core.register_type('interval', {
|
|
45
|
+
'_inherit': 'float'})
|
|
46
|
+
|
|
47
|
+
core.register_type('default 1', {
|
|
48
|
+
'_inherit': 'float',
|
|
49
|
+
'_default': 1.0})
|
|
50
|
+
|
|
51
|
+
core.register_type('ode_config', {
|
|
52
|
+
'stoichiometry': {
|
|
53
|
+
'_type': 'array',
|
|
54
|
+
'_data': 'int64'},
|
|
55
|
+
'rates': 'map[float]',
|
|
56
|
+
'species': 'map[float]'})
|
|
57
|
+
|
|
58
|
+
return core
|
|
59
|
+
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Content-addressed study artifacts.
|
|
2
|
+
|
|
3
|
+
A study node produces an **artifact**, and some artifacts are trajectories.
|
|
4
|
+
That is the generalization ``sim_data`` forces: a simulation's ``results`` is a
|
|
5
|
+
zarr trajectory, but a calibration object is not a trajectory at all, and both
|
|
6
|
+
are things a downstream study consumes through the same ``results`` port.
|
|
7
|
+
|
|
8
|
+
Three pieces:
|
|
9
|
+
|
|
10
|
+
- the **content address** — ported verbatim from the workbench's artifact store
|
|
11
|
+
so both sides agree on where an artifact lives (see the lock-step note);
|
|
12
|
+
- :class:`ArtifactRef` — a typed reference: *what kind*, *which address*,
|
|
13
|
+
*where on disk*;
|
|
14
|
+
- :class:`ArtifactResults` — the resolved handle a ``results`` site carries,
|
|
15
|
+
dispatching ``resolve()`` on kind. ``EmitterResults`` is the ``trajectory``
|
|
16
|
+
case and keeps its own API.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Callable
|
|
26
|
+
|
|
27
|
+
# ── content address ─────────────────────────────────────────────────
|
|
28
|
+
#
|
|
29
|
+
# LOCK-STEP: this is a hand-port of
|
|
30
|
+
# ``vivarium_workbench/lib/artifacts/hashing.py``. process-bigraph cannot
|
|
31
|
+
# import the workbench (the dependency runs the other way), so the formula is
|
|
32
|
+
# duplicated deliberately — the same two-writers/one-store arrangement the
|
|
33
|
+
# reproducible-rerun spine already has. **Any change to either copy must be
|
|
34
|
+
# made to both**, or the workbench pipeline and the engine will disagree about
|
|
35
|
+
# where an artifact lives and every cache lookup will silently miss.
|
|
36
|
+
|
|
37
|
+
ARTIFACT_ROOT = '.pbg/artifacts'
|
|
38
|
+
"""Store convention: an artifact lives under ``.pbg/artifacts/<hash>/``."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stable(o):
|
|
42
|
+
"""json's ``default=`` hook: the last word on a value json cannot encode.
|
|
43
|
+
|
|
44
|
+
It also narrows a whole-valued float, but that is a **backstop, not the
|
|
45
|
+
mechanism** — see :func:`narrow_whole_floats`. ``default=`` is consulted
|
|
46
|
+
only for types json cannot serialize, and a float is serializable, so this
|
|
47
|
+
never sees one. The narrowing has to happen before the encoder runs.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(o, float) and o.is_integer():
|
|
50
|
+
return int(o)
|
|
51
|
+
raise TypeError(type(o))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def narrow_whole_floats(value):
|
|
55
|
+
"""Recursively replace whole-valued floats with the equal int.
|
|
56
|
+
|
|
57
|
+
``1.0`` and ``1`` are the same number and must be the same content
|
|
58
|
+
address. They were not: a config carrying ``seed: 1`` from YAML and the
|
|
59
|
+
same config after a float-typed parameter pass produced two different
|
|
60
|
+
addresses for the same study — a silent cache miss and a duplicate
|
|
61
|
+
recompute, on the exact path content-addressing exists to prevent.
|
|
62
|
+
|
|
63
|
+
This has to be a **pre-walk**. The obvious place to put it is json's
|
|
64
|
+
``default=`` hook, which is where it lived and never ran: ``default=`` is
|
|
65
|
+
consulted only for types the encoder cannot handle, and a float is not
|
|
66
|
+
one of them.
|
|
67
|
+
|
|
68
|
+
``bool`` is deliberately untouched: it is an ``int`` subclass, so
|
|
69
|
+
narrowing would erase the difference between ``True`` and ``1`` — a
|
|
70
|
+
widening the address should not perform. ``NaN`` and the infinities are
|
|
71
|
+
not whole, so ``is_integer()`` already leaves them alone.
|
|
72
|
+
"""
|
|
73
|
+
if isinstance(value, bool):
|
|
74
|
+
return value
|
|
75
|
+
if isinstance(value, float):
|
|
76
|
+
return int(value) if value.is_integer() else value
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
return {key: narrow_whole_floats(item) for key, item in value.items()}
|
|
79
|
+
if isinstance(value, (list, tuple)):
|
|
80
|
+
return [narrow_whole_floats(item) for item in value]
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def canonical(config: dict) -> str:
|
|
85
|
+
"""The canonical JSON encoding a content address is computed over.
|
|
86
|
+
|
|
87
|
+
Sorted keys, no whitespace, and whole floats narrowed to ints so that a
|
|
88
|
+
value's *spelling* never changes where its artifact lives.
|
|
89
|
+
"""
|
|
90
|
+
return json.dumps(
|
|
91
|
+
narrow_whole_floats(config or {}),
|
|
92
|
+
sort_keys=True, separators=(",", ":"), default=_stable)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def artifact_id(*, composite_id: str, config: dict,
|
|
96
|
+
input_ids: list, commit: str) -> str:
|
|
97
|
+
"""The content address of an artifact.
|
|
98
|
+
|
|
99
|
+
A function of *what produced it* and *what went into it* — never of a
|
|
100
|
+
filesystem path, so two worktrees at the same commit resolve the same
|
|
101
|
+
address and share a cache with no symlink between them.
|
|
102
|
+
"""
|
|
103
|
+
return _address(composite_id, canonical(config), input_ids, commit)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _address(composite_id, encoded_config, input_ids, commit) -> str:
|
|
107
|
+
payload = "\n".join([composite_id, encoded_config,
|
|
108
|
+
"\n".join(sorted(input_ids or [])), commit or ""])
|
|
109
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── the pre-narrowing formula, for migration only ───────────────────
|
|
113
|
+
#
|
|
114
|
+
# Whole-float narrowing changed where an artifact lives, but only for a
|
|
115
|
+
# config that actually *spells* a whole number as a float. The relation is
|
|
116
|
+
# exact and worth stating, because it is what makes the migration a rename
|
|
117
|
+
# rather than a recompute:
|
|
118
|
+
#
|
|
119
|
+
# artifact_id(config) == legacy_artifact_id(narrow_whole_floats(config))
|
|
120
|
+
#
|
|
121
|
+
# So a float-spelled config's artifact moves onto the address its
|
|
122
|
+
# int-spelled twin already had — the two spellings converge on the one that
|
|
123
|
+
# was always correct, and no third address is invented. Every config with no
|
|
124
|
+
# whole-valued float keeps its address unchanged.
|
|
125
|
+
|
|
126
|
+
def legacy_canonical(config: dict) -> str:
|
|
127
|
+
"""``canonical`` as it behaved before whole-float narrowing worked."""
|
|
128
|
+
return json.dumps(
|
|
129
|
+
config or {}, sort_keys=True, separators=(",", ":"), default=_stable)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def legacy_artifact_id(*, composite_id: str, config: dict,
|
|
133
|
+
input_ids: list, commit: str) -> str:
|
|
134
|
+
"""Where this artifact *used to* live. Only migrations should call this.
|
|
135
|
+
|
|
136
|
+
Everything else wants :func:`artifact_id`.
|
|
137
|
+
"""
|
|
138
|
+
return _address(
|
|
139
|
+
composite_id, legacy_canonical(config), input_ids, commit)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def artifact_store(address: str, root: str = ARTIFACT_ROOT) -> str:
|
|
143
|
+
"""Where the artifact at ``address`` lives."""
|
|
144
|
+
return os.path.join(root, address)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def artifact_exists(address: str, root: str = ARTIFACT_ROOT) -> bool:
|
|
148
|
+
"""Is this artifact already in the store?"""
|
|
149
|
+
return os.path.isdir(artifact_store(address, root))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
FINGERPRINT_FILE = 'fingerprint'
|
|
153
|
+
"""Where a stored artifact records what it produced, next to the payload."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def write_fingerprint(address: str, value: str,
|
|
157
|
+
root: str = ARTIFACT_ROOT) -> str:
|
|
158
|
+
"""Record an artifact's fingerprint in its store entry.
|
|
159
|
+
|
|
160
|
+
Producers call this after writing the payload. Without it the address is
|
|
161
|
+
the *only* thing a cache hit is checked against — and an address is a hash
|
|
162
|
+
of inputs, which says what *should* come out, never what did.
|
|
163
|
+
"""
|
|
164
|
+
store = artifact_store(address, root)
|
|
165
|
+
os.makedirs(store, exist_ok=True)
|
|
166
|
+
path = os.path.join(store, FINGERPRINT_FILE)
|
|
167
|
+
with open(path, 'w', encoding='utf-8') as handle:
|
|
168
|
+
handle.write(value)
|
|
169
|
+
return path
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def read_fingerprint(address: str, root: str = ARTIFACT_ROOT) -> str:
|
|
173
|
+
"""The recorded fingerprint, or '' when the producer wrote none.
|
|
174
|
+
|
|
175
|
+
Absent is not a failure: artifacts stored before fingerprinting, and
|
|
176
|
+
producers that opt out, simply cannot be checked — and
|
|
177
|
+
:func:`check_fingerprint` treats an empty stored value as "no claim".
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
with open(os.path.join(artifact_store(address, root),
|
|
181
|
+
FINGERPRINT_FILE), encoding='utf-8') as handle:
|
|
182
|
+
return handle.read().strip()
|
|
183
|
+
except OSError:
|
|
184
|
+
return ''
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── typed references ────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
TRAJECTORY = 'trajectory'
|
|
190
|
+
SIM_DATA = 'sim_data'
|
|
191
|
+
FIGURES = 'figures'
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class ArtifactRef:
|
|
196
|
+
"""A typed, content-addressed reference to a study's output."""
|
|
197
|
+
|
|
198
|
+
kind: str
|
|
199
|
+
hash: str = ''
|
|
200
|
+
store: str = ''
|
|
201
|
+
context: dict = field(default_factory=dict)
|
|
202
|
+
fingerprint: str = ''
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def coerce(cls, ref) -> 'ArtifactRef':
|
|
206
|
+
if isinstance(ref, ArtifactRef):
|
|
207
|
+
return ref
|
|
208
|
+
if isinstance(ref, dict):
|
|
209
|
+
known = {'kind', 'hash', 'store', 'context', 'fingerprint'}
|
|
210
|
+
return cls(**{k: v for k, v in ref.items() if k in known})
|
|
211
|
+
raise TypeError(f'not an artifact reference: {ref!r}')
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict:
|
|
214
|
+
return {'kind': self.kind, 'hash': self.hash, 'store': self.store,
|
|
215
|
+
'context': dict(self.context), 'fingerprint': self.fingerprint}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── loaders: one per kind ───────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
_LOADERS: dict = {}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def register_artifact_loader(kind: str, loader: Callable) -> None:
|
|
224
|
+
"""Teach ``ArtifactResults`` how to resolve a kind.
|
|
225
|
+
|
|
226
|
+
``loader(ref) -> value``. Kinds are open on purpose: a workspace adds
|
|
227
|
+
``sim_data`` by registering the loader that knows its calibration format,
|
|
228
|
+
without the engine knowing anything about it.
|
|
229
|
+
"""
|
|
230
|
+
_LOADERS[kind] = loader
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def artifact_loader(kind: str) -> Callable:
|
|
234
|
+
loader = _LOADERS.get(kind)
|
|
235
|
+
if loader is None:
|
|
236
|
+
raise KeyError(
|
|
237
|
+
f'no loader registered for artifact kind {kind!r} — '
|
|
238
|
+
f'known kinds: {sorted(_LOADERS)}. Register one with '
|
|
239
|
+
f'`register_artifact_loader({kind!r}, ...)`.')
|
|
240
|
+
return loader
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_trajectory(ref: ArtifactRef):
|
|
244
|
+
"""Read a trajectory back from its store.
|
|
245
|
+
|
|
246
|
+
The default understands a zarr directory via xarray, which is what the
|
|
247
|
+
emitters write; a workspace with another format registers its own.
|
|
248
|
+
"""
|
|
249
|
+
import xarray as xr
|
|
250
|
+
return xr.open_datatree(ref.store, engine='zarr')
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_pickle(ref: ArtifactRef):
|
|
254
|
+
"""Load a calibration object (the shape ParCa writes).
|
|
255
|
+
|
|
256
|
+
A stand-in: it reads the single pickle in the artifact's store. The real
|
|
257
|
+
v2ecoli loader replaces it by registering over this kind.
|
|
258
|
+
"""
|
|
259
|
+
import pickle
|
|
260
|
+
from pathlib import Path
|
|
261
|
+
|
|
262
|
+
store = Path(ref.store)
|
|
263
|
+
candidate = store if store.is_file() else None
|
|
264
|
+
if candidate is None:
|
|
265
|
+
pickles = sorted(store.glob('*.pkl')) + sorted(store.glob('*.pickle'))
|
|
266
|
+
if not pickles:
|
|
267
|
+
raise FileNotFoundError(
|
|
268
|
+
f'no calibration object under {store} for artifact '
|
|
269
|
+
f'{ref.hash!r}')
|
|
270
|
+
candidate = pickles[0]
|
|
271
|
+
with open(candidate, 'rb') as handle:
|
|
272
|
+
return pickle.load(handle)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
register_artifact_loader(TRAJECTORY, _load_trajectory)
|
|
276
|
+
register_artifact_loader(SIM_DATA, _load_pickle)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── the resolved handle ─────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
class ArtifactResults:
|
|
282
|
+
"""The handle a ``results`` site carries for a **stored** artifact.
|
|
283
|
+
|
|
284
|
+
The counterpart of ``EmitterResults``: that one references a live
|
|
285
|
+
emitter, this one references the store. Both answer ``kind``, ``context``
|
|
286
|
+
and ``resolve()``, so a downstream consumer cannot tell whether the study
|
|
287
|
+
ran or was pulled — which is the whole point of caching a study.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(self, ref, context=None):
|
|
291
|
+
self.ref = ArtifactRef.coerce(ref)
|
|
292
|
+
if context:
|
|
293
|
+
self.ref.context = {**self.ref.context, **context}
|
|
294
|
+
self._resolved = {}
|
|
295
|
+
self.provenance_status = 'ok'
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def from_ref(cls, ref) -> 'ArtifactResults':
|
|
299
|
+
return cls(ref)
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def kind(self) -> str:
|
|
303
|
+
return self.ref.kind
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def context(self) -> dict:
|
|
307
|
+
return self.ref.context
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def hash(self) -> str:
|
|
311
|
+
return self.ref.hash
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def store(self) -> str:
|
|
315
|
+
return self.ref.store
|
|
316
|
+
|
|
317
|
+
def resolve(self, paths=None):
|
|
318
|
+
"""Load the artifact. Memoized, like ``EmitterResults.resolve``: a
|
|
319
|
+
stored artifact does not change under us, and several flush entities
|
|
320
|
+
resolve the same handle."""
|
|
321
|
+
key = tuple(paths) if isinstance(paths, list) else paths
|
|
322
|
+
if key not in self._resolved:
|
|
323
|
+
self._resolved[key] = artifact_loader(self.kind)(self.ref)
|
|
324
|
+
return self._resolved[key]
|
|
325
|
+
|
|
326
|
+
def to_dict(self) -> dict:
|
|
327
|
+
return {**self.ref.to_dict(),
|
|
328
|
+
'provenance_status': self.provenance_status}
|
|
329
|
+
|
|
330
|
+
def __repr__(self) -> str:
|
|
331
|
+
return (f'ArtifactResults(kind={self.kind!r}, hash={self.hash!r}, '
|
|
332
|
+
f'status={self.provenance_status!r})')
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── determinism ─────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
def fingerprint_of(value) -> str:
|
|
338
|
+
"""A cheap, stable fingerprint of a produced result.
|
|
339
|
+
|
|
340
|
+
Used to catch the unsound case: a content address is a hash of *inputs*,
|
|
341
|
+
so it is only a valid cache key when the producer is deterministic given
|
|
342
|
+
them. A recompute that disagrees with the stored fingerprint means it is
|
|
343
|
+
not.
|
|
344
|
+
"""
|
|
345
|
+
return hashlib.sha256(
|
|
346
|
+
canonical(value if isinstance(value, dict) else {'value': value})
|
|
347
|
+
.encode('utf-8')).hexdigest()[:16]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def check_fingerprint(results, observed: str) -> str:
|
|
351
|
+
"""Compare a recomputed fingerprint against the stored one.
|
|
352
|
+
|
|
353
|
+
Returns (and records) ``'ok'`` or ``'nondeterministic'``. A divergence is
|
|
354
|
+
*flagged*, never silently served — the address said these results should
|
|
355
|
+
be identical and they were not.
|
|
356
|
+
"""
|
|
357
|
+
stored = getattr(getattr(results, 'ref', None), 'fingerprint', '')
|
|
358
|
+
status = 'ok' if (not stored or stored == observed) else 'nondeterministic'
|
|
359
|
+
results.provenance_status = status
|
|
360
|
+
return status
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── admitting kinds at a `results` site ─────────────────────────────
|
|
364
|
+
|
|
365
|
+
def results_kind(filler) -> str:
|
|
366
|
+
"""The artifact kind a filler carries, or '' if it is not a handle."""
|
|
367
|
+
return getattr(filler, 'kind', '') or ''
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def register_artifact_sorts(core, kinds=(TRAJECTORY, SIM_DATA, FIGURES)):
|
|
371
|
+
"""Register the sorts a ``results`` site is constrained by.
|
|
372
|
+
|
|
373
|
+
``'results'`` admits any handle; ``'results_<kind>'`` admits only that
|
|
374
|
+
kind — so a study declaring it needs ``sim_data`` cannot be wired to a
|
|
375
|
+
trajectory.
|
|
376
|
+
|
|
377
|
+
The names use an underscore, not a colon: ``_sort`` is a schema field, so
|
|
378
|
+
``access`` runs it through the type grammar, where ``a:b`` is the
|
|
379
|
+
named-parameter form and would be parsed rather than kept as a name.
|
|
380
|
+
"""
|
|
381
|
+
core.register_sort('results', lambda core, site, filler: bool(
|
|
382
|
+
results_kind(filler)))
|
|
383
|
+
for kind in kinds:
|
|
384
|
+
core.register_sort(
|
|
385
|
+
results_sort(kind),
|
|
386
|
+
(lambda wanted: (lambda core, site, filler:
|
|
387
|
+
results_kind(filler) == wanted))(kind))
|
|
388
|
+
return core
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def results_sort(kind: str) -> str:
|
|
392
|
+
"""The sort name constraining a ``results`` site to one artifact kind."""
|
|
393
|
+
return f'results_{kind}'
|