portlearn 0.0.1.dev0__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.
portlearn/leakage.py ADDED
@@ -0,0 +1,290 @@
1
+ """Leakage battery for the contract error classes.
2
+
3
+ This module implements the invariant battery layer of the public
4
+ contract: a battery is a list of :class:`LeakageCase` scenarios — each a
5
+ named finance-language leak attempt paired with the single
6
+ contract error class expected to block it — executed totally by
7
+ :func:`run_leakage_cases`, which reports every outcome as a
8
+ :class:`LeakageFinding` and never lets an attempted leak fail silently.
9
+
10
+ Design laws honoured here:
11
+
12
+ * ``FROZEN_CONTRACT_ERRORS`` is exactly the six module-qualified
13
+ contract error classes — a battery can never expect a foreign error
14
+ class, and every expected error must be one of these six.
15
+ * Admission is fail-closed: a case with a blank or non-string name, a
16
+ non-callable attempt, or an expected error outside the supported
17
+ contract error classes is rejected before anything runs.
18
+ * Totality: every admitted case produces exactly one finding, whatever
19
+ it does — a wrong error class reports ``blocked is False`` with
20
+ ``detail == "wrong error class"``, and a leak that raises nothing at
21
+ all reports ``error_type is None`` with
22
+ ``detail == "leak was NOT blocked"``.
23
+ * The summary derives only from the findings:
24
+ ``"{blocked}/{total} attempted leaks blocked"``.
25
+
26
+ The module imports stdlib only at module level plus the contract error
27
+ classes by import (aware-instant validation remains implemented solely
28
+ in ``portlearn.timing``; nothing here re-implements it). Importing this
29
+ module performs no I/O and executes no case.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from collections.abc import Callable, Sequence
35
+ from typing import Any
36
+
37
+ from portlearn.observations import (
38
+ AmbiguousObservationError,
39
+ FeatureLineageError,
40
+ )
41
+ from portlearn.timing import (
42
+ FutureInformationError,
43
+ InvalidChronologyError,
44
+ MissingAvailabilityError,
45
+ NaiveTimestampError,
46
+ )
47
+
48
+ __all__ = [ # noqa: RUF022 — frozen order (types, function, constant)
49
+ "LeakageCase",
50
+ "LeakageFinding",
51
+ "LeakageReport",
52
+ "run_leakage_cases",
53
+ "FROZEN_CONTRACT_ERRORS",
54
+ ]
55
+
56
+
57
+ #: The exact set of contract error classes a battery case may
58
+ #: expect — the six module-qualified contract error classes. A tuple,
59
+ #: so the order is itself fixed and import-stable.
60
+ FROZEN_CONTRACT_ERRORS: tuple[type[Exception], ...] = (
61
+ FutureInformationError,
62
+ InvalidChronologyError,
63
+ MissingAvailabilityError,
64
+ NaiveTimestampError,
65
+ AmbiguousObservationError,
66
+ FeatureLineageError,
67
+ )
68
+
69
+ _FROZEN_ERROR_SET: frozenset[type[Exception]] = frozenset(FROZEN_CONTRACT_ERRORS)
70
+
71
+ #: Detail pinned for a case whose attempt raised an error of the wrong
72
+ #: class — the leak was blocked, but by the wrong law.
73
+ _WRONG_ERROR_CLASS_DETAIL = "wrong error class"
74
+
75
+ #: Detail pinned for a case whose attempt raised nothing at all — the
76
+ #: attempted leak was not blocked by any contract.
77
+ _UNBLOCKED_LEAK_DETAIL = "leak was NOT blocked"
78
+
79
+ #: Detail pinned for a case blocked by exactly its expected error.
80
+ _BLOCKED_DETAIL = "blocked"
81
+
82
+
83
+ class LeakageCase:
84
+ """One named leak attempt paired with its expected contract error.
85
+
86
+ The attempt is a zero-argument callable: running it is the leak
87
+ scenario (for example, admitting a forecast whose feature vintage
88
+ postdates the decision instant). ``expected_error`` must be exactly
89
+ one of :data:`FROZEN_CONTRACT_ERRORS` — never a foreign class — and
90
+ admission is fail-closed with ``ValueError`` for a blank or
91
+ non-string ``name``, a non-callable ``attempt``, or an expected
92
+ error outside the supported contract error classes (including
93
+ non-class inputs).
94
+ """
95
+
96
+ __slots__ = ("attempt", "expected_error", "name")
97
+
98
+ def __init__(
99
+ self,
100
+ name: str,
101
+ attempt: Callable[[], Any],
102
+ expected_error: type[Exception],
103
+ ) -> None:
104
+ if not isinstance(name, str) or not name.strip():
105
+ raise ValueError(
106
+ "LeakageCase.name must be a non-blank string identifying "
107
+ f"the leak scenario; got {name!r}."
108
+ )
109
+ if not callable(attempt):
110
+ raise ValueError( # noqa: TRY004 — admission rejects with ValueError
111
+ "LeakageCase.attempt must be callable — the zero-argument "
112
+ "leak scenario to execute; got "
113
+ f"{type(attempt).__name__}: {attempt!r}."
114
+ )
115
+ if not isinstance(expected_error, type) or not issubclass(
116
+ expected_error, Exception
117
+ ):
118
+ raise ValueError( # noqa: TRY004 — admission rejects with ValueError
119
+ "expected_error must be one of PortLearn's supported contract "
120
+ "error types (portlearn.leakage.FROZEN_CONTRACT_ERRORS); "
121
+ f"got {expected_error!r}."
122
+ )
123
+ if expected_error not in _FROZEN_ERROR_SET:
124
+ raise ValueError(
125
+ "expected_error must be one of PortLearn's supported contract "
126
+ "error types (portlearn.leakage.FROZEN_CONTRACT_ERRORS); "
127
+ f"{expected_error.__module__}.{expected_error.__qualname__} "
128
+ "is not one of them."
129
+ )
130
+ object.__setattr__(self, "name", name)
131
+ object.__setattr__(self, "attempt", attempt)
132
+ object.__setattr__(self, "expected_error", expected_error)
133
+
134
+ def __setattr__(self, key: str, value: Any) -> None:
135
+ raise AttributeError(
136
+ f"LeakageCase is immutable: field {key!r} cannot be reassigned."
137
+ )
138
+
139
+ def __delattr__(self, key: str) -> None:
140
+ raise AttributeError(
141
+ f"LeakageCase is immutable: field {key!r} cannot be deleted."
142
+ )
143
+
144
+ def __repr__(self) -> str:
145
+ return (
146
+ f"LeakageCase(name={self.name!r}, "
147
+ f"expected_error={self.expected_error.__qualname__})"
148
+ )
149
+
150
+
151
+ class LeakageFinding:
152
+ """The total outcome of executing one :class:`LeakageCase`.
153
+
154
+ ``blocked`` is true only when the attempt raised exactly the case's
155
+ expected error class; ``error_type`` is the class that was actually
156
+ raised (``None`` when nothing was raised); ``detail`` is one of the
157
+ three pinned outcome phrases, so reports carry laws, never message
158
+ tails.
159
+ """
160
+
161
+ __slots__ = ("blocked", "case", "detail", "error_type")
162
+
163
+ def __init__(
164
+ self,
165
+ case: LeakageCase,
166
+ blocked: bool,
167
+ error_type: type[Exception] | None,
168
+ detail: str,
169
+ ) -> None:
170
+ object.__setattr__(self, "case", case)
171
+ object.__setattr__(self, "blocked", blocked)
172
+ object.__setattr__(self, "error_type", error_type)
173
+ object.__setattr__(self, "detail", detail)
174
+
175
+ def __setattr__(self, key: str, value: Any) -> None:
176
+ raise AttributeError(
177
+ f"LeakageFinding is immutable: field {key!r} cannot be reassigned."
178
+ )
179
+
180
+ def __delattr__(self, key: str) -> None:
181
+ raise AttributeError(
182
+ f"LeakageFinding is immutable: field {key!r} cannot be deleted."
183
+ )
184
+
185
+ def __repr__(self) -> str:
186
+ error_name = (
187
+ self.error_type.__qualname__
188
+ if self.error_type is not None
189
+ else "None"
190
+ )
191
+ return (
192
+ f"LeakageFinding(case={self.case.name!r}, "
193
+ f"blocked={self.blocked!r}, error_type={error_name}, "
194
+ f"detail={self.detail!r})"
195
+ )
196
+
197
+
198
+ class LeakageReport:
199
+ """Findings from one total battery run, in admission order.
200
+
201
+ ``all_blocked`` and ``summary`` derive only from ``findings``; the
202
+ empty report is admitted and summarises as ``"0/0 attempted leaks
203
+ blocked"`` with ``all_blocked is True`` (vacuous truth: nothing
204
+ leaked).
205
+ """
206
+
207
+ __slots__ = ("findings",)
208
+
209
+ def __init__(self, findings: Sequence[LeakageFinding]) -> None:
210
+ object.__setattr__(self, "findings", tuple(findings))
211
+
212
+ def __setattr__(self, key: str, value: Any) -> None:
213
+ raise AttributeError(
214
+ f"LeakageReport is immutable: field {key!r} cannot be reassigned."
215
+ )
216
+
217
+ def __delattr__(self, key: str) -> None:
218
+ raise AttributeError(
219
+ f"LeakageReport is immutable: field {key!r} cannot be deleted."
220
+ )
221
+
222
+ @property
223
+ def all_blocked(self) -> bool:
224
+ """True when every finding reports its leak as blocked."""
225
+ return all(finding.blocked for finding in self.findings)
226
+
227
+ @property
228
+ def summary(self) -> str:
229
+ """``"{blocked}/{total} attempted leaks blocked"``."""
230
+ blocked = sum(1 for finding in self.findings if finding.blocked)
231
+ return f"{blocked}/{len(self.findings)} attempted leaks blocked"
232
+
233
+ def __repr__(self) -> str:
234
+ return f"LeakageReport(summary={self.summary!r})"
235
+
236
+
237
+ def run_leakage_cases(cases: Sequence[LeakageCase]) -> LeakageReport:
238
+ """Execute ``cases`` totally, reporting every outcome.
239
+
240
+ The battery is fail-closed on admission — it must be a non-empty
241
+ sequence of :class:`LeakageCase` (an empty battery proves nothing
242
+ and is rejected with ``ValueError``). Every admitted case then
243
+ produces exactly one finding:
244
+
245
+ * the attempt raises the case's expected error class → blocked,
246
+ ``error_type`` = the expected class, ``detail == "blocked"``;
247
+ * the attempt raises a different exception class → not blocked,
248
+ ``error_type`` = the actually-raised class,
249
+ ``detail == "wrong error class"``;
250
+ * the attempt raises nothing → not blocked, ``error_type is None``,
251
+ ``detail == "leak was NOT blocked"``.
252
+
253
+ A raised exception is never allowed to abort the run: outcomes are
254
+ converted to findings, so an attempted leak can never disappear
255
+ silently.
256
+ """
257
+ if not cases:
258
+ raise ValueError(
259
+ "run_leakage_cases requires a non-empty sequence of LeakageCase "
260
+ "scenarios — an empty battery executes nothing and proves "
261
+ "nothing."
262
+ )
263
+ findings: list[LeakageFinding] = []
264
+ for case in cases:
265
+ if not isinstance(case, LeakageCase):
266
+ raise ValueError( # noqa: TRY004 — admission is uniformly ValueError
267
+ "run_leakage_cases requires LeakageCase scenarios; got "
268
+ f"{type(case).__name__}: {case!r}."
269
+ )
270
+ try:
271
+ case.attempt()
272
+ except Exception as raised: # noqa: BLE001 — totality is the law
273
+ actual = type(raised)
274
+ if actual is case.expected_error:
275
+ findings.append(
276
+ LeakageFinding(
277
+ case, True, actual, _BLOCKED_DETAIL
278
+ )
279
+ )
280
+ else:
281
+ findings.append(
282
+ LeakageFinding(
283
+ case, False, actual, _WRONG_ERROR_CLASS_DETAIL
284
+ )
285
+ )
286
+ else:
287
+ findings.append(
288
+ LeakageFinding(case, False, None, _UNBLOCKED_LEAK_DETAIL)
289
+ )
290
+ return LeakageReport(findings)
portlearn/manifest.py ADDED
@@ -0,0 +1,298 @@
1
+ """Run manifest: minimal, aware-only, canonical-JSON run identity.
2
+
3
+ This module implements the run-manifest layer of the public
4
+ contract: every synthetic wiring run records one :class:`RunManifest`
5
+ carrying exactly six fields — ``run_id``, ``created_at``,
6
+ ``python_version``, ``package_version``, ``dependency_pins``,
7
+ ``commands`` — and nothing else. The schema is deliberately minimal:
8
+ **no ``seed`` field exists** (seeds live in run parameters,
9
+ not in run identity), so a manifest can never silently assert
10
+ randomness control it does not provide.
11
+
12
+ Design laws honoured here:
13
+
14
+ * **Identity is fail-closed** — blank ``run_id`` /
15
+ ``python_version`` / ``package_version``, an empty or blank-valued
16
+ ``dependency_pins`` mapping, or an empty ``commands`` sequence
17
+ rejects with ``ValueError``.
18
+ * **Aware-ness law** — ``created_at`` must be a timezone-aware
19
+ instant; naive datetimes and calendar dates reject with
20
+ ``NaiveTimestampError`` (reused by import from ``portlearn.timing``,
21
+ where it is solely implemented).
22
+ * **Canonical serialization** — ``to_json()`` emits deterministic
23
+ canonical JSON: sorted keys, the compact separators ``","`` and
24
+ ``":"``, and ISO-8601 datetime text carrying the original offset.
25
+ ``from_json`` inverts it exactly and rejects offset-free serialized
26
+ instants with ``NaiveTimestampError``.
27
+ * **Offset preservation** — the round trip preserves the original
28
+ UTC offset exactly (a ``+10:00`` instant stays ``+10:00``), because
29
+ the offset is part of the recorded fact.
30
+ * **Equality** — two manifests are equal iff their canonical JSON
31
+ bytes are equal, so manifest equality is byte-stable identity.
32
+
33
+ The module imports the standard library plus ``NaiveTimestampError``
34
+ and the aware-instant validator from ``portlearn.timing`` by import;
35
+ importing it performs no I/O and writes nothing.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ from collections.abc import Mapping, Sequence
42
+ from datetime import datetime
43
+ from types import MappingProxyType
44
+ from typing import Any, ClassVar
45
+
46
+ from portlearn.timing import NaiveTimestampError, _require_aware_instant
47
+
48
+ __all__ = ["RunManifest"]
49
+
50
+
51
+ def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
52
+ """``object_pairs_hook`` that fails closed on duplicate object keys.
53
+
54
+ ``json.loads`` silently keeps the last value of a duplicated key;
55
+ a manifest must never accept an ambiguous payload, so any
56
+ duplicate — at the top level or in any nested object — rejects.
57
+ Raises ``_DuplicateKeyError`` (a ``ValueError``) naming the key.
58
+ """
59
+
60
+ result: dict[str, Any] = {}
61
+ for key, value in pairs:
62
+ if key in result:
63
+ raise _DuplicateKeyError(
64
+ f"duplicate JSON object key {key!r} — the payload is "
65
+ "ambiguous and no last-write-wins reading is allowed"
66
+ )
67
+ result[key] = value
68
+ return result
69
+
70
+
71
+ class _DuplicateKeyError(ValueError):
72
+ """Internal marker: the JSON text contained a duplicate object key."""
73
+
74
+
75
+ class RunManifest:
76
+ """Minimal six-field run identity with canonical JSON serialization.
77
+
78
+ The public schema is exactly the six fields — there is no ``seed``
79
+ field: seeds live in run parameters, not run identity.
80
+
81
+ ``created_at`` keeps its original UTC offset through every
82
+ serialization round trip; equality is canonical-JSON byte equality.
83
+ """
84
+
85
+ __slots__ = (
86
+ "commands",
87
+ "created_at",
88
+ "dependency_pins",
89
+ "package_version",
90
+ "python_version",
91
+ "run_id",
92
+ )
93
+
94
+ #: The exact public schema keys, in canonical (sorted) order.
95
+ _SCHEMA_KEYS: ClassVar[tuple[str, ...]] = (
96
+ "commands",
97
+ "created_at",
98
+ "dependency_pins",
99
+ "package_version",
100
+ "python_version",
101
+ "run_id",
102
+ )
103
+
104
+ def __init__(
105
+ self,
106
+ run_id: str,
107
+ created_at: datetime,
108
+ python_version: str,
109
+ package_version: str,
110
+ dependency_pins: Mapping[str, str],
111
+ commands: Sequence[str],
112
+ ) -> None:
113
+ if not isinstance(run_id, str) or not run_id.strip():
114
+ raise ValueError(
115
+ "RunManifest.run_id must be a non-blank string identifying "
116
+ f"the run; got {run_id!r}."
117
+ )
118
+ if not isinstance(python_version, str) or not python_version.strip():
119
+ raise ValueError(
120
+ "RunManifest.python_version must be a non-blank string; "
121
+ f"got {python_version!r}."
122
+ )
123
+ if not isinstance(package_version, str) or not package_version.strip():
124
+ raise ValueError(
125
+ "RunManifest.package_version must be a non-blank string; "
126
+ f"got {package_version!r}."
127
+ )
128
+ # Aware-ness law: reuse the single timing-owned validator by
129
+ # import — never re-implement it here.
130
+ aware_created_at = _require_aware_instant(
131
+ created_at, "RunManifest.created_at"
132
+ )
133
+ if not isinstance(dependency_pins, Mapping):
134
+ raise ValueError( # noqa: TRY004 — admission rejects with ValueError
135
+ "RunManifest.dependency_pins must be a mapping of "
136
+ "distribution name to version pin; got "
137
+ f"{type(dependency_pins).__name__}: {dependency_pins!r}."
138
+ )
139
+ if not dependency_pins:
140
+ raise ValueError(
141
+ "RunManifest.dependency_pins must contain at least one "
142
+ "(distribution, pin) pair — a manifest with no dependency "
143
+ "provenance is not fail-closed."
144
+ )
145
+ for distribution, pin in dependency_pins.items():
146
+ if not isinstance(distribution, str) or not distribution.strip():
147
+ raise ValueError(
148
+ "RunManifest.dependency_pins keys must be non-blank "
149
+ f"distribution names; got {distribution!r}."
150
+ )
151
+ if not isinstance(pin, str) or not pin.strip():
152
+ raise ValueError(
153
+ "RunManifest.dependency_pins values must be non-blank "
154
+ f"version pins; got {pin!r} for {distribution!r}."
155
+ )
156
+ if isinstance(commands, (str, bytes)) or not isinstance(
157
+ commands, Sequence
158
+ ):
159
+ raise ValueError( # noqa: TRY004 — admission rejects with ValueError
160
+ "RunManifest.commands must be a sequence of command "
161
+ "strings; got "
162
+ f"{type(commands).__name__}: {commands!r}."
163
+ )
164
+ commands_tuple = tuple(commands)
165
+ if not commands_tuple:
166
+ raise ValueError(
167
+ "RunManifest.commands must contain at least one command."
168
+ )
169
+ for command in commands_tuple:
170
+ if not isinstance(command, str) or not command.strip():
171
+ raise ValueError(
172
+ "RunManifest.commands entries must be non-blank "
173
+ f"strings; got {command!r}."
174
+ )
175
+ object.__setattr__(self, "run_id", run_id)
176
+ object.__setattr__(self, "created_at", aware_created_at)
177
+ object.__setattr__(self, "python_version", python_version)
178
+ object.__setattr__(self, "package_version", package_version)
179
+ # Genuine immutability: the pins are stored as a read-only
180
+ # mapping over a private copied dict — never the caller's
181
+ # mapping and never a mutable dict — so no in-place mutation
182
+ # can change the manifest, its hash, or its serialization.
183
+ object.__setattr__(
184
+ self, "dependency_pins", MappingProxyType(dict(dependency_pins))
185
+ )
186
+ object.__setattr__(self, "commands", commands_tuple)
187
+
188
+ def __setattr__(self, key: str, value: Any) -> None:
189
+ raise AttributeError(
190
+ f"RunManifest is immutable: field {key!r} cannot be reassigned."
191
+ )
192
+
193
+ def __delattr__(self, key: str) -> None:
194
+ raise AttributeError(
195
+ f"RunManifest is immutable: field {key!r} cannot be deleted."
196
+ )
197
+
198
+ def to_json(self) -> str:
199
+ """Canonical JSON text: sorted keys, compact separators, the
200
+ original UTC offset preserved in the ISO-8601 instant."""
201
+ payload: dict[str, Any] = {
202
+ "run_id": self.run_id,
203
+ "created_at": self.created_at.isoformat(),
204
+ "python_version": self.python_version,
205
+ "package_version": self.package_version,
206
+ "dependency_pins": dict(self.dependency_pins),
207
+ "commands": list(self.commands),
208
+ }
209
+ return json.dumps(
210
+ payload,
211
+ sort_keys=True,
212
+ separators=(",", ":"),
213
+ ensure_ascii=True,
214
+ )
215
+
216
+ @classmethod
217
+ def from_json(cls, text: str) -> RunManifest:
218
+ """Reconstruct a manifest from canonical JSON text.
219
+
220
+ Malformed JSON, a non-object payload, a payload whose keys
221
+ are not exactly the six schema keys, or any duplicated object
222
+ key (top-level or nested) rejects with ``ValueError`` —
223
+ duplicate keys never resolve last-write-wins; a serialized
224
+ ``created_at`` that carries no UTC offset rejects with
225
+ ``NaiveTimestampError`` — never is a default timezone
226
+ silently assumed.
227
+ """
228
+ if not isinstance(text, str):
229
+ raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
230
+ "RunManifest.from_json requires JSON text; got "
231
+ f"{type(text).__name__}: {text!r}."
232
+ )
233
+ try:
234
+ payload = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
235
+ except _DuplicateKeyError as duplicate:
236
+ raise ValueError(
237
+ "RunManifest.from_json rejects JSON text with duplicate "
238
+ f"object keys — fail-closed: {duplicate}."
239
+ ) from None
240
+ except ValueError as malformed:
241
+ raise ValueError(
242
+ "RunManifest.from_json requires well-formed JSON text; "
243
+ f"parsing failed: {malformed!r}."
244
+ ) from None
245
+ if not isinstance(payload, dict):
246
+ raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
247
+ "RunManifest.from_json requires a JSON object with exactly "
248
+ f"the six schema keys; got {type(payload).__name__}."
249
+ )
250
+ if set(payload) != set(cls._SCHEMA_KEYS):
251
+ raise ValueError(
252
+ "RunManifest.from_json requires exactly the six schema "
253
+ f"keys {list(cls._SCHEMA_KEYS)}; got {sorted(payload)}."
254
+ )
255
+ raw_created_at = payload["created_at"]
256
+ if not isinstance(raw_created_at, str):
257
+ raise ValueError( # noqa: TRY004 — parsing rejects with ValueError
258
+ "RunManifest.from_json requires created_at as ISO-8601 "
259
+ f"text; got {raw_created_at!r}."
260
+ )
261
+ try:
262
+ parsed_created_at = datetime.fromisoformat(raw_created_at)
263
+ except ValueError as unparsable:
264
+ raise ValueError(
265
+ "RunManifest.from_json requires created_at as ISO-8601 "
266
+ f"text; got {raw_created_at!r} ({unparsable!r})."
267
+ ) from None
268
+ if parsed_created_at.tzinfo is None or (
269
+ parsed_created_at.utcoffset() is None
270
+ ):
271
+ raise NaiveTimestampError(
272
+ "RunManifest.from_json: created_at is a naive timestamp — "
273
+ "it carries no explicit UTC offset, so it cannot be placed "
274
+ f"on the single timeline; got {raw_created_at!r}. Supply "
275
+ "an offset-carrying ISO-8601 instant instead."
276
+ )
277
+ return cls(
278
+ run_id=payload["run_id"],
279
+ created_at=parsed_created_at,
280
+ python_version=payload["python_version"],
281
+ package_version=payload["package_version"],
282
+ dependency_pins=payload["dependency_pins"],
283
+ commands=payload["commands"],
284
+ )
285
+
286
+ def __eq__(self, other: object) -> bool:
287
+ if not isinstance(other, RunManifest):
288
+ return NotImplemented
289
+ return self.to_json() == other.to_json()
290
+
291
+ def __hash__(self) -> int:
292
+ return hash(self.to_json())
293
+
294
+ def __repr__(self) -> str:
295
+ created = self.created_at.isoformat()
296
+ return (
297
+ f"RunManifest(run_id={self.run_id!r}, created_at={created!r})"
298
+ )