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/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