robustmargin 0.1.1__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.
- robustmargin/__init__.py +90 -0
- robustmargin/censoring.py +652 -0
- robustmargin/empirical.py +348 -0
- robustmargin/intervals.py +785 -0
- robustmargin/models.py +2119 -0
- robustmargin/py.typed +0 -0
- robustmargin/ratio.py +941 -0
- robustmargin/report.py +650 -0
- robustmargin/schema/scorecard-v1.json +777 -0
- robustmargin/testing.py +465 -0
- robustmargin/threshold.py +631 -0
- robustmargin/types.py +541 -0
- robustmargin/validation.py +722 -0
- robustmargin-0.1.1.dist-info/METADATA +281 -0
- robustmargin-0.1.1.dist-info/RECORD +18 -0
- robustmargin-0.1.1.dist-info/WHEEL +5 -0
- robustmargin-0.1.1.dist-info/licenses/LICENSE +201 -0
- robustmargin-0.1.1.dist-info/top_level.txt +1 -0
robustmargin/report.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"""Serialisation between robustmargin's result dataclasses and JSON: :func:`to_dict`,
|
|
2
|
+
:func:`from_dict`, :class:`Scorecard`, and :func:`load_schema`.
|
|
3
|
+
|
|
4
|
+
``to_dict`` walks a result object and produces a plain, ``json.dumps(..., allow_nan=False)``-safe
|
|
5
|
+
structure: enums become their string values, tuples become lists, and :class:`Scorecard`,
|
|
6
|
+
:class:`~robustmargin.GridBreakPoint`, :class:`~robustmargin.Fit`, :class:`~robustmargin.Threshold`
|
|
7
|
+
and :class:`~robustmargin.Ratio` are tagged with a ``"type"`` key naming the class, at every
|
|
8
|
+
nesting depth (a ``Fit`` embedded in a ``Threshold`` is tagged too). The five helper types
|
|
9
|
+
(:class:`~robustmargin.Axis`, :class:`~robustmargin.Definition`, :class:`~robustmargin.Cell`,
|
|
10
|
+
:class:`~robustmargin.Parameter`, :class:`~robustmargin.Covariance`) are never tagged. A
|
|
11
|
+
non-finite float anywhere raises ``ValueError``, and a ``provenance`` value outside plain JSON (a
|
|
12
|
+
tuple or a set, for example) raises ``TypeError`` -- no NaN and nothing un-JSON-encodable ever
|
|
13
|
+
reaches ``json.dumps``.
|
|
14
|
+
|
|
15
|
+
``from_dict`` is the inverse: it dispatches on ``"type"``, rebuilds nested objects, and raises
|
|
16
|
+
``ValueError`` naming the problem. At each level, ``schema_version`` (when the type has one) is
|
|
17
|
+
checked before an unknown field is: an unrecognised ``schema_version`` is reported on its own
|
|
18
|
+
even if the same dict also carries an unknown field. An unknown field found anywhere inside a
|
|
19
|
+
``Scorecard`` -- at the ``Scorecard``'s own level or at any nesting depth beneath it -- names
|
|
20
|
+
the field, the enclosing card's ``library_version``, and this reader's version; a bare
|
|
21
|
+
``Fit``/``Threshold``/``Ratio``/``GridBreakPoint`` read on its own (not nested in a
|
|
22
|
+
``Scorecard``) gets a plainer message with no version context, since there is no card to name.
|
|
23
|
+
A key that a tagged type's schema ``required`` list requires but the dict omits raises; a key
|
|
24
|
+
the schema does not require, if omitted, is filled from the dataclass's own default. This
|
|
25
|
+
required-key set is read from the packaged schema (:data:`load_schema`), not duplicated as a
|
|
26
|
+
literal list, so it cannot drift from ``schema/scorecard-v1.json``.
|
|
27
|
+
|
|
28
|
+
``SCHEMA_VERSION`` is ``"1"``, and ``from_dict`` reads ``"1"`` only. A ``schema_version`` of
|
|
29
|
+
anything else, on any typed object, raises a ``ValueError`` that names the version found and the
|
|
30
|
+
version this reader reads. Only the ``Scorecard`` root requires the tag; a nested or bare result
|
|
31
|
+
that omits it is read as ``"1"``. A freshly-constructed result carries ``"1"``.
|
|
32
|
+
|
|
33
|
+
``load_schema`` reads the packaged ``schema/scorecard-v{version}.json`` with
|
|
34
|
+
:mod:`importlib.resources`; only ``scorecard-v1.json`` is packaged. Nothing under ``src/``
|
|
35
|
+
imports ``jsonschema``; it stays a dev-only dependency used by consumers' own validators and by
|
|
36
|
+
this package's own tests.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import dataclasses
|
|
42
|
+
import datetime as dt
|
|
43
|
+
import json
|
|
44
|
+
import math
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from functools import lru_cache
|
|
48
|
+
from importlib import resources
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from robustmargin.empirical import BaselineRate, GridBreakPoint
|
|
52
|
+
from robustmargin.models import Covariance, Fit, Parameter
|
|
53
|
+
from robustmargin.ratio import Ratio
|
|
54
|
+
from robustmargin.threshold import Threshold
|
|
55
|
+
from robustmargin.types import Axis, Cell, Censoring, Definition, IntervalShape, JSONValue, Status
|
|
56
|
+
from robustmargin.validation import AdjacentPair, Diagnostics, MonotonicityCheck
|
|
57
|
+
|
|
58
|
+
__all__ = ["SCHEMA_VERSION", "Scorecard", "from_dict", "load_schema", "to_dict"]
|
|
59
|
+
|
|
60
|
+
SCHEMA_VERSION = "1"
|
|
61
|
+
|
|
62
|
+
# `from_dict` reads schema "1" only.
|
|
63
|
+
_SUPPORTED_SCHEMA_VERSIONS: frozenset[str] = frozenset({"1"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _default_created_at() -> str:
|
|
67
|
+
return dt.datetime.now(dt.timezone.utc).isoformat()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _default_library_version() -> str:
|
|
71
|
+
# Imported lazily, not at module level, to avoid a circular import between this module and
|
|
72
|
+
# robustmargin/__init__.py (which imports result types report.py also imports).
|
|
73
|
+
import robustmargin
|
|
74
|
+
|
|
75
|
+
return robustmargin.__version__
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, kw_only=True)
|
|
79
|
+
class Scorecard:
|
|
80
|
+
"""A collection of results, plus the metadata that lets a saved file be read back years
|
|
81
|
+
later: which robustmargin version produced it, when, and under what schema.
|
|
82
|
+
|
|
83
|
+
Attributes
|
|
84
|
+
----------
|
|
85
|
+
results
|
|
86
|
+
A tuple of results, each a :class:`~robustmargin.GridBreakPoint`,
|
|
87
|
+
:class:`~robustmargin.Fit`, :class:`~robustmargin.Threshold` or
|
|
88
|
+
:class:`~robustmargin.Ratio`. Any other element type raises ``TypeError``.
|
|
89
|
+
provenance
|
|
90
|
+
An opaque mapping the caller may attach to record where the run came from. robustmargin
|
|
91
|
+
stores it and never interprets it. Empty by default.
|
|
92
|
+
created_at
|
|
93
|
+
An ISO-8601 datetime string carrying a timezone. Defaults to now, in UTC. A given value
|
|
94
|
+
that :func:`datetime.datetime.fromisoformat` cannot parse, or that carries no timezone,
|
|
95
|
+
raises ``ValueError``.
|
|
96
|
+
library_version
|
|
97
|
+
The version of robustmargin that produced this scorecard. Defaults to
|
|
98
|
+
:data:`robustmargin.__version__` at construction time.
|
|
99
|
+
schema_version
|
|
100
|
+
The serialised-result schema version this object belongs to. Defaults to ``"1"``, the
|
|
101
|
+
only version ``from_dict`` reads; any other version is rejected.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
results: tuple[GridBreakPoint | Fit | Threshold | Ratio, ...]
|
|
105
|
+
provenance: Mapping[str, JSONValue] = field(default_factory=dict)
|
|
106
|
+
created_at: str = field(default_factory=_default_created_at)
|
|
107
|
+
library_version: str = field(default_factory=_default_library_version)
|
|
108
|
+
schema_version: str = "1"
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
object.__setattr__(self, "results", tuple(self.results))
|
|
112
|
+
for item in self.results:
|
|
113
|
+
if not isinstance(item, (GridBreakPoint, Fit, Threshold, Ratio)):
|
|
114
|
+
raise TypeError(
|
|
115
|
+
"Scorecard.results entries must each be a GridBreakPoint, Fit, Threshold "
|
|
116
|
+
f"or Ratio, got {type(item).__name__}"
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
parsed = dt.datetime.fromisoformat(self.created_at)
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Scorecard.created_at must be an ISO-8601 datetime string, got {self.created_at!r}"
|
|
123
|
+
) from exc
|
|
124
|
+
if parsed.tzinfo is None:
|
|
125
|
+
raise ValueError(f"Scorecard.created_at must carry a timezone, got {self.created_at!r}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_TAGGED_CLASSES: tuple[type, ...] = (Scorecard, GridBreakPoint, Fit, Threshold, Ratio)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------------------------
|
|
132
|
+
# to_dict
|
|
133
|
+
# --------------------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _encode_json_value(value: object) -> JSONValue:
|
|
137
|
+
"""Encode a value that must already be plain JSON (a ``provenance`` entry): only
|
|
138
|
+
``None``/``bool``/``int``/``float``/``str``/``list``/``dict`` are accepted. A tuple, a set,
|
|
139
|
+
or any other type raises ``TypeError``; a non-finite float raises ``ValueError``.
|
|
140
|
+
"""
|
|
141
|
+
if value is None:
|
|
142
|
+
return None
|
|
143
|
+
if isinstance(value, bool):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, float):
|
|
146
|
+
if not math.isfinite(value):
|
|
147
|
+
raise ValueError(f"non-finite float in provenance: {value!r}")
|
|
148
|
+
return value
|
|
149
|
+
if isinstance(value, int):
|
|
150
|
+
return value
|
|
151
|
+
if isinstance(value, str):
|
|
152
|
+
return value
|
|
153
|
+
if isinstance(value, list):
|
|
154
|
+
return [_encode_json_value(v) for v in value]
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
return {str(k): _encode_json_value(v) for k, v in value.items()}
|
|
157
|
+
raise TypeError(
|
|
158
|
+
"provenance values must be JSON (dict, list, str, int, float, bool or None), got "
|
|
159
|
+
f"{type(value).__name__}: {value!r}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _encode_provenance(value: Mapping[str, object]) -> dict[str, JSONValue]:
|
|
164
|
+
if not isinstance(value, Mapping):
|
|
165
|
+
raise TypeError(f"provenance must be a mapping, got {type(value).__name__}")
|
|
166
|
+
return {str(k): _encode_json_value(v) for k, v in value.items()}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _encode_field_value(value: object) -> JSONValue:
|
|
170
|
+
if isinstance(value, (Censoring, Status, IntervalShape)):
|
|
171
|
+
return value.value
|
|
172
|
+
if isinstance(value, bool):
|
|
173
|
+
return value
|
|
174
|
+
if isinstance(value, float):
|
|
175
|
+
if not math.isfinite(value):
|
|
176
|
+
raise ValueError(f"non-finite float in result: {value!r}")
|
|
177
|
+
return value
|
|
178
|
+
if isinstance(value, int):
|
|
179
|
+
return value
|
|
180
|
+
if isinstance(value, str):
|
|
181
|
+
return value
|
|
182
|
+
if value is None:
|
|
183
|
+
return None
|
|
184
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
185
|
+
return _encode_dataclass_instance(value)
|
|
186
|
+
if isinstance(value, (tuple, list)):
|
|
187
|
+
return [_encode_field_value(v) for v in value]
|
|
188
|
+
if isinstance(value, Mapping):
|
|
189
|
+
return {str(k): _encode_field_value(v) for k, v in value.items()}
|
|
190
|
+
raise TypeError(f"cannot encode value of type {type(value).__name__}: {value!r}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _encode_dataclass_instance(obj: Any) -> dict[str, JSONValue]:
|
|
194
|
+
cls = type(obj)
|
|
195
|
+
result: dict[str, JSONValue] = {}
|
|
196
|
+
for f in dataclasses.fields(obj):
|
|
197
|
+
raw = getattr(obj, f.name)
|
|
198
|
+
if f.name == "provenance":
|
|
199
|
+
result[f.name] = _encode_provenance(raw)
|
|
200
|
+
else:
|
|
201
|
+
result[f.name] = _encode_field_value(raw)
|
|
202
|
+
if cls in _TAGGED_CLASSES:
|
|
203
|
+
result["type"] = cls.__name__
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def to_dict(obj: object) -> dict[str, JSONValue]:
|
|
208
|
+
"""Encode a robustmargin result dataclass instance into a plain, JSON-safe dict.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
obj
|
|
213
|
+
A :class:`Scorecard`, :class:`~robustmargin.GridBreakPoint`, :class:`~robustmargin.Fit`,
|
|
214
|
+
:class:`~robustmargin.Threshold` or :class:`~robustmargin.Ratio` instance -- the five tagged
|
|
215
|
+
types. Nothing else is accepted at the top level (a helper type such as
|
|
216
|
+
:class:`~robustmargin.Axis` only ever appears nested inside one of these).
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
dict[str, JSONValue]
|
|
221
|
+
A dict safe to pass to ``json.dumps(..., allow_nan=False)``. Tagged types carry a
|
|
222
|
+
``"type"`` key naming the class, at every nesting depth.
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
ValueError
|
|
227
|
+
If any float anywhere in ``obj`` (including inside ``provenance``) is NaN or infinite.
|
|
228
|
+
TypeError
|
|
229
|
+
If ``obj`` is not one of the five tagged types, or if a ``provenance`` value is not
|
|
230
|
+
plain JSON.
|
|
231
|
+
"""
|
|
232
|
+
if not isinstance(obj, _TAGGED_CLASSES):
|
|
233
|
+
raise TypeError(
|
|
234
|
+
"to_dict expects a Scorecard, GridBreakPoint, Fit, Threshold or Ratio instance, "
|
|
235
|
+
f"got {type(obj).__name__}"
|
|
236
|
+
)
|
|
237
|
+
return _encode_dataclass_instance(obj)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --------------------------------------------------------------------------------------------
|
|
241
|
+
# from_dict
|
|
242
|
+
# --------------------------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@lru_cache(maxsize=1)
|
|
246
|
+
def _schema_required_by_type() -> dict[str, frozenset[str]]:
|
|
247
|
+
"""The packaged schema's own ``required`` list for each of the five tagged result types
|
|
248
|
+
(:class:`Scorecard`, :class:`~robustmargin.GridBreakPoint`, :class:`~robustmargin.Fit`,
|
|
249
|
+
:class:`~robustmargin.Threshold`, :class:`~robustmargin.Ratio`), keyed by class name, with
|
|
250
|
+
``"type"`` removed -- ``from_dict`` checks ``"type"`` itself, separately.
|
|
251
|
+
|
|
252
|
+
Read from :func:`load_schema` rather than duplicated as a literal list, so a required field
|
|
253
|
+
can never drift between the schema and ``from_dict`` without the schema/``from_dict`` field
|
|
254
|
+
parity test (or this function's own malformed-schema check) catching it.
|
|
255
|
+
"""
|
|
256
|
+
schema = load_schema()
|
|
257
|
+
root_required = schema.get("required")
|
|
258
|
+
if not isinstance(root_required, list):
|
|
259
|
+
raise TypeError(
|
|
260
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing root "
|
|
261
|
+
"'required'"
|
|
262
|
+
)
|
|
263
|
+
defs = schema.get("$defs")
|
|
264
|
+
if not isinstance(defs, dict):
|
|
265
|
+
raise TypeError(
|
|
266
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing '$defs'"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
result: dict[str, frozenset[str]] = {
|
|
270
|
+
"Scorecard": frozenset(str(k) for k in root_required if k != "type")
|
|
271
|
+
}
|
|
272
|
+
for name in ("GridBreakPoint", "Fit", "Threshold", "Ratio"):
|
|
273
|
+
node = defs.get(name)
|
|
274
|
+
if not isinstance(node, dict):
|
|
275
|
+
raise TypeError(
|
|
276
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing "
|
|
277
|
+
f"'$defs.{name}'"
|
|
278
|
+
)
|
|
279
|
+
node_required = node.get("required")
|
|
280
|
+
if not isinstance(node_required, list):
|
|
281
|
+
raise TypeError(
|
|
282
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing "
|
|
283
|
+
f"'$defs.{name}.required'"
|
|
284
|
+
)
|
|
285
|
+
result[name] = frozenset(str(k) for k in node_required if k != "type")
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@lru_cache(maxsize=1)
|
|
290
|
+
def _schema_properties_by_type() -> dict[str, frozenset[str]]:
|
|
291
|
+
"""The packaged schema's own ``properties`` key set for each of the five tagged result
|
|
292
|
+
types, keyed by class name, ``"type"`` **included** (unlike
|
|
293
|
+
:func:`_schema_required_by_type`, which strips it since ``from_dict`` checks ``"type"``
|
|
294
|
+
separately -- there is no such special-casing needed here).
|
|
295
|
+
|
|
296
|
+
Used as :func:`_validated_fields`'s "allowed keys" set for a tagged type, in place of the
|
|
297
|
+
dataclass's own field names, so a key the schema does not list is rejected as unknown even if
|
|
298
|
+
a dataclass field of that name were ever added without the schema following. Same caching and
|
|
299
|
+
malformed-schema-detection shape as :func:`_schema_required_by_type`, read from
|
|
300
|
+
:func:`load_schema` rather than duplicated as a literal list.
|
|
301
|
+
"""
|
|
302
|
+
schema = load_schema()
|
|
303
|
+
root_properties = schema.get("properties")
|
|
304
|
+
if not isinstance(root_properties, dict):
|
|
305
|
+
raise TypeError(
|
|
306
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing root "
|
|
307
|
+
"'properties'"
|
|
308
|
+
)
|
|
309
|
+
defs = schema.get("$defs")
|
|
310
|
+
if not isinstance(defs, dict):
|
|
311
|
+
raise TypeError(
|
|
312
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing '$defs'"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
result: dict[str, frozenset[str]] = {"Scorecard": frozenset(str(k) for k in root_properties)}
|
|
316
|
+
for name in ("GridBreakPoint", "Fit", "Threshold", "Ratio"):
|
|
317
|
+
node = defs.get(name)
|
|
318
|
+
if not isinstance(node, dict):
|
|
319
|
+
raise TypeError(
|
|
320
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing "
|
|
321
|
+
f"'$defs.{name}'"
|
|
322
|
+
)
|
|
323
|
+
node_properties = node.get("properties")
|
|
324
|
+
if not isinstance(node_properties, dict):
|
|
325
|
+
raise TypeError(
|
|
326
|
+
f"robustmargin's packaged schema v{SCHEMA_VERSION} is malformed: missing "
|
|
327
|
+
f"'$defs.{name}.properties'"
|
|
328
|
+
)
|
|
329
|
+
result[name] = frozenset(str(k) for k in node_properties)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _validated_fields(
|
|
334
|
+
cls: type, data: object, *, tagged: bool, card_version: str | None = None
|
|
335
|
+
) -> dict[str, Any]:
|
|
336
|
+
"""Check ``data``'s keys against ``cls``'s dataclass fields (and ``schema_version``, if
|
|
337
|
+
``cls`` has one), and return the subset of ``data`` corresponding to fields that are
|
|
338
|
+
present.
|
|
339
|
+
|
|
340
|
+
``schema_version`` is checked before the unknown-field check, so an unsupported version is
|
|
341
|
+
reported on its own even when the dict also carries an unknown key. A version outside
|
|
342
|
+
:data:`_SUPPORTED_SCHEMA_VERSIONS` is reported as unknown. ``card_version`` is the
|
|
343
|
+
``library_version`` of the enclosing :class:`Scorecard`, threaded down from
|
|
344
|
+
:func:`from_dict` for every object nested inside one; when it is not ``None``, an
|
|
345
|
+
unknown-field error names it (and this reader's version) alongside the field. A bare
|
|
346
|
+
``Fit``/``Threshold``/``Ratio``/``GridBreakPoint`` read on its own (``card_version=None``)
|
|
347
|
+
gets a plainer message, since there is no card to name.
|
|
348
|
+
|
|
349
|
+
For one of the five tagged result types, a field is required exactly when the packaged
|
|
350
|
+
schema says so (:func:`_schema_required_by_type`); for an untagged helper type
|
|
351
|
+
(:class:`~robustmargin.Axis`, :class:`~robustmargin.Definition`,
|
|
352
|
+
:class:`~robustmargin.Cell`, :class:`~robustmargin.Parameter`,
|
|
353
|
+
:class:`~robustmargin.Covariance`), a field is required exactly when its dataclass field has
|
|
354
|
+
no default. A required field missing from ``data`` raises ``ValueError``; every other missing
|
|
355
|
+
field is simply omitted here, so the constructor applies its own default.
|
|
356
|
+
"""
|
|
357
|
+
if not isinstance(data, Mapping):
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"from_dict: expected a mapping for {cls.__name__}, got {type(data).__name__}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
fields = dataclasses.fields(cls)
|
|
363
|
+
field_names = {f.name for f in fields}
|
|
364
|
+
|
|
365
|
+
if "schema_version" in field_names:
|
|
366
|
+
schema_version = data.get("schema_version", SCHEMA_VERSION)
|
|
367
|
+
if schema_version not in _SUPPORTED_SCHEMA_VERSIONS:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"from_dict: unknown schema_version {schema_version!r} for {cls.__name__}, "
|
|
370
|
+
f"expected one of {sorted(_SUPPORTED_SCHEMA_VERSIONS)!r}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# A tagged type's allowed keys come from the packaged schema rather than from the dataclass's
|
|
374
|
+
# field names, so the schema stays the single statement of what a card may contain. An
|
|
375
|
+
# untagged helper type has no schema entry of its own to check against, so it allows exactly
|
|
376
|
+
# its dataclass fields.
|
|
377
|
+
allowed = _schema_properties_by_type()[cls.__name__] if tagged else field_names
|
|
378
|
+
|
|
379
|
+
unknown = set(data) - allowed
|
|
380
|
+
if unknown:
|
|
381
|
+
if card_version is not None:
|
|
382
|
+
reader_version = _default_library_version()
|
|
383
|
+
raise ValueError(
|
|
384
|
+
f"from_dict: unknown field(s) {sorted(unknown)} for {cls.__name__} (card "
|
|
385
|
+
f"written by robustmargin {card_version}; this reader is robustmargin "
|
|
386
|
+
f"{reader_version})"
|
|
387
|
+
)
|
|
388
|
+
raise ValueError(f"from_dict: unknown field(s) {sorted(unknown)} for {cls.__name__}")
|
|
389
|
+
|
|
390
|
+
required = (
|
|
391
|
+
_schema_required_by_type()[cls.__name__]
|
|
392
|
+
if tagged
|
|
393
|
+
else frozenset(
|
|
394
|
+
f.name
|
|
395
|
+
for f in fields
|
|
396
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
result: dict[str, Any] = {}
|
|
401
|
+
for f in fields:
|
|
402
|
+
if f.name in data:
|
|
403
|
+
result[f.name] = data[f.name]
|
|
404
|
+
elif f.name in required:
|
|
405
|
+
raise ValueError(f"from_dict: missing required field {f.name!r} for {cls.__name__}")
|
|
406
|
+
else:
|
|
407
|
+
continue
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _expect_tag(data: object, *, expected: str) -> object:
|
|
412
|
+
"""Check that a nested tagged value carries ``"type": expected`` before it is rebuilt.
|
|
413
|
+
|
|
414
|
+
A wrong or missing ``"type"`` on a nested value (for example a ``Threshold`` payload where
|
|
415
|
+
``Ratio.threshold_a`` expects a ``Threshold``) is a corrupted or hand-edited file, not
|
|
416
|
+
something to coerce silently.
|
|
417
|
+
"""
|
|
418
|
+
if not isinstance(data, Mapping):
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"from_dict: expected a mapping tagged type={expected!r}, got {type(data).__name__}"
|
|
421
|
+
)
|
|
422
|
+
tag = data.get("type")
|
|
423
|
+
if tag != expected:
|
|
424
|
+
raise ValueError(f"from_dict: expected type={expected!r}, got {tag!r}")
|
|
425
|
+
return data
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _axis_from_dict(data: object, card_version: str | None) -> Axis:
|
|
429
|
+
return Axis(**_validated_fields(Axis, data, tagged=False, card_version=card_version))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _definition_from_dict(data: object, card_version: str | None) -> Definition:
|
|
433
|
+
fields = _validated_fields(Definition, data, tagged=False, card_version=card_version)
|
|
434
|
+
return Definition(**fields)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _cell_from_dict(data: object, card_version: str | None) -> Cell:
|
|
438
|
+
return Cell(**_validated_fields(Cell, data, tagged=False, card_version=card_version))
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parameter_from_dict(data: object, card_version: str | None) -> Parameter:
|
|
442
|
+
return Parameter(**_validated_fields(Parameter, data, tagged=False, card_version=card_version))
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _covariance_from_dict(data: object, card_version: str | None) -> Covariance:
|
|
446
|
+
fields = _validated_fields(Covariance, data, tagged=False, card_version=card_version)
|
|
447
|
+
return Covariance(**fields)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _adjacent_pair_from_dict(data: object, card_version: str | None) -> AdjacentPair:
|
|
451
|
+
"""``AdjacentPair`` is an untagged helper, like ``Cell`` and ``Parameter``: it only ever
|
|
452
|
+
appears inside ``MonotonicityCheck.pairs``, never as a card's top-level result.
|
|
453
|
+
"""
|
|
454
|
+
fields = _validated_fields(AdjacentPair, data, tagged=False, card_version=card_version)
|
|
455
|
+
return AdjacentPair(**fields)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _monotonicity_check_from_dict(data: object, card_version: str | None) -> MonotonicityCheck:
|
|
459
|
+
"""``MonotonicityCheck`` is an untagged helper, like ``BaselineRate``: it only ever appears
|
|
460
|
+
as ``Fit.monotonicity``.
|
|
461
|
+
"""
|
|
462
|
+
fields = _validated_fields(MonotonicityCheck, data, tagged=False, card_version=card_version)
|
|
463
|
+
fields["pairs"] = tuple(_adjacent_pair_from_dict(p, card_version) for p in fields["pairs"])
|
|
464
|
+
return MonotonicityCheck(**fields)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _diagnostics_from_dict(data: object, card_version: str | None) -> Diagnostics:
|
|
468
|
+
"""``Diagnostics`` is an untagged helper, like ``BaselineRate``: it only ever appears as
|
|
469
|
+
``Fit.diagnostics``.
|
|
470
|
+
"""
|
|
471
|
+
fields = _validated_fields(Diagnostics, data, tagged=False, card_version=card_version)
|
|
472
|
+
fields["link_status"] = {k: Status(v) for k, v in fields["link_status"].items()}
|
|
473
|
+
return Diagnostics(**fields)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _fit_from_dict(data: object, card_version: str | None) -> Fit:
|
|
477
|
+
fields = _validated_fields(Fit, data, tagged=True, card_version=card_version)
|
|
478
|
+
fields["axis"] = _axis_from_dict(fields["axis"], card_version)
|
|
479
|
+
fields["cells"] = tuple(_cell_from_dict(c, card_version) for c in fields["cells"])
|
|
480
|
+
fields["status"] = Status(fields["status"])
|
|
481
|
+
if fields.get("params") is not None:
|
|
482
|
+
fields["params"] = {
|
|
483
|
+
k: _parameter_from_dict(v, card_version) for k, v in fields["params"].items()
|
|
484
|
+
}
|
|
485
|
+
if fields.get("covariance") is not None:
|
|
486
|
+
fields["covariance"] = _covariance_from_dict(fields["covariance"], card_version)
|
|
487
|
+
if fields.get("cluster_ids") is not None:
|
|
488
|
+
fields["cluster_ids"] = tuple(fields["cluster_ids"])
|
|
489
|
+
# Neither `diagnostics` nor `monotonicity` is in the schema's `required` list for a Fit, and
|
|
490
|
+
# `_validated_fields` fills a key a card omits from the dataclass default (`None`), so a card
|
|
491
|
+
# that omits either decodes with `None` and needs no special case beyond the nested-object
|
|
492
|
+
# conversion below.
|
|
493
|
+
if fields.get("diagnostics") is not None:
|
|
494
|
+
fields["diagnostics"] = _diagnostics_from_dict(fields["diagnostics"], card_version)
|
|
495
|
+
if fields.get("monotonicity") is not None:
|
|
496
|
+
fields["monotonicity"] = _monotonicity_check_from_dict(fields["monotonicity"], card_version)
|
|
497
|
+
return Fit(**fields)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _baseline_rate_from_dict(data: object, card_version: str | None) -> BaselineRate:
|
|
501
|
+
"""``BaselineRate`` is an untagged helper, like ``Cell`` and ``Parameter``: it only ever
|
|
502
|
+
appears as ``Threshold.baseline``, never as a card's top-level result, so it carries no
|
|
503
|
+
``"type"`` discriminator.
|
|
504
|
+
"""
|
|
505
|
+
fields = _validated_fields(BaselineRate, data, tagged=False, card_version=card_version)
|
|
506
|
+
return BaselineRate(**fields)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _threshold_from_dict(data: object, card_version: str | None) -> Threshold:
|
|
510
|
+
fields = _validated_fields(Threshold, data, tagged=True, card_version=card_version)
|
|
511
|
+
fields["axis"] = _axis_from_dict(fields["axis"], card_version)
|
|
512
|
+
fields["definition"] = _definition_from_dict(fields["definition"], card_version)
|
|
513
|
+
fields["censoring"] = Censoring(fields["censoring"])
|
|
514
|
+
fields["status"] = Status(fields["status"])
|
|
515
|
+
fields["fit"] = _fit_from_dict(_expect_tag(fields["fit"], expected="Fit"), card_version)
|
|
516
|
+
# Neither `baseline` nor `grid` is in the schema's `required` list for a Threshold, and
|
|
517
|
+
# `_validated_fields` fills a key a card omits from the dataclass default (`None`), so a card
|
|
518
|
+
# that omits either decodes with `None` and needs no special case here.
|
|
519
|
+
if fields.get("baseline") is not None:
|
|
520
|
+
fields["baseline"] = _baseline_rate_from_dict(fields["baseline"], card_version)
|
|
521
|
+
if fields.get("grid") is not None:
|
|
522
|
+
fields["grid"] = _grid_break_point_from_dict(
|
|
523
|
+
_expect_tag(fields["grid"], expected="GridBreakPoint"), card_version
|
|
524
|
+
)
|
|
525
|
+
return Threshold(**fields)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _ratio_from_dict(data: object, card_version: str | None) -> Ratio:
|
|
529
|
+
fields = _validated_fields(Ratio, data, tagged=True, card_version=card_version)
|
|
530
|
+
fields["threshold_a"] = _threshold_from_dict(
|
|
531
|
+
_expect_tag(fields["threshold_a"], expected="Threshold"), card_version
|
|
532
|
+
)
|
|
533
|
+
fields["threshold_b"] = _threshold_from_dict(
|
|
534
|
+
_expect_tag(fields["threshold_b"], expected="Threshold"), card_version
|
|
535
|
+
)
|
|
536
|
+
if fields.get("shape") is not None:
|
|
537
|
+
fields["shape"] = IntervalShape(fields["shape"])
|
|
538
|
+
fields["censoring"] = Censoring(fields["censoring"])
|
|
539
|
+
fields["status"] = Status(fields["status"])
|
|
540
|
+
return Ratio(**fields)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _grid_break_point_from_dict(data: object, card_version: str | None) -> GridBreakPoint:
|
|
544
|
+
fields = _validated_fields(GridBreakPoint, data, tagged=True, card_version=card_version)
|
|
545
|
+
fields["censoring"] = Censoring(fields["censoring"])
|
|
546
|
+
return GridBreakPoint(**fields)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _scorecard_from_dict(data: object, card_version: str | None) -> Scorecard:
|
|
550
|
+
# ``card_version`` (uniform with every other builder in _RESULT_BUILDERS) is unused here: a
|
|
551
|
+
# Scorecard is always the root of its own tree, never nested inside another Scorecard, so
|
|
552
|
+
# its own library_version is what every unknown-field error beneath it names instead.
|
|
553
|
+
if not isinstance(data, Mapping):
|
|
554
|
+
raise ValueError(f"from_dict: expected a mapping for Scorecard, got {type(data).__name__}")
|
|
555
|
+
own_version = data.get("library_version")
|
|
556
|
+
own_version_str = own_version if isinstance(own_version, str) else "<unknown>"
|
|
557
|
+
fields = _validated_fields(Scorecard, data, tagged=True, card_version=own_version_str)
|
|
558
|
+
fields["results"] = tuple(
|
|
559
|
+
_from_dict_impl(item, card_version=own_version_str) for item in fields["results"]
|
|
560
|
+
)
|
|
561
|
+
return Scorecard(**fields)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
_RESULT_BUILDERS: dict[str, Any] = {
|
|
565
|
+
"GridBreakPoint": _grid_break_point_from_dict,
|
|
566
|
+
"Fit": _fit_from_dict,
|
|
567
|
+
"Threshold": _threshold_from_dict,
|
|
568
|
+
"Ratio": _ratio_from_dict,
|
|
569
|
+
"Scorecard": _scorecard_from_dict,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _from_dict_impl(data: object, *, card_version: str | None) -> object:
|
|
574
|
+
if not isinstance(data, Mapping):
|
|
575
|
+
raise ValueError(f"from_dict expects a mapping, got {type(data).__name__}")
|
|
576
|
+
type_name = data.get("type")
|
|
577
|
+
if type_name is None:
|
|
578
|
+
raise ValueError("from_dict: missing required field 'type'")
|
|
579
|
+
builder = _RESULT_BUILDERS.get(type_name)
|
|
580
|
+
if builder is None:
|
|
581
|
+
raise ValueError(f"from_dict: unknown type {type_name!r}")
|
|
582
|
+
result: object = builder(data, card_version)
|
|
583
|
+
return result
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def from_dict(data: object) -> object:
|
|
587
|
+
"""Rebuild a robustmargin result object from the dict :func:`to_dict` produced (or an
|
|
588
|
+
equivalent one read back through ``json.loads``).
|
|
589
|
+
|
|
590
|
+
Parameters
|
|
591
|
+
----------
|
|
592
|
+
data
|
|
593
|
+
A mapping carrying a ``"type"`` key naming one of ``Scorecard``, ``GridBreakPoint``,
|
|
594
|
+
``Fit``, ``Threshold`` or ``Ratio``, plus that type's fields.
|
|
595
|
+
|
|
596
|
+
Returns
|
|
597
|
+
-------
|
|
598
|
+
object
|
|
599
|
+
An instance of the type named by ``data["type"]``. ``from_dict(to_dict(x)) == x`` for
|
|
600
|
+
every ``x`` of these five types whose ``schema_version`` is ``"1"``, which is every
|
|
601
|
+
result this package builds. Only ``schema_version`` ``"1"`` is read. Constructors do
|
|
602
|
+
not check the tag, so an object built with any other ``schema_version`` serialises but
|
|
603
|
+
does not read back.
|
|
604
|
+
|
|
605
|
+
Raises
|
|
606
|
+
------
|
|
607
|
+
ValueError
|
|
608
|
+
If ``"type"`` is missing or unrecognised; if ``schema_version`` (at any nesting level)
|
|
609
|
+
is not ``"1"``; if a field (at any nesting level) is not recognised; or if a required
|
|
610
|
+
field is missing.
|
|
611
|
+
"""
|
|
612
|
+
return _from_dict_impl(data, card_version=None)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# --------------------------------------------------------------------------------------------
|
|
616
|
+
# load_schema
|
|
617
|
+
# --------------------------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def load_schema(version: str = SCHEMA_VERSION) -> dict[str, object]:
|
|
621
|
+
"""Load a packaged score-card JSON Schema (draft 2020-12).
|
|
622
|
+
|
|
623
|
+
Reads ``schema/scorecard-v{version}.json`` from inside the installed ``robustmargin`` package
|
|
624
|
+
using :mod:`importlib.resources`, so it works the same way from a wheel as from a source
|
|
625
|
+
checkout. ``jsonschema`` itself is never imported here or anywhere under ``src/``; it is a
|
|
626
|
+
dev-only dependency for consumers that want to validate against the schema.
|
|
627
|
+
|
|
628
|
+
Parameters
|
|
629
|
+
----------
|
|
630
|
+
version
|
|
631
|
+
Which packaged schema version to load. Defaults to :data:`SCHEMA_VERSION`, ``"1"``,
|
|
632
|
+
which is the only version packaged.
|
|
633
|
+
|
|
634
|
+
Returns
|
|
635
|
+
-------
|
|
636
|
+
dict[str, object]
|
|
637
|
+
The parsed JSON Schema document.
|
|
638
|
+
|
|
639
|
+
Raises
|
|
640
|
+
------
|
|
641
|
+
FileNotFoundError
|
|
642
|
+
If ``version`` names a schema that is not packaged. Only ``"1"`` is.
|
|
643
|
+
"""
|
|
644
|
+
text = (
|
|
645
|
+
resources.files("robustmargin")
|
|
646
|
+
.joinpath("schema", f"scorecard-v{version}.json")
|
|
647
|
+
.read_text(encoding="utf-8")
|
|
648
|
+
)
|
|
649
|
+
schema: dict[str, object] = json.loads(text)
|
|
650
|
+
return schema
|