process-bigraph 1.5.0__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.
Files changed (49) hide show
  1. {process_bigraph-1.5.0/process_bigraph.egg-info → process_bigraph-1.7.1}/PKG-INFO +21 -2
  2. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/README.md +19 -0
  3. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/__init__.py +16 -0
  4. process_bigraph-1.7.1/process_bigraph/artifacts.py +393 -0
  5. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/composite.py +175 -1
  6. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/composite_spec.py +190 -16
  7. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/emitter.py +216 -9
  8. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/__init__.py +2 -0
  9. process_bigraph-1.7.1/process_bigraph/processes/cached.py +67 -0
  10. process_bigraph-1.7.1/process_bigraph/processes/simulation.py +94 -0
  11. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/__init__.py +3 -1
  12. process_bigraph-1.7.1/process_bigraph/protocols/_git_worker.py +164 -0
  13. process_bigraph-1.7.1/process_bigraph/protocols/git.py +678 -0
  14. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/scheduling.py +7 -1
  15. process_bigraph-1.7.1/process_bigraph/templates.py +374 -0
  16. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/types/process.py +104 -3
  17. {process_bigraph-1.5.0 → process_bigraph-1.7.1/process_bigraph.egg-info}/PKG-INFO +21 -2
  18. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph.egg-info/SOURCES.txt +6 -0
  19. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph.egg-info/requires.txt +1 -1
  20. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/pyproject.toml +8 -5
  21. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/AUTHORS.md +0 -0
  22. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/LICENSE +0 -0
  23. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/bundle.py +0 -0
  24. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/experiments/__init__.py +0 -0
  25. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/experiments/minimal_gillespie.py +0 -0
  26. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/nextflow.py +0 -0
  27. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/plumbing.py +0 -0
  28. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/bigraphical_reactive_system.py +0 -0
  29. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/dynamic_structure.py +0 -0
  30. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/examples.py +0 -0
  31. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/growth_division.py +0 -0
  32. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/parameter_scan.py +0 -0
  33. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/processes/reaction.py +0 -0
  34. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/clusters/__init__.py +0 -0
  35. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/clusters/ec2_ssm.py +0 -0
  36. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/parallel.py +0 -0
  37. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/pool.py +0 -0
  38. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/ray.py +0 -0
  39. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/rest.py +0 -0
  40. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/protocols/session.py +0 -0
  41. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/run.py +0 -0
  42. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/run_step.py +0 -0
  43. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/server/__init__.py +0 -0
  44. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/server/rest.py +0 -0
  45. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/server/start.py +0 -0
  46. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph/types/__init__.py +0 -0
  47. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph.egg-info/dependency_links.txt +0 -0
  48. {process_bigraph-1.5.0 → process_bigraph-1.7.1}/process_bigraph.egg-info/top_level.txt +0 -0
  49. {process_bigraph-1.5.0 → 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.5.0
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.2
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
@@ -1,5 +1,21 @@
1
1
  from bigraph_schema import allocate_core # noqa: F401
2
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
+
3
19
  from process_bigraph.composite import ( # noqa: F401
4
20
  Process, Step, Composite, TimingSummary, interval_time_precision, wire_step_layers,
5
21
  )
@@ -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}'