MATE-contract 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.
@@ -0,0 +1,52 @@
1
+ """MATE contract: the exchange types and interfaces shared by all MATE modules.
2
+
3
+ A system and a method depend only on this distribution (and NumPy). Every public name is
4
+ imported from here; the submodules are private.
5
+ """
6
+
7
+ from importlib.metadata import version as _distribution_version
8
+
9
+ from ._context import Context, EventSink, Logger
10
+ from ._declarations import Capabilities, Device, Requirements
11
+ from ._domains import Categorical, Domain, Integer, Log, Ordinal, Real
12
+ from ._exchange import Budget, Candidate, Environment, Invalid, Operation, Partial, Performance
13
+ from ._interfaces import Genome, Method, System
14
+ from ._keys import Key
15
+ from ._phenotype import Network, Params, Phenotype, Program
16
+ from ._specs import (
17
+ EnvironmentSpec,
18
+ EvaluationSpec,
19
+ FieldArray,
20
+ NetworkSpec,
21
+ ObservableSpec,
22
+ ParamsSpec,
23
+ PerformanceSpec,
24
+ PhenotypeSpec,
25
+ ProgramSpec,
26
+ Role,
27
+ Setting,
28
+ TraceSpec,
29
+ )
30
+
31
+ __version__ = _distribution_version("MATE-contract")
32
+
33
+ __all__ = [
34
+ # _domains
35
+ "Domain", "Real", "Log", "Integer", "Ordinal", "Categorical",
36
+ # _keys
37
+ "Key",
38
+ # _specs
39
+ "FieldArray", "ParamsSpec", "NetworkSpec", "ProgramSpec", "PhenotypeSpec",
40
+ "EnvironmentSpec", "TraceSpec", "PerformanceSpec", "ObservableSpec",
41
+ "Role", "EvaluationSpec", "Setting",
42
+ # _phenotype
43
+ "Params", "Network", "Program", "Phenotype",
44
+ # _exchange
45
+ "Budget", "Partial", "Performance", "Operation", "Invalid", "Candidate", "Environment",
46
+ # _declarations
47
+ "Requirements", "Capabilities", "Device",
48
+ # _context
49
+ "Context", "Logger", "EventSink",
50
+ # _interfaces
51
+ "System", "Method", "Genome",
52
+ ] # fmt: skip
MATE_contract/_base.py ADDED
@@ -0,0 +1,292 @@
1
+ """Shared helpers of the contract types: closed families, read-only storage, value equality, names.
2
+
3
+ Everything here is private to ``MATE_contract``. The public behaviours it implements are those of
4
+ the contract specification: closed families ([K] §3 "Enforcement", P1b), arrays and mappings
5
+ stored as private read-only copies ([K] §1 "Immutability"), value equality of array-holding
6
+ types ([K] §1.1) and the checks on names ([K] OB3, NS2).
7
+ """
8
+
9
+ import dataclasses
10
+ import numbers
11
+ from collections.abc import Hashable, ItemsView, Iterable, Iterator, KeysView, Mapping, ValuesView
12
+ from typing import Any, ClassVar
13
+
14
+ import numpy as np
15
+ from numpy.typing import ArrayLike, DTypeLike
16
+
17
+ _PACKAGE = "MATE_contract"
18
+
19
+
20
+ def _in_contract(module: str) -> bool:
21
+ """Tell whether a module name belongs to the contract distribution."""
22
+ return module == _PACKAGE or module.startswith(_PACKAGE + ".")
23
+
24
+
25
+ class Closed:
26
+ """Mixin making a class family closed to classes defined outside ``MATE_contract``.
27
+
28
+ A direct subclass of ``Closed`` is the root of a family. Any class of the family, the root or
29
+ a member, can be subclassed only by a class whose module belongs to ``MATE_contract``.
30
+ """
31
+
32
+ def __init_subclass__(cls, **kwargs: Any) -> None:
33
+ super().__init_subclass__(**kwargs)
34
+ root = _family_root(cls)
35
+ if root is not cls and not _in_contract(cls.__module__):
36
+ raise TypeError(
37
+ f"{root.__name__} is a closed family: {cls.__qualname__} cannot subclass it; "
38
+ f"see {_PACKAGE}.{root.__name__}"
39
+ )
40
+
41
+
42
+ def _family_root(cls: type) -> type:
43
+ """Return the root of the closed family ``cls`` belongs to (the class listing ``Closed``)."""
44
+ for klass in cls.__mro__:
45
+ if Closed in klass.__bases__:
46
+ return klass
47
+ return cls
48
+
49
+
50
+ def check_exact_type(value: object, types: tuple[type, ...], where: str) -> None:
51
+ """Raise ``TypeError`` unless ``type(value)`` is exactly one of ``types``.
52
+
53
+ The membership test of closed families is by exact type ([K] §3, P1b), never by
54
+ ``isinstance``.
55
+ """
56
+ if type(value) not in types:
57
+ names = ", ".join(t.__name__ for t in types)
58
+ raise TypeError(f"{where}: {type(value).__name__} is not one of {names}")
59
+
60
+
61
+ def readonly(a: ArrayLike, dtype: DTypeLike | None = None) -> np.ndarray:
62
+ """Return a private read-only copy of ``np.asarray(a, dtype)``.
63
+
64
+ The copy is made even when ``a`` is already an array of that dtype: a caller who later
65
+ writes into its own array (a buffer reused across genomes, say) does not change the object
66
+ that stores the copy ([K] §1 "Immutability").
67
+ """
68
+ arr = np.array(a, dtype=dtype, copy=True)
69
+ arr.flags.writeable = False
70
+ return arr
71
+
72
+
73
+ class ReadOnlyMapping(Mapping[Any, Any]):
74
+ """Read-only mapping holding its own copy of the items it is built from.
75
+
76
+ It takes the place of ``types.MappingProxyType``, which cannot be pickled, so that a module
77
+ keeping a contract mapping (``self.fields = spec.fields``) can still be sent to a worker
78
+ process. It pickles and deep-copies through :meth:`__reduce__`, compares equal to any
79
+ mapping with the same items, is unhashable, prints as a ``dict`` and keeps the read
80
+ methods of ``MappingProxyType``: ``copy()`` and ``|`` return a ``dict``.
81
+ """
82
+
83
+ __slots__ = ("_items",)
84
+
85
+ _items: dict[Any, Any]
86
+
87
+ def __init__(self, items: Mapping[Any, Any] | Iterable[tuple[Any, Any]] = ()) -> None:
88
+ object.__setattr__(self, "_items", dict(items))
89
+
90
+ def __getitem__(self, key: Any) -> Any:
91
+ return self._items[key]
92
+
93
+ def __iter__(self) -> Iterator[Any]:
94
+ return iter(self._items)
95
+
96
+ def __len__(self) -> int:
97
+ return len(self._items)
98
+
99
+ def __contains__(self, key: object) -> bool:
100
+ return key in self._items
101
+
102
+ def __reversed__(self) -> Iterator[Any]:
103
+ return reversed(self._items)
104
+
105
+ def get(self, key: Any, default: Any = None) -> Any:
106
+ return self._items.get(key, default)
107
+
108
+ def keys(self) -> KeysView[Any]:
109
+ return self._items.keys()
110
+
111
+ def values(self) -> ValuesView[Any]:
112
+ return self._items.values()
113
+
114
+ def items(self) -> ItemsView[Any, Any]:
115
+ return self._items.items()
116
+
117
+ def copy(self) -> dict[Any, Any]:
118
+ """Return the items as a new ``dict``."""
119
+ return dict(self._items)
120
+
121
+ def __or__(self, other: object) -> dict[Any, Any]:
122
+ if not isinstance(other, Mapping):
123
+ return NotImplemented
124
+ return {**self._items, **other}
125
+
126
+ def __ror__(self, other: object) -> dict[Any, Any]:
127
+ if not isinstance(other, Mapping):
128
+ return NotImplemented
129
+ return {**other, **self._items}
130
+
131
+ def __eq__(self, other: object) -> bool:
132
+ if isinstance(other, ReadOnlyMapping):
133
+ other = other._items
134
+ elif not isinstance(other, Mapping):
135
+ return NotImplemented
136
+ elif not isinstance(other, dict):
137
+ other = dict(other.items())
138
+ return self._items == other
139
+
140
+ __hash__ = None # type: ignore[assignment]
141
+
142
+ def __repr__(self) -> str:
143
+ return repr(self._items)
144
+
145
+ def __reduce__(self) -> tuple[Any, ...]:
146
+ return (ReadOnlyMapping, (self._items,))
147
+
148
+ def __setattr__(self, name: str, value: object) -> None:
149
+ raise AttributeError(f"{type(self).__name__} is read-only")
150
+
151
+ def __delattr__(self, name: str) -> None:
152
+ raise AttributeError(f"{type(self).__name__} is read-only")
153
+
154
+
155
+ def frozen_mapping(mapping: Mapping[str, Any], where: str) -> ReadOnlyMapping:
156
+ """Return a read-only copy of ``mapping`` whose keys are checked names.
157
+
158
+ Raises
159
+ ------
160
+ TypeError
161
+ If ``mapping`` is not a mapping, or a key is not a ``str``.
162
+ ValueError
163
+ If a key is the empty string.
164
+ """
165
+ if not isinstance(mapping, Mapping):
166
+ raise TypeError(f"{where}: expected a mapping, got {type(mapping).__name__}")
167
+ for key in mapping:
168
+ check_name(key, where)
169
+ return ReadOnlyMapping(mapping)
170
+
171
+
172
+ def check_name(name: object, where: str) -> str:
173
+ """Return ``name`` if it is a non-empty ``str``.
174
+
175
+ Raises
176
+ ------
177
+ TypeError
178
+ If ``name`` is not a ``str``.
179
+ ValueError
180
+ If ``name`` is empty.
181
+ """
182
+ if not isinstance(name, str):
183
+ raise TypeError(f"{where}: a name must be a str, got {type(name).__name__} {name!r}")
184
+ if not name:
185
+ raise ValueError(f"{where}: a name must be a non-empty str")
186
+ return name
187
+
188
+
189
+ def check_names(names: Iterable[object], where: str) -> tuple[str, ...]:
190
+ """Return ``names`` as a tuple of distinct non-empty strings.
191
+
192
+ Raises
193
+ ------
194
+ TypeError
195
+ If ``names`` is a single ``str`` or not iterable, or an element is not a ``str``.
196
+ ValueError
197
+ If an element is empty or repeated.
198
+ """
199
+ result = tuple(check_name(name, where) for name in as_tuple(names, where))
200
+ seen: set[str] = set()
201
+ for name in result:
202
+ if name in seen:
203
+ raise ValueError(
204
+ f"{where}: name '{name}' appears more than once; names must be distinct"
205
+ )
206
+ seen.add(name)
207
+ return result
208
+
209
+
210
+ def as_tuple(values: Iterable[Any], where: str) -> tuple[Any, ...]:
211
+ """Return ``values`` as a tuple; a ``str`` or ``bytes`` is refused, not split into items."""
212
+ if isinstance(values, (str, bytes)):
213
+ raise TypeError(f"{where}: expected a sequence, got {type(values).__name__} {values!r}")
214
+ try:
215
+ return tuple(values)
216
+ except TypeError:
217
+ raise TypeError(f"{where}: expected a sequence, got {type(values).__name__}") from None
218
+
219
+
220
+ def check_hashable(value: object, where: str) -> Hashable:
221
+ """Return ``value`` if it is hashable, else raise ``TypeError``."""
222
+ try:
223
+ hash(value)
224
+ except TypeError:
225
+ raise TypeError(f"{where}: {type(value).__name__} {value!r} is not hashable") from None
226
+ return value
227
+
228
+
229
+ def is_bool(value: object) -> bool:
230
+ """Tell whether ``value`` is a Python or numpy boolean (refused where an int is expected)."""
231
+ return isinstance(value, (bool, np.bool_))
232
+
233
+
234
+ def as_int(value: object, where: str) -> int:
235
+ """Return ``value`` as a Python ``int``; booleans and non-integral types raise ``TypeError``."""
236
+ if is_bool(value) or not isinstance(value, numbers.Integral):
237
+ raise TypeError(f"{where}: expected an int, got {type(value).__name__} {value!r}")
238
+ return int(value)
239
+
240
+
241
+ def values_equal(a: object, b: object) -> bool:
242
+ """Compare two contract values by the value-equality rule of [K] §1.1.
243
+
244
+ Arrays are equal when dtype, shape and bytes are equal (so NaN equals NaN of the same bit
245
+ pattern and ``0.0 != -0.0``); mappings when they have the same keys and equal values; tuples
246
+ and lists elementwise; anything else by ``==``.
247
+ """
248
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
249
+ if not (isinstance(a, np.ndarray) and isinstance(b, np.ndarray)):
250
+ return False
251
+ return (
252
+ a.dtype == b.dtype
253
+ and a.shape == b.shape
254
+ and np.ascontiguousarray(a).tobytes() == np.ascontiguousarray(b).tobytes()
255
+ )
256
+ if isinstance(a, Mapping) or isinstance(b, Mapping):
257
+ if not (isinstance(a, Mapping) and isinstance(b, Mapping)):
258
+ return False
259
+ return a.keys() == b.keys() and all(values_equal(a[k], b[k]) for k in a)
260
+ if isinstance(a, (tuple, list)) or isinstance(b, (tuple, list)):
261
+ if type(a) is not type(b) or len(a) != len(b):
262
+ return False
263
+ return all(values_equal(x, y) for x, y in zip(a, b))
264
+ result = a == b
265
+ return isinstance(result, (bool, np.bool_)) and bool(result)
266
+
267
+
268
+ class ValueEquality:
269
+ """Mixin giving a dataclass the value equality of [K] §1.1 and making it unhashable.
270
+
271
+ Two instances are equal when they have the same exact type and their comparison keys are
272
+ equal under :func:`values_equal`. The key is the tuple of dataclass field values, minus the
273
+ fields named in ``_eq_ignore``; a class overrides :meth:`_eq_key` to normalise a field (for
274
+ instance an absent ``filled`` mask, equal to an all-true mask).
275
+ """
276
+
277
+ _eq_ignore: ClassVar[tuple[str, ...]] = ()
278
+
279
+ def _eq_key(self) -> tuple[Any, ...]:
280
+ """Return the values compared by ``==``."""
281
+ return tuple(
282
+ getattr(self, f.name)
283
+ for f in dataclasses.fields(self) # type: ignore[arg-type]
284
+ if f.compare and f.name not in self._eq_ignore
285
+ )
286
+
287
+ def __eq__(self, other: object) -> bool:
288
+ if type(other) is not type(self):
289
+ return NotImplemented
290
+ return values_equal(self._eq_key(), other._eq_key()) # type: ignore[attr-defined]
291
+
292
+ __hash__ = None # type: ignore[assignment]
@@ -0,0 +1,215 @@
1
+ """The execution context of a module call, and its two output streams ([K] §8.2-8.3).
2
+
3
+ Every module call receives a :class:`Context`: its random stream and key, its logger (messages
4
+ for humans), its event sink (structured events for machines), its configuration block and the
5
+ run's budget. The runtime passes its own subclass, which extends the log path in
6
+ :meth:`Context.spawn` and implements the checkpoint hooks of a system that owns its loop; a
7
+ module author can build a plain :class:`Context` to test a module without the runtime.
8
+ """
9
+
10
+ import dataclasses
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Protocol
15
+
16
+ import numpy as np
17
+
18
+ from ._declarations import Device
19
+ from ._exchange import Budget
20
+ from ._keys import Key
21
+
22
+
23
+ class Logger(Protocol):
24
+ """Journal of messages for humans, filtered by a verbosity level.
25
+
26
+ Any object with these four methods is a logger. A message is formatted as ``msg % args``
27
+ only after the level test, so that a discarded message costs one comparison.
28
+
29
+ Notes
30
+ -----
31
+ Rule LG1.
32
+ """
33
+
34
+ def debug(self, msg: str, /, *args: object) -> None:
35
+ """Log a debugging message.
36
+
37
+ Parameters
38
+ ----------
39
+ msg : str
40
+ Message, a ``%``-format string when ``args`` are given.
41
+ *args : object
42
+ Values formatted into ``msg`` after the level test.
43
+ """
44
+ ...
45
+
46
+ def info(self, msg: str, /, *args: object) -> None:
47
+ """Log an informative message.
48
+
49
+ Parameters
50
+ ----------
51
+ msg : str
52
+ Message, a ``%``-format string when ``args`` are given.
53
+ *args : object
54
+ Values formatted into ``msg`` after the level test.
55
+ """
56
+ ...
57
+
58
+ def warning(self, msg: str, /, *args: object) -> None:
59
+ """Log a warning.
60
+
61
+ Parameters
62
+ ----------
63
+ msg : str
64
+ Message, a ``%``-format string when ``args`` are given.
65
+ *args : object
66
+ Values formatted into ``msg`` after the level test.
67
+ """
68
+ ...
69
+
70
+ def error(self, msg: str, /, *args: object) -> None:
71
+ """Log an error.
72
+
73
+ Parameters
74
+ ----------
75
+ msg : str
76
+ Message, a ``%``-format string when ``args`` are given.
77
+ *args : object
78
+ Values formatted into ``msg`` after the level test.
79
+ """
80
+ ...
81
+
82
+
83
+ class EventSink(Protocol):
84
+ """Receiver of structured events: a name and typed fields.
85
+
86
+ ``ctx.emit(name, value=v)`` publishes a value of a declared series or summary;
87
+ ``ctx.emit(name, **fields)`` for a declared trace publishes one record; any other name is
88
+ a free event. Field values are plain data.
89
+
90
+ Notes
91
+ -----
92
+ Rules LG2, LG3. A method emits the names of its declaration; MATE publishes them under
93
+ ``method.``.
94
+ """
95
+
96
+ def __call__(self, name: str, /, **fields: object) -> None:
97
+ """Publish one event.
98
+
99
+ Parameters
100
+ ----------
101
+ name : str
102
+ Event name, as declared by the module.
103
+ **fields : object
104
+ Event fields, plain data.
105
+ """
106
+ ...
107
+
108
+
109
+ @dataclass
110
+ class Context:
111
+ """What a module call receives from MATE besides its arguments.
112
+
113
+ Parameters
114
+ ----------
115
+ rng : numpy.random.Generator
116
+ Random stream of this component, ``key.rng()``.
117
+ key : Key
118
+ Its key in the seed tree.
119
+ log : Logger
120
+ Journal of messages.
121
+ emit : EventSink
122
+ Events, series and traces.
123
+ config : Mapping
124
+ Its block of the resolved blueprint, read-only.
125
+ workdir : pathlib.Path
126
+ The run's directory, for resolving module-relative resources; a module writes nothing
127
+ there.
128
+ stopping : callable
129
+ ``stopping()`` is true when a cooperative stop is requested.
130
+ device : Device, default Device()
131
+ Device designated by the executor.
132
+ ceiling : Budget or None, default None
133
+ The run's total budget.
134
+ spent : Budget, default Budget(0, 0)
135
+ Consumption up to the last result returned.
136
+
137
+ Notes
138
+ -----
139
+ Rules CT1-CT8. A module never keeps a generator in its state: it draws from the context of
140
+ each call. Outside a run, a plain ``Context`` serves standalone tests of a module:
141
+ ``Context(rng=key.rng(), key=key, log=logger, emit=sink, config={}, workdir=Path.cwd(),
142
+ stopping=lambda: False)``.
143
+ """
144
+
145
+ rng: np.random.Generator
146
+ key: Key
147
+ log: Logger
148
+ emit: EventSink
149
+ config: Mapping[str, Any]
150
+ workdir: Path
151
+ stopping: Callable[[], bool]
152
+ device: Device = Device()
153
+ ceiling: Budget | None = None
154
+ spent: Budget = Budget(0, 0)
155
+
156
+ def spawn(self, label: str) -> Context:
157
+ """Return the context of a child component.
158
+
159
+ Parameters
160
+ ----------
161
+ label : str
162
+ Name of the child; it extends the seed-tree branch (and, in a run, the log path).
163
+
164
+ Returns
165
+ -------
166
+ Context
167
+ A context of the same type with ``key = self.key.child(label)`` and a fresh
168
+ ``rng = key.rng()``; the other fields, ``log`` and ``emit`` included, are shared.
169
+
170
+ Notes
171
+ -----
172
+ Rule CT4. The runtime's subclass also extends the logger's path.
173
+ """
174
+ key = self.key.child(label)
175
+ return dataclasses.replace(self, key=key, rng=key.rng())
176
+
177
+ def checkpoint_due(self, date: int | float) -> bool:
178
+ """Tell a system that owns its loop whether a checkpoint is due at ``date``.
179
+
180
+ Parameters
181
+ ----------
182
+ date : int or float
183
+ Current date, in the system's clock unit.
184
+
185
+ Returns
186
+ -------
187
+ bool
188
+ Always ``False`` outside a run.
189
+
190
+ Notes
191
+ -----
192
+ Rule CT7.
193
+ """
194
+ return False
195
+
196
+ def checkpoint(self, date: int | float, state: bytes) -> None:
197
+ """Hand MATE a checkpoint of a system that owns its loop.
198
+
199
+ Parameters
200
+ ----------
201
+ date : int or float
202
+ Date of the state, in the system's clock unit.
203
+ state : bytes
204
+ The system's serialised state, in its own format.
205
+
206
+ Raises
207
+ ------
208
+ RuntimeError
209
+ Always outside a run.
210
+
211
+ Notes
212
+ -----
213
+ Rule CT7.
214
+ """
215
+ raise RuntimeError("no checkpoint outside a run")