MATE-runtime 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.
- MATE/__init__.py +22 -0
- MATE/__main__.py +7 -0
- MATE/cli/__init__.py +181 -0
- MATE/cli/commands.py +573 -0
- MATE/cli/workdir.py +571 -0
- MATE/compat/__init__.py +1 -0
- MATE/compat/interface.py +248 -0
- MATE/compat/sizes.py +144 -0
- MATE/config/__init__.py +1 -0
- MATE/config/axes.py +310 -0
- MATE/config/builders.py +498 -0
- MATE/config/calendar.py +371 -0
- MATE/config/canonical.py +543 -0
- MATE/config/criteria.py +396 -0
- MATE/config/events.py +183 -0
- MATE/config/invariance.py +227 -0
- MATE/config/loader.py +498 -0
- MATE/config/model.py +883 -0
- MATE/config/resolve.py +943 -0
- MATE/config/runtable.py +342 -0
- MATE/config/seeds.py +344 -0
- MATE/config/stages.py +632 -0
- MATE/config/structure.py +635 -0
- MATE/config/toml.py +237 -0
- MATE/conformance/__init__.py +14 -0
- MATE/conformance/baseline.py +250 -0
- MATE/conformance/checks.py +1604 -0
- MATE/conformance/sampler.py +179 -0
- MATE/errors.py +236 -0
- MATE/evaluation/__init__.py +1 -0
- MATE/evaluation/budget.py +160 -0
- MATE/evaluation/invalids.py +181 -0
- MATE/evaluation/requests.py +148 -0
- MATE/execution/__init__.py +1 -0
- MATE/execution/accelerator.py +60 -0
- MATE/execution/base.py +643 -0
- MATE/execution/choose.py +152 -0
- MATE/execution/devices.py +108 -0
- MATE/execution/process.py +377 -0
- MATE/execution/serial.py +37 -0
- MATE/execution/thread.py +106 -0
- MATE/execution/vectorised.py +32 -0
- MATE/launch.py +267 -0
- MATE/loop/__init__.py +6 -0
- MATE/loop/continuous.py +172 -0
- MATE/loop/driver.py +783 -0
- MATE/loop/events.py +266 -0
- MATE/loop/generational.py +140 -0
- MATE/loop/owns_loop.py +333 -0
- MATE/loop/subpops.py +508 -0
- MATE/modules/__init__.py +1 -0
- MATE/modules/construct.py +198 -0
- MATE/modules/discovery.py +238 -0
- MATE/modules/registry.py +970 -0
- MATE/modules/software.py +593 -0
- MATE/modules/versions.py +72 -0
- MATE/numerics.py +159 -0
- MATE/observation/__init__.py +10 -0
- MATE/observation/control.py +199 -0
- MATE/observation/hub.py +451 -0
- MATE/observation/records.py +125 -0
- MATE/observation/sinks.py +313 -0
- MATE/provenance.py +396 -0
- MATE/readout/__init__.py +10 -0
- MATE/readout/catalogue.py +282 -0
- MATE/readout/front.py +151 -0
- MATE/readout/readout.py +927 -0
- MATE/readout/series.py +155 -0
- MATE/readout/statistics.py +202 -0
- MATE/run/__init__.py +1 -0
- MATE/run/checkpointing.py +266 -0
- MATE/run/contexts.py +553 -0
- MATE/run/declarations.py +240 -0
- MATE/run/plan.py +464 -0
- MATE/run/preconditions.py +165 -0
- MATE/run/run.py +991 -0
- MATE/run/specs.py +319 -0
- MATE/run/times.py +103 -0
- MATE/scheduling/__init__.py +214 -0
- MATE/scheduling/local.py +119 -0
- MATE/scheduling/slurm.py +380 -0
- MATE/storage/__init__.py +1 -0
- MATE/storage/checkpoint.py +414 -0
- MATE/storage/codec.py +315 -0
- MATE/storage/database.py +359 -0
- MATE/storage/lease.py +397 -0
- MATE/storage/rundir.py +248 -0
- mate_runtime-1.0.0.dist-info/METADATA +58 -0
- mate_runtime-1.0.0.dist-info/RECORD +93 -0
- mate_runtime-1.0.0.dist-info/WHEEL +4 -0
- mate_runtime-1.0.0.dist-info/entry_points.txt +2 -0
- mate_runtime-1.0.0.dist-info/licenses/COPYING +674 -0
- mate_runtime-1.0.0.dist-info/licenses/COPYING.LESSER +165 -0
MATE/cli/workdir.py
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Readers of a workdir and of its run directories, for the command line.
|
|
2
|
+
|
|
3
|
+
``MATE status``, ``verify`` and ``runs`` read the run table ([C] R21.4, R19.8); ``MATE diff``
|
|
4
|
+
compares blueprints ([C] R21.5); ``MATE show`` summarises run databases (design §1.2);
|
|
5
|
+
:func:`branch_protocol` builds the protocol of ``MATE branch`` ([C] R21.3). Each reader
|
|
6
|
+
returns the text the command prints, or raises :class:`WorkdirError`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from MATE.config import canonical
|
|
16
|
+
from MATE.config.invariance import Violation, default_hold, shared_branch_mismatches, verify
|
|
17
|
+
from MATE.config.model import Value, designates
|
|
18
|
+
from MATE.config.runtable import PlannedRun, RunTable
|
|
19
|
+
from MATE.errors import MATEError
|
|
20
|
+
from MATE.launch import run_state
|
|
21
|
+
from MATE.provenance import Provenance, read_provenance
|
|
22
|
+
from MATE.storage import codec
|
|
23
|
+
from MATE.storage.checkpoint import find_addressable
|
|
24
|
+
from MATE.storage.lease import live_lease, read_lease, running
|
|
25
|
+
from MATE.storage.rundir import RunDirectory
|
|
26
|
+
|
|
27
|
+
__all__ = ["WorkdirError", "status", "verify_workdir", "runs", "diff", "show",
|
|
28
|
+
"branch_protocol"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WorkdirError(MATEError):
|
|
32
|
+
"""A workdir command cannot proceed.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
message : str
|
|
37
|
+
exit_status : int, default 3
|
|
38
|
+
1 for a refusal (an invariant violated, a best without a total order), 3 for a
|
|
39
|
+
usage error (no run table, an ambiguous protocol).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
message: str
|
|
43
|
+
exit_status: int
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, exit_status: int = 3) -> None:
|
|
46
|
+
self.message = message
|
|
47
|
+
self.exit_status = exit_status
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- the run table ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _table(workdir: Path) -> RunTable:
|
|
55
|
+
"""The run table of a workdir; a usage error without one."""
|
|
56
|
+
path = Path(workdir)
|
|
57
|
+
if path.exists() and not path.is_dir():
|
|
58
|
+
if path.suffix == ".toml":
|
|
59
|
+
raise WorkdirError(f"{workdir}: a protocol file, not a workdir; give the directory "
|
|
60
|
+
f"its launch wrote ([execution] workdir)")
|
|
61
|
+
raise WorkdirError(f"{workdir}: a file, not a workdir")
|
|
62
|
+
try:
|
|
63
|
+
table = RunTable.load(path)
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
raise WorkdirError(str(exc), 1) from None
|
|
66
|
+
if table is None:
|
|
67
|
+
raise WorkdirError(f"{workdir}: no run table (not a workdir)")
|
|
68
|
+
return table
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _protocol(table: RunTable, protocol: str | None, required: bool) -> str | None:
|
|
72
|
+
"""The protocol aggregated: given, implied by a single one, or mandatory (R18.9)."""
|
|
73
|
+
if protocol is not None:
|
|
74
|
+
if protocol not in table.protocols:
|
|
75
|
+
raise WorkdirError(f"protocol '{protocol}' is not in the run table (found: "
|
|
76
|
+
f"{', '.join(table.protocols) or 'none'})")
|
|
77
|
+
return protocol
|
|
78
|
+
if len(table.protocols) == 1:
|
|
79
|
+
return next(iter(table.protocols))
|
|
80
|
+
if required and table.protocols:
|
|
81
|
+
raise WorkdirError(f"the workdir holds several protocols ({', '.join(table.protocols)});"
|
|
82
|
+
f" name one with --protocol")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _missing_state(row: PlannedRun, table: RunTable, workdir: Path) -> str | None:
|
|
87
|
+
"""The R17.18 message of a chained run whose designated state will not exist."""
|
|
88
|
+
if row.parent is None:
|
|
89
|
+
return None
|
|
90
|
+
parent_ref, generation = row.parent
|
|
91
|
+
parent = table.find(parent_ref)
|
|
92
|
+
if parent is None:
|
|
93
|
+
return None
|
|
94
|
+
parent_dir = RunDirectory(Path(workdir) / parent.directory)
|
|
95
|
+
state, cause = run_state(parent_dir.path)
|
|
96
|
+
if state not in ("done", "failed") or find_addressable(parent_dir, generation) is not None:
|
|
97
|
+
return None
|
|
98
|
+
head = f"state {parent.directory}@{generation} missing"
|
|
99
|
+
if cause is None:
|
|
100
|
+
return f"{head}: parent did not run"
|
|
101
|
+
reason = cause.kind + (f": {cause.detail}" if cause.detail else "")
|
|
102
|
+
at = f" at generation {cause.generation}" if cause.generation is not None else ""
|
|
103
|
+
return f"{head}: parent stopped{at} ({reason})"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _state(row: PlannedRun, table: RunTable, workdir: Path) -> tuple[str, str]:
|
|
107
|
+
"""``(state label, detail)`` of a row."""
|
|
108
|
+
directory = Path(workdir) / row.directory
|
|
109
|
+
missing = _missing_state(row, table, workdir)
|
|
110
|
+
if not directory.is_dir():
|
|
111
|
+
return ("missing state", missing) if missing else ("not started", "")
|
|
112
|
+
holder = live_lease(directory)
|
|
113
|
+
if holder is not None:
|
|
114
|
+
return "running", holder.describe()
|
|
115
|
+
state, cause = run_state(directory)
|
|
116
|
+
if cause is not None and cause.kind == "missing_state":
|
|
117
|
+
return "missing state", cause.detail
|
|
118
|
+
if state == "new" and missing:
|
|
119
|
+
return "missing state", missing
|
|
120
|
+
labels = {"new": "not started", "interrupted": "interrupted", "running": "running",
|
|
121
|
+
"done": "done", "failed": "failed"}
|
|
122
|
+
detail = ""
|
|
123
|
+
if cause is not None:
|
|
124
|
+
detail = cause.kind + (f": {cause.detail}" if cause.detail else "")
|
|
125
|
+
if cause.generation is not None:
|
|
126
|
+
detail += f" (generation {cause.generation})"
|
|
127
|
+
elif state == "interrupted" and (stale := read_lease(directory)) is not None:
|
|
128
|
+
detail = f"lease left behind: {stale.describe()}" # killed without ending
|
|
129
|
+
return labels[state], detail
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def status(workdir: Path, protocol: str | None = None) -> str:
|
|
133
|
+
"""Report the progress of a workdir's runs ([C] R21.4).
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
workdir : Path
|
|
138
|
+
protocol : str, optional
|
|
139
|
+
Restrict to one protocol's rows; default every row.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
str
|
|
144
|
+
One line per run (state and stop cause; host, pid and age of the last beat for a
|
|
145
|
+
running run), then the counts per state; missing states are reported with their
|
|
146
|
+
cause (R17.18).
|
|
147
|
+
|
|
148
|
+
Raises
|
|
149
|
+
------
|
|
150
|
+
WorkdirError
|
|
151
|
+
Without a run table, or for an unknown protocol.
|
|
152
|
+
"""
|
|
153
|
+
table = _table(workdir)
|
|
154
|
+
name = _protocol(table, protocol, required=False) if protocol is not None else None
|
|
155
|
+
rows = table.rows(name)
|
|
156
|
+
counts: dict[str, int] = {}
|
|
157
|
+
lines = [f"workdir {workdir}" + (f", protocol {name}" if name else "")]
|
|
158
|
+
for row in rows:
|
|
159
|
+
label, detail = _state(row, table, workdir)
|
|
160
|
+
counts[label] = counts.get(label, 0) + 1
|
|
161
|
+
lines.append(f" {row.directory:<10} {label:<13} {detail}".rstrip())
|
|
162
|
+
summary = ", ".join(f"{n} {label}" for label, n in counts.items())
|
|
163
|
+
lines.append(f"{len(rows)} run(s)" + (f": {summary}" if summary else ""))
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- invariance --------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _hold(rows: Sequence[PlannedRun], workdir: Path,
|
|
171
|
+
provenances: Mapping[str, Provenance]) -> tuple[str, ...]:
|
|
172
|
+
"""The held keys: ``[invariant] hold`` of the launch's protocol files, else the default."""
|
|
173
|
+
for row in rows:
|
|
174
|
+
sources = Path(workdir) / row.directory / "sources"
|
|
175
|
+
chain = [sources / "protocol.toml"]
|
|
176
|
+
chain += sorted(sources.glob("extends-*.toml"), key=lambda p: int(p.stem.split("-")[1]))
|
|
177
|
+
if not chain[0].is_file():
|
|
178
|
+
continue
|
|
179
|
+
for path in chain:
|
|
180
|
+
try:
|
|
181
|
+
with open(path, "rb") as handle:
|
|
182
|
+
table = tomllib.load(handle)
|
|
183
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
184
|
+
break
|
|
185
|
+
hold = table.get("invariant", {}).get("hold")
|
|
186
|
+
if isinstance(hold, list):
|
|
187
|
+
return tuple(hold)
|
|
188
|
+
break
|
|
189
|
+
owns_loop = bool(provenances) and all(
|
|
190
|
+
not any(m.kind == "method" for m in p.modules) for p in provenances.values())
|
|
191
|
+
return default_hold(owns_loop)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _provenances(rows: Sequence[PlannedRun], workdir: Path) -> dict[str, Provenance]:
|
|
195
|
+
"""Run directory -> provenance, for the runs that have one."""
|
|
196
|
+
out: dict[str, Provenance] = {}
|
|
197
|
+
for row in rows:
|
|
198
|
+
try:
|
|
199
|
+
out[row.directory] = read_provenance(Path(workdir) / row.directory)
|
|
200
|
+
except (OSError, ValueError):
|
|
201
|
+
continue
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _violation_lines(violations: Sequence[Violation]) -> list[str]:
|
|
206
|
+
"""One block per violation: key, subject, each value with its runs."""
|
|
207
|
+
lines: list[str] = []
|
|
208
|
+
for v in violations:
|
|
209
|
+
lines.append(f" {v.key} ({v.subject}) takes {len(v.found)} values:")
|
|
210
|
+
for value, dirs in v.found.items():
|
|
211
|
+
lines.append(f" {value}: {', '.join(dirs)}")
|
|
212
|
+
return lines
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _violations(workdir: Path, protocol: str | None
|
|
216
|
+
) -> tuple[str | None, list[PlannedRun], tuple[str, ...], list[Violation]]:
|
|
217
|
+
"""The protocol aggregated, its rows, the held keys and every violation."""
|
|
218
|
+
table = _table(workdir)
|
|
219
|
+
name = _protocol(table, protocol, required=True)
|
|
220
|
+
rows = table.rows(name)
|
|
221
|
+
provenances = _provenances(rows, workdir)
|
|
222
|
+
hold = _hold(rows, workdir, provenances)
|
|
223
|
+
found = verify(hold, provenances) + shared_branch_mismatches(rows, workdir)
|
|
224
|
+
return name, rows, hold, found
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def verify_workdir(workdir: Path, protocol: str | None = None) -> tuple[str, bool]:
|
|
228
|
+
"""Check the held invariants over one protocol's runs ([C] R19.8).
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
workdir : Path
|
|
233
|
+
protocol : str, optional
|
|
234
|
+
Mandatory when the workdir holds several protocols (R18.9).
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
tuple
|
|
239
|
+
``(report, ok)``: every violation (key, subject, each value with its runs) and the
|
|
240
|
+
shared-branch mismatches; ``ok`` is ``False`` when there is any.
|
|
241
|
+
|
|
242
|
+
Raises
|
|
243
|
+
------
|
|
244
|
+
WorkdirError
|
|
245
|
+
Without a run table, or for a missing or unknown protocol.
|
|
246
|
+
|
|
247
|
+
Notes
|
|
248
|
+
-----
|
|
249
|
+
The held keys are those of ``[invariant] hold`` in the protocol files copied into the
|
|
250
|
+
first run's ``sources/``, else the default set (R19.5).
|
|
251
|
+
"""
|
|
252
|
+
name, rows, hold, found = _violations(workdir, protocol)
|
|
253
|
+
head = f"protocol {name}: " if name else ""
|
|
254
|
+
if not found:
|
|
255
|
+
return f"{head}{len(rows)} run(s), no violation (held: {', '.join(hold)})", True
|
|
256
|
+
lines = [f"{head}{len(found)} violation(s)"] + _violation_lines(found)
|
|
257
|
+
return "\n".join(lines), False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _axis(value: Value) -> str:
|
|
261
|
+
"""An axis value as written in a report."""
|
|
262
|
+
return codec.to_json(canonical.thaw(value))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def runs(workdir: Path, protocol: str | None = None) -> str:
|
|
266
|
+
"""Return the run table of one protocol: blueprint -> runs, axis values ([C] R21.4).
|
|
267
|
+
|
|
268
|
+
Parameters
|
|
269
|
+
----------
|
|
270
|
+
workdir : Path
|
|
271
|
+
protocol : str, optional
|
|
272
|
+
Mandatory when the workdir holds several protocols.
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
str
|
|
277
|
+
One line per run: directory, stage, short fingerprint, replicate, seed, state, axes.
|
|
278
|
+
|
|
279
|
+
Raises
|
|
280
|
+
------
|
|
281
|
+
WorkdirError
|
|
282
|
+
On any invariant violation (exit status 1, R19.8), without a run table, or for a
|
|
283
|
+
missing or unknown protocol.
|
|
284
|
+
"""
|
|
285
|
+
name, rows, _, found = _violations(workdir, protocol)
|
|
286
|
+
if found:
|
|
287
|
+
raise WorkdirError("\n".join([f"protocol {name}: invariants violated, no aggregation"]
|
|
288
|
+
+ _violation_lines(found)), 1)
|
|
289
|
+
table = _table(workdir)
|
|
290
|
+
lines = [f"{'run':<10} {'stage':<10} {'blueprint':<12} {'rep':>4} {'state':<13} "
|
|
291
|
+
f"{'seed':<40} axes"]
|
|
292
|
+
for row in rows:
|
|
293
|
+
label, _ = _state(row, table, workdir)
|
|
294
|
+
axes = ", ".join(f"{k} = {_axis(v)}" for k, v in row.axes.items())
|
|
295
|
+
lines.append(f"{row.directory:<10} {row.stage or '-':<10} "
|
|
296
|
+
f"{row.identity.fingerprint[:12]:<12} {row.replicate:>4} {label:<13} "
|
|
297
|
+
f"{row.identity.seed!s:<40} {axes}".rstrip())
|
|
298
|
+
return "\n".join(lines)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# --- diff --------------------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _blueprints(path: Path) -> list[dict[str, Value]]:
|
|
305
|
+
"""The flat blueprints of a run directory (one) or of a workdir (each, not superseded)."""
|
|
306
|
+
path = Path(path)
|
|
307
|
+
if (path / "blueprint.toml").is_file():
|
|
308
|
+
return [RunDirectory(path).read_blueprint()[0]]
|
|
309
|
+
table = _table(path)
|
|
310
|
+
seen: dict[str, dict[str, Value]] = {}
|
|
311
|
+
for row in table.rows(None):
|
|
312
|
+
fp = row.identity.fingerprint
|
|
313
|
+
file = path / row.directory / "blueprint.toml"
|
|
314
|
+
if fp not in seen and file.is_file():
|
|
315
|
+
seen[fp] = RunDirectory(path / row.directory).read_blueprint()[0]
|
|
316
|
+
if not seen:
|
|
317
|
+
raise WorkdirError(f"{path}: no recorded blueprint")
|
|
318
|
+
return list(seen.values())
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _values(blueprints: Sequence[Mapping[str, Value]], key: str) -> list[str]:
|
|
322
|
+
"""The distinct values of a path over blueprints, in order of first appearance."""
|
|
323
|
+
out: list[str] = []
|
|
324
|
+
for b in blueprints:
|
|
325
|
+
if key in b and (v := _axis(b[key])) not in out:
|
|
326
|
+
out.append(v)
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def diff(a: Path, b: Path) -> str:
|
|
331
|
+
"""Compare the blueprints of two run directories or workdirs ([C] R21.5).
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
a, b : Path
|
|
336
|
+
Each a run directory or a workdir.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
str
|
|
341
|
+
The flat paths constant within each side whose value differs between the sides,
|
|
342
|
+
then (with a workdir) the paths that vary within a side with their value sets, then
|
|
343
|
+
the paths present on one side only. Sides with identical blueprints give
|
|
344
|
+
``no difference``.
|
|
345
|
+
|
|
346
|
+
Raises
|
|
347
|
+
------
|
|
348
|
+
WorkdirError
|
|
349
|
+
For a path that is neither a run directory nor a workdir.
|
|
350
|
+
"""
|
|
351
|
+
left, right = _blueprints(a), _blueprints(b)
|
|
352
|
+
keys_l = list(dict.fromkeys(k for bp in left for k in bp))
|
|
353
|
+
keys_r = list(dict.fromkeys(k for bp in right for k in bp))
|
|
354
|
+
both = [k for k in keys_l if k in keys_r]
|
|
355
|
+
differ, vary = [], []
|
|
356
|
+
for k in both:
|
|
357
|
+
vl, vr = _values(left, k), _values(right, k)
|
|
358
|
+
full = all(k in bp for bp in left) and all(k in bp for bp in right)
|
|
359
|
+
if len(vl) == 1 and len(vr) == 1 and full:
|
|
360
|
+
if vl != vr:
|
|
361
|
+
differ.append(f" {k}: {vl[0]} | {vr[0]}")
|
|
362
|
+
elif vl != vr or len(vl) > 1:
|
|
363
|
+
vary.append(f" {k}: {{{', '.join(vl)}}} | {{{', '.join(vr)}}}")
|
|
364
|
+
only_l = [f" {k} = {', '.join(_values(left, k))}" for k in keys_l if k not in keys_r]
|
|
365
|
+
only_r = [f" {k} = {', '.join(_values(right, k))}" for k in keys_r if k not in keys_l]
|
|
366
|
+
lines: list[str] = [f"--- {a}", f"+++ {b}"]
|
|
367
|
+
for title, block in ((f"differ ({a} | {b}):", differ),
|
|
368
|
+
("vary within a side:", vary),
|
|
369
|
+
(f"only in {a}:", only_l),
|
|
370
|
+
(f"only in {b}:", only_r)):
|
|
371
|
+
if block:
|
|
372
|
+
lines.append(title)
|
|
373
|
+
lines.extend(block)
|
|
374
|
+
if len(lines) == 2:
|
|
375
|
+
lines.append("no difference")
|
|
376
|
+
return "\n".join(lines)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# --- show --------------------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _query(db: Path, sql: str, params: Sequence[object] = ()) -> list[tuple]:
|
|
383
|
+
"""Run one read-only query on a run database."""
|
|
384
|
+
connection = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
385
|
+
try:
|
|
386
|
+
return connection.execute(sql, tuple(params)).fetchall()
|
|
387
|
+
finally:
|
|
388
|
+
connection.close()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _number(x: object) -> str:
|
|
392
|
+
"""A number as shown in a summary."""
|
|
393
|
+
return "-" if x is None else f"{x:.6g}" if isinstance(x, float) else str(x)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _show_run(directory: Path, objective: str | None, subpop: str | None) -> list[str]:
|
|
397
|
+
"""The summary of one run."""
|
|
398
|
+
rundir = RunDirectory(directory)
|
|
399
|
+
db = rundir.database
|
|
400
|
+
if not db.is_file():
|
|
401
|
+
return [f"{rundir.name}: no database (not started)"]
|
|
402
|
+
lines = [f"{rundir.name}"]
|
|
403
|
+
try:
|
|
404
|
+
_, identity, stage = rundir.read_blueprint()
|
|
405
|
+
lines.append(f" blueprint {identity.fingerprint[:12]}, seed {identity.seed}"
|
|
406
|
+
+ (f", stage {stage}" if stage else ""))
|
|
407
|
+
except (OSError, ValueError):
|
|
408
|
+
pass
|
|
409
|
+
outcome = _query(db, "SELECT status, kind, detail, run_generation FROM outcome "
|
|
410
|
+
"ORDER BY seq DESC LIMIT 1")
|
|
411
|
+
holder = live_lease(directory)
|
|
412
|
+
if holder is not None:
|
|
413
|
+
lines.append(f" status {running(holder)}")
|
|
414
|
+
elif outcome:
|
|
415
|
+
st, kind, detail, g = outcome[0]
|
|
416
|
+
at = f" at generation {g}" if g is not None else ""
|
|
417
|
+
lines.append(f" status {st}: {kind}" + (f" ({detail})" if detail else "") + at)
|
|
418
|
+
else:
|
|
419
|
+
lines.append(" status no outcome recorded (interrupted)")
|
|
420
|
+
gens = _query(db, "SELECT run_generation, spent_episodes, spent_steps FROM generations "
|
|
421
|
+
"ORDER BY run_generation DESC LIMIT 1")
|
|
422
|
+
if gens:
|
|
423
|
+
lines.append(f" generations {gens[0][0] + 1}")
|
|
424
|
+
lines.append(f" consumption {_number(gens[0][1])} episodes, {_number(gens[0][2])} steps")
|
|
425
|
+
try:
|
|
426
|
+
prov = read_provenance(directory)
|
|
427
|
+
modules = ", ".join(f"{m.kind} {m.name} {m.version}" for m in prov.modules)
|
|
428
|
+
lines.append(f" versions runtime {prov.runtime_version}, contract "
|
|
429
|
+
f"{prov.contract_version}" + (f", {modules}" if modules else ""))
|
|
430
|
+
except (OSError, ValueError):
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
paths = [p for (p,) in _query(db, "SELECT path FROM subpops ORDER BY seq")]
|
|
434
|
+
paths = list(dict.fromkeys(paths))
|
|
435
|
+
if subpop is not None:
|
|
436
|
+
chosen = [p for p in paths if designates(subpop, p)]
|
|
437
|
+
if not chosen:
|
|
438
|
+
raise WorkdirError(f"{rundir.name}: no subpopulation '{subpop}' (found: "
|
|
439
|
+
f"{', '.join(paths) or 'none'})")
|
|
440
|
+
elif len(paths) > 1:
|
|
441
|
+
raise WorkdirError(f"{rundir.name}: several subpopulations ({', '.join(paths)}); "
|
|
442
|
+
f"name one with --subpop", 1)
|
|
443
|
+
else:
|
|
444
|
+
chosen = paths
|
|
445
|
+
for path in chosen:
|
|
446
|
+
lines.extend(_show_subpop(db, rundir.name, path, objective))
|
|
447
|
+
return lines
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _show_subpop(db: Path, run: str, path: str, objective: str | None) -> list[str]:
|
|
451
|
+
"""Series per generation and best individual of one subpopulation."""
|
|
452
|
+
stats = _query(db, "SELECT run_generation, objective, best, mean FROM statistics "
|
|
453
|
+
"WHERE subpop = ? ORDER BY seq", (path,))
|
|
454
|
+
objectives = list(dict.fromkeys(o for _, o, _, _ in stats))
|
|
455
|
+
if objective is not None and objective not in objectives:
|
|
456
|
+
raise WorkdirError(f"{run}: no objective '{objective}' (found: "
|
|
457
|
+
f"{', '.join(objectives) or 'none'})")
|
|
458
|
+
if objective is None and len(objectives) >= 2:
|
|
459
|
+
raise WorkdirError(f"{run}: {len(objectives)} objectives ({', '.join(objectives)}): "
|
|
460
|
+
f"the best individual has no total order; name one with "
|
|
461
|
+
f"--objective", 1)
|
|
462
|
+
lines = [f" subpopulation {path}"]
|
|
463
|
+
for name in objectives:
|
|
464
|
+
lines.append(f" performance.{name}: generation, best, mean")
|
|
465
|
+
for g, o, best, mean in stats:
|
|
466
|
+
if o == name:
|
|
467
|
+
lines.append(f" {g:>6} {_number(best):>12} {_number(mean):>12}")
|
|
468
|
+
if not objectives:
|
|
469
|
+
lines.append(" no performance statistics recorded")
|
|
470
|
+
return lines
|
|
471
|
+
name = objective if objective is not None else objectives[0]
|
|
472
|
+
index = objectives.index(name)
|
|
473
|
+
rows = _query(db, "SELECT e.genome_id, e.run_generation, e.objectives FROM evaluations e "
|
|
474
|
+
"WHERE e.subpop = ? AND e.done = 1 AND e.objectives IS NOT NULL AND "
|
|
475
|
+
"e.genome_id IN (SELECT genome_id FROM fronts WHERE scope = 'run' AND "
|
|
476
|
+
"subpop = ?) ORDER BY e.rank", (path, path))
|
|
477
|
+
if not rows:
|
|
478
|
+
rows = _query(db, "SELECT genome_id, run_generation, objectives FROM evaluations "
|
|
479
|
+
"WHERE subpop = ? AND done = 1 AND objectives IS NOT NULL "
|
|
480
|
+
"ORDER BY rank", (path,))
|
|
481
|
+
best: tuple[float, str, object, list] | None = None
|
|
482
|
+
for gid, g, text in rows:
|
|
483
|
+
values = codec.from_json(text)
|
|
484
|
+
if index < len(values) and (best is None or values[index] > best[0]):
|
|
485
|
+
best = (values[index], gid, g, values)
|
|
486
|
+
if best is None:
|
|
487
|
+
lines.append(f" best ({name}): no completed evaluation")
|
|
488
|
+
else:
|
|
489
|
+
shown = ", ".join(_number(v) for v in best[3])
|
|
490
|
+
lines.append(f" best ({name}): genome {best[1]}, generation {best[2]}, "
|
|
491
|
+
f"objectives ({shown})")
|
|
492
|
+
return lines
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def show(path: Path, objective: str | None = None, subpop: str | None = None) -> str:
|
|
496
|
+
"""Summarise a run, or every run of a workdir (design §1.2).
|
|
497
|
+
|
|
498
|
+
Parameters
|
|
499
|
+
----------
|
|
500
|
+
path : Path
|
|
501
|
+
A run directory or a workdir.
|
|
502
|
+
objective : str, optional
|
|
503
|
+
The objective by which the best individual is chosen; mandatory with K >= 2
|
|
504
|
+
([X] ST4).
|
|
505
|
+
subpop : str, optional
|
|
506
|
+
The subpopulation shown; mandatory with several ([X] SQ1).
|
|
507
|
+
|
|
508
|
+
Returns
|
|
509
|
+
-------
|
|
510
|
+
str
|
|
511
|
+
Per run: stop cause (or who runs it), generations, consumption, versions, then per
|
|
512
|
+
subpopulation the performance statistics per generation and the best individual
|
|
513
|
+
(objectives are maximised).
|
|
514
|
+
|
|
515
|
+
Raises
|
|
516
|
+
------
|
|
517
|
+
WorkdirError
|
|
518
|
+
Exit status 1 for a best without total order or several subpopulations unnamed;
|
|
519
|
+
3 for a path that is neither a run directory nor a workdir.
|
|
520
|
+
"""
|
|
521
|
+
path = Path(path)
|
|
522
|
+
if (path / "blueprint.toml").is_file() or (path / "run.sqlite").is_file():
|
|
523
|
+
return "\n".join(_show_run(path, objective, subpop))
|
|
524
|
+
table = _table(path)
|
|
525
|
+
out: list[str] = []
|
|
526
|
+
for row in table.rows(None):
|
|
527
|
+
out.extend(_show_run(path / row.directory, objective, subpop))
|
|
528
|
+
return "\n".join(out) if out else f"{path}: no run"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --- branch ------------------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def branch_protocol(run_dir: Path, generation: int | Literal["end"], count: int,
|
|
535
|
+
root_seed: int | Literal["fresh"]) -> dict:
|
|
536
|
+
"""Build the protocol of ``MATE branch <run>@<generation>`` ([C] R21.3).
|
|
537
|
+
|
|
538
|
+
Parameters
|
|
539
|
+
----------
|
|
540
|
+
run_dir : Path
|
|
541
|
+
The recorded run.
|
|
542
|
+
generation : int or "end"
|
|
543
|
+
count : int
|
|
544
|
+
``[replicates] count``.
|
|
545
|
+
root_seed : int or "fresh"
|
|
546
|
+
``[replicates] root_seed``.
|
|
547
|
+
|
|
548
|
+
Returns
|
|
549
|
+
-------
|
|
550
|
+
dict
|
|
551
|
+
The run's resolved blueprint as a raw protocol table, with ``[start] kind =
|
|
552
|
+
"from_run"``, ``run`` (the absolute run directory) and ``generation``, the
|
|
553
|
+
replicates, and ``[protocol] name = "<run directory name>@<generation>"``. No
|
|
554
|
+
``[execution]`` table: the command line supplies it.
|
|
555
|
+
|
|
556
|
+
Raises
|
|
557
|
+
------
|
|
558
|
+
WorkdirError
|
|
559
|
+
For a directory without ``blueprint.toml``.
|
|
560
|
+
"""
|
|
561
|
+
rundir = RunDirectory(Path(run_dir).resolve())
|
|
562
|
+
try:
|
|
563
|
+
flat, _, _ = rundir.read_blueprint()
|
|
564
|
+
except (OSError, ValueError) as exc:
|
|
565
|
+
raise WorkdirError(f"{run_dir}: not a recorded run ({exc})") from None
|
|
566
|
+
raw = canonical.thaw(canonical.unflatten(flat))
|
|
567
|
+
assert isinstance(raw, dict)
|
|
568
|
+
raw["start"] = {"kind": "from_run", "run": str(rundir.path), "generation": generation}
|
|
569
|
+
raw["replicates"] = {**raw.get("replicates", {}), "count": count, "root_seed": root_seed}
|
|
570
|
+
raw["protocol"] = {"name": f"{rundir.name}@{generation}"}
|
|
571
|
+
return raw
|
MATE/compat/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Interface compatibility: the test between a system's specs and a method's requirements."""
|