relativities 0.1.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,51 @@
1
+ """Local actuarial models and population analysis."""
2
+
3
+ from . import rsl as RSL
4
+ from ._bundles import BundleError
5
+ from ._types import Failure
6
+ from .data import Dataset, Population
7
+ from .datasets import DatasetDescriptor, list_datasets, load_dataset
8
+ from .evaluation import RatingEvaluationReport, ValidationCase, evaluate
9
+ from .hub import AuthenticationError, Hub, HubError, Publication
10
+ from .models import ConditionalDistribution, LossModel, LossResult
11
+ from .pipelines import (
12
+ ComparisonBasis,
13
+ ComparisonPipeline,
14
+ ComparisonResult,
15
+ LossPipeline,
16
+ RatingPipeline,
17
+ pipeline,
18
+ )
19
+ from .raters import Rater
20
+ from .results import RatingResult, ResultRow, Summary
21
+
22
+ __all__ = [
23
+ "RSL",
24
+ "AuthenticationError",
25
+ "BundleError",
26
+ "ComparisonBasis",
27
+ "ComparisonPipeline",
28
+ "ComparisonResult",
29
+ "ConditionalDistribution",
30
+ "Dataset",
31
+ "DatasetDescriptor",
32
+ "Failure",
33
+ "Hub",
34
+ "HubError",
35
+ "LossModel",
36
+ "LossPipeline",
37
+ "LossResult",
38
+ "Population",
39
+ "Publication",
40
+ "Rater",
41
+ "RatingEvaluationReport",
42
+ "RatingPipeline",
43
+ "RatingResult",
44
+ "ResultRow",
45
+ "Summary",
46
+ "ValidationCase",
47
+ "evaluate",
48
+ "list_datasets",
49
+ "load_dataset",
50
+ "pipeline",
51
+ ]
@@ -0,0 +1,499 @@
1
+ """Load verified RSLC rater bundles into an owned snapshot and the native runtime.
2
+
3
+ A bundle's manifest, program fingerprint and complete file closure are verified
4
+ before anything reads it. Rating then runs entirely in the `rslc` Rust runtime:
5
+ the bundle's RSL source is checked again by the Rust compiler and evaluated
6
+ natively. The generated `rater.py` in a bundle is verified as part of its
7
+ identity but never executed. Only RSL 0.4 bundles load; older ones must be
8
+ migrated (`rslc fmt --migrate`) and re-verified first.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import re
17
+ import stat
18
+ import zipfile
19
+ from collections.abc import Callable, Mapping
20
+ from contextlib import AbstractContextManager
21
+ from dataclasses import dataclass, field
22
+ from decimal import Decimal
23
+ from pathlib import Path
24
+ from tempfile import TemporaryDirectory
25
+ from typing import Any, BinaryIO, Protocol, cast
26
+
27
+ _MANIFEST = "rater-manifest.json"
28
+ _PROGRAM = "rater.py"
29
+ _MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
30
+ _MAX_FILE_BYTES = 384 * 1024 * 1024
31
+ _MAX_TOTAL_BYTES = 512 * 1024 * 1024
32
+ _MAX_MANIFEST_BYTES = 16 * 1024 * 1024
33
+ _MAX_FILES = 10_000
34
+ _CHUNK = 1024 * 1024
35
+ _EXPECTED_ID = re.compile(rb'(?m)^_EXPECTED_BUNDLE_ID = "([0-9a-f]{64})"$')
36
+
37
+
38
+ class BundleError(ValueError):
39
+ """An unsupported, incomplete, or inconsistent local rater artifact."""
40
+
41
+
42
+ class RaterRuntime(Protocol):
43
+ """The native runtime boundary consumed by the public Rater adapter."""
44
+
45
+ @property
46
+ def input_schema(self) -> dict[str, Any] | None: ...
47
+
48
+ @property
49
+ def output_schema(self) -> dict[str, Any] | None: ...
50
+
51
+ @property
52
+ def rate_supported(self) -> bool: ...
53
+
54
+ def rate(self, payload_json: str, *, detailed: bool = False) -> str: ...
55
+
56
+ def close(self) -> None: ...
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class BundleContext:
61
+ """Own the runtime and the private verified files it was loaded from."""
62
+
63
+ manifest: dict[str, Any]
64
+ root: Path
65
+ runtime: RaterRuntime
66
+ _temporary: TemporaryDirectory[str] = field(repr=False)
67
+ _closed: bool = field(default=False, init=False, repr=False)
68
+
69
+ def close(self) -> None:
70
+ if not self._closed:
71
+ self._closed = True
72
+ try:
73
+ self.runtime.close()
74
+ finally:
75
+ self._temporary.cleanup()
76
+
77
+ def __enter__(self) -> BundleContext:
78
+ if self._closed:
79
+ raise RuntimeError("The rater bundle has been closed.")
80
+ return self
81
+
82
+ def __exit__(self, *_: object) -> None:
83
+ self.close()
84
+
85
+
86
+ def load_bundle(source: Path, expected_bundle_id: str | None = None) -> BundleContext:
87
+ """Verify a directory or deployment ZIP, then prepare its native RSL runtime.
88
+
89
+ Only the declared file closure is copied from directories. ZIPs must contain
90
+ exactly that closure (directory entries are permitted). Caller mutations of
91
+ the original files cannot affect the loaded runtime. Hash version 7 is
92
+ supported; historical version 6 requires re-export with a current compiler.
93
+ """
94
+ if expected_bundle_id is not None:
95
+ _sha256(expected_bundle_id, "expected bundle ID")
96
+ temporary = TemporaryDirectory(prefix="relativities-rater-")
97
+ root = Path(temporary.name)
98
+ runtime: RaterRuntime | None = None
99
+ try:
100
+ if source.is_dir():
101
+ manifest = _snapshot_directory(source, root)
102
+ else:
103
+ manifest = _snapshot_archive(source, root)
104
+ if expected_bundle_id is not None and manifest["bundle_id"] != expected_bundle_id:
105
+ raise BundleError("The downloaded bundle does not match the requested bundle ID.")
106
+ program = (root / _PROGRAM).read_bytes()
107
+ _verify_program(program, manifest)
108
+ runtime = NativeRuntime(root)
109
+ if not runtime.rate_supported:
110
+ raise BundleError("This bundle does not support the submission rating interface.")
111
+ return BundleContext(manifest, root, runtime, temporary)
112
+ except BaseException:
113
+ try:
114
+ if runtime is not None:
115
+ runtime.close()
116
+ finally:
117
+ temporary.cleanup()
118
+ raise
119
+
120
+
121
+ def _snapshot_directory(source: Path, root: Path) -> dict[str, Any]:
122
+ if source.is_symlink():
123
+ raise BundleError("A bundle directory must not be a symbolic link.")
124
+ source = source.resolve(strict=True)
125
+
126
+ def open_file(relative: str) -> BinaryIO:
127
+ path = source
128
+ for component in relative.split("/"):
129
+ path /= component
130
+ if path.is_symlink():
131
+ raise BundleError(f"Bundle paths must not contain symbolic links: {relative!r}.")
132
+ if not path.is_file():
133
+ raise BundleError(f"Bundle file is missing or is not a regular file: {relative!r}.")
134
+ if path.stat().st_size > _MAX_FILE_BYTES:
135
+ raise BundleError(f"Bundle file exceeds the size limit: {relative!r}.")
136
+ return path.open("rb")
137
+
138
+ return _snapshot(open_file, root)
139
+
140
+
141
+ def _snapshot_archive(source: Path, root: Path) -> dict[str, Any]:
142
+ if source.stat().st_size > _MAX_ARCHIVE_BYTES:
143
+ raise BundleError("Bundle archive exceeds the compressed size limit.")
144
+ try:
145
+ with zipfile.ZipFile(source) as archive:
146
+ entries = archive.infolist()
147
+ if len(entries) > _MAX_FILES:
148
+ raise BundleError("Bundle archive contains too many entries.")
149
+ names: set[str] = set()
150
+ files: set[str] = set()
151
+ total = 0
152
+ for entry in entries:
153
+ name = _relative_path(
154
+ entry.filename.rstrip("/") if entry.is_dir() else entry.filename
155
+ )
156
+ normalized = name.casefold()
157
+ if normalized in names:
158
+ raise BundleError(f"Duplicate bundle archive path: {name!r}.")
159
+ names.add(normalized)
160
+ kind = stat.S_IFMT(entry.external_attr >> 16)
161
+ if kind not in (0, stat.S_IFDIR if entry.is_dir() else stat.S_IFREG):
162
+ raise BundleError(f"Bundle archive path is not a regular file: {name!r}.")
163
+ if entry.flag_bits & 1:
164
+ raise BundleError("Encrypted bundle archives are not supported.")
165
+ if entry.file_size > _MAX_FILE_BYTES:
166
+ raise BundleError(f"Bundle file exceeds the size limit: {name!r}.")
167
+ total += entry.file_size
168
+ if total > _MAX_TOTAL_BYTES:
169
+ raise BundleError("Bundle archive exceeds the unpacked size limit.")
170
+ if not entry.is_dir():
171
+ files.add(name)
172
+
173
+ def open_file(relative: str) -> BinaryIO:
174
+ if relative not in files:
175
+ raise BundleError(f"Bundle archive is missing {relative!r}.")
176
+ return cast(BinaryIO, archive.open(relative))
177
+
178
+ manifest = _snapshot(open_file, root)
179
+ expected = {_MANIFEST, _PROGRAM, *_closure(manifest)}
180
+ if files != expected:
181
+ raise BundleError(
182
+ f"Bundle archive has undeclared files: {sorted(files - expected)!r}."
183
+ )
184
+ return manifest
185
+ except (zipfile.BadZipFile, NotImplementedError) as error:
186
+ raise BundleError(f"Cannot read bundle archive: {error}") from error
187
+
188
+
189
+ def _snapshot(
190
+ open_file: Callable[[str], AbstractContextManager[BinaryIO]], root: Path
191
+ ) -> dict[str, Any]:
192
+ with open_file(_MANIFEST) as stream:
193
+ encoded = stream.read(_MAX_MANIFEST_BYTES + 1)
194
+ if len(encoded) > _MAX_MANIFEST_BYTES:
195
+ raise BundleError("Bundle manifest exceeds the size limit.")
196
+ try:
197
+ manifest = json.loads(
198
+ encoded, object_pairs_hook=_unique_object, parse_constant=_bad_constant
199
+ )
200
+ except (UnicodeError, json.JSONDecodeError) as error:
201
+ raise BundleError(f"Invalid bundle manifest JSON: {error}") from error
202
+ _validate_manifest(manifest)
203
+ closure = _closure(manifest)
204
+ (root / _MANIFEST).write_bytes(encoded)
205
+ total = len(encoded)
206
+ for relative, expected in {_PROGRAM: None, **closure}.items():
207
+ destination = root / relative
208
+ destination.parent.mkdir(parents=True, exist_ok=True)
209
+ digest = hashlib.sha256()
210
+ size = 0
211
+ with open_file(relative) as stream, destination.open("xb") as output:
212
+ while chunk := stream.read(_CHUNK):
213
+ size += len(chunk)
214
+ total += len(chunk)
215
+ if size > _MAX_FILE_BYTES or total > _MAX_TOTAL_BYTES:
216
+ raise BundleError("Bundle exceeds its file or unpacked size limit.")
217
+ digest.update(chunk)
218
+ output.write(chunk)
219
+ if expected is not None and digest.hexdigest() != expected:
220
+ raise BundleError(f"Bundle file hash mismatch: {relative!r}.")
221
+ return manifest
222
+
223
+
224
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
225
+ result: dict[str, Any] = {}
226
+ for key, value in pairs:
227
+ if key in result:
228
+ raise BundleError(f"Duplicate JSON object key in bundle manifest: {key!r}.")
229
+ result[key] = value
230
+ return result
231
+
232
+
233
+ def _bad_constant(value: str) -> Any:
234
+ raise BundleError(f"Non-finite JSON value in bundle manifest: {value}.")
235
+
236
+
237
+ def _relative_path(value: Any) -> str:
238
+ if (
239
+ not isinstance(value, str)
240
+ or not value
241
+ or any(character in value for character in ("\\", "\x00", ":"))
242
+ or any(part in ("", ".", "..") for part in value.split("/"))
243
+ ):
244
+ raise BundleError(f"Unsafe bundle path: {value!r}.")
245
+ return value
246
+
247
+
248
+ def _sha256(value: Any, field_name: str) -> str:
249
+ if not isinstance(value, str) or re.fullmatch("[0-9a-f]{64}", value) is None:
250
+ raise BundleError(f"Bundle {field_name} must be a lowercase SHA-256 digest.")
251
+ return value
252
+
253
+
254
+ def _text(entry: Mapping[str, Any], name: str) -> str:
255
+ value = entry.get(name)
256
+ if not isinstance(value, str) or not value:
257
+ raise BundleError(f"Bundle {name} must be a nonempty string.")
258
+ return value
259
+
260
+
261
+ def _validate_manifest(manifest: Any) -> None:
262
+ if not isinstance(manifest, dict) or manifest.get("format") != "rslc-python-rater/2":
263
+ raise BundleError("Expected an rslc-python-rater/2 bundle manifest.")
264
+ if type(manifest.get("bundle_hash_version")) is not int or manifest["bundle_hash_version"] != 7:
265
+ raise BundleError(
266
+ "Unsupported bundle hash version; version 7 is required. Re-export the rater."
267
+ )
268
+ for name in ("bundle_id", "program_fingerprint", "core_program_fingerprint"):
269
+ _sha256(manifest.get(name), name)
270
+ for group in ("table_files", "extern_tables", "source_files"):
271
+ entries = manifest.get(group)
272
+ if not isinstance(entries, list) or not all(isinstance(entry, dict) for entry in entries):
273
+ raise BundleError(f"Bundle {group} must be a list of objects.")
274
+ for table in manifest["table_files"]:
275
+ _text(table, "name")
276
+ relative = _relative_path(table.get("path"))
277
+ role = {"format", "rows", "source_path", "source_sha256"} & table.keys()
278
+ if relative.endswith(".sqlite"):
279
+ if table.get("format") != "rslc.sqlite-table/1":
280
+ raise BundleError("Unsupported bundled SQLite table format.")
281
+ if type(table.get("rows")) is not int or table["rows"] < 0:
282
+ raise BundleError("A bundled SQLite table must declare a nonnegative row count.")
283
+ _relative_path(table.get("source_path"))
284
+ _sha256(table.get("source_sha256"), "source_sha256")
285
+ elif role:
286
+ raise BundleError("A CSV table cannot declare SQLite storage metadata.")
287
+ for source in manifest["source_files"]:
288
+ if source.get("kind") not in ("rsl", "enum_csv"):
289
+ raise BundleError("Unsupported bundle source kind.")
290
+ for external in manifest["extern_tables"]:
291
+ for name in ("binding", "table_name", "reject_code"):
292
+ _text(external, name)
293
+ if external.get("implementation") not in ("csv", "sqlite"):
294
+ raise BundleError("Unsupported legacy extern table implementation.")
295
+ if external.get("match_mode") not in ("specific", "range"):
296
+ raise BundleError("Unsupported legacy extern table match mode.")
297
+ if external["implementation"] == "sqlite":
298
+ _text(external, "sqlite_table")
299
+ if external["match_mode"] != "specific":
300
+ raise BundleError("Legacy SQLite externs must use specific matching.")
301
+ elif external.get("sqlite_table") is not None:
302
+ raise BundleError("A CSV extern cannot declare a SQLite table.")
303
+ for name in ("key_columns", "output_columns"):
304
+ columns = external.get(name)
305
+ if not isinstance(columns, list) or not all(isinstance(c, str) and c for c in columns):
306
+ raise BundleError(f"Bundle extern {name} must be a list of nonempty strings.")
307
+ if not isinstance(manifest.get("source_provenance_complete"), bool):
308
+ raise BundleError("Bundle source_provenance_complete must be a boolean.")
309
+ metadata = manifest.get("metadata")
310
+ if not isinstance(metadata, dict) or not isinstance(metadata.get("catalog"), dict):
311
+ raise BundleError("Bundle metadata.catalog must be an object.")
312
+ _closure(manifest)
313
+ if _derive_bundle_id(manifest) != manifest["bundle_id"]:
314
+ raise BundleError("Bundle manifest ID does not match its contents.")
315
+
316
+
317
+ def _closure(manifest: Mapping[str, Any]) -> dict[str, str]:
318
+ files: dict[str, str] = {}
319
+ normalized: dict[str, str] = {_MANIFEST: _MANIFEST, _PROGRAM: _PROGRAM}
320
+
321
+ def add(relative: Any, checksum: Any) -> None:
322
+ path = _relative_path(relative)
323
+ digest = _sha256(checksum, f"{path} sha256")
324
+ previous = normalized.get(path.casefold())
325
+ if previous is not None and (previous != path or files.get(path) != digest):
326
+ raise BundleError(f"Conflicting bundle closure path: {path!r}.")
327
+ normalized[path.casefold()] = path
328
+ files[path] = digest
329
+
330
+ for group in ("table_files", "extern_tables", "source_files"):
331
+ for entry in manifest[group]:
332
+ add(entry.get("path"), entry.get("sha256"))
333
+ if group == "table_files" and "source_path" in entry:
334
+ add(entry["source_path"], entry.get("source_sha256"))
335
+ if len(files) + 2 > _MAX_FILES:
336
+ raise BundleError("Bundle manifest contains too many files.")
337
+ return files
338
+
339
+
340
+ def _derive_bundle_id(manifest: Mapping[str, Any]) -> str:
341
+ """RSLC bundle hash v7, including pre-D-0111 externs and D-0125 projection."""
342
+ digest = hashlib.sha256()
343
+
344
+ def integer(value: int) -> None:
345
+ digest.update(value.to_bytes(8, "big"))
346
+
347
+ def text(value: str) -> None:
348
+ encoded = value.encode("utf-8")
349
+ integer(len(encoded))
350
+ digest.update(encoded)
351
+
352
+ text("rslc.python-rater-bundle")
353
+ integer(7)
354
+ text(manifest["program_fingerprint"])
355
+ text(manifest["core_program_fingerprint"])
356
+ tables = manifest["table_files"]
357
+ integer(len(tables))
358
+ for table in tables:
359
+ for name in ("name", "path", "sha256"):
360
+ text(table[name])
361
+ if "format" in table:
362
+ text(table["format"])
363
+ text(str(table["rows"]))
364
+ text(table["source_path"])
365
+ text(table["source_sha256"])
366
+ externs = manifest["extern_tables"]
367
+ integer(len(externs))
368
+ for external in externs:
369
+ for name in ("binding", "implementation", "path", "sha256"):
370
+ text(external[name])
371
+ text(external.get("sqlite_table") or "")
372
+ text(external["table_name"])
373
+ text(external["match_mode"])
374
+ for name in ("key_columns", "output_columns"):
375
+ integer(len(external[name]))
376
+ for column in external[name]:
377
+ text(column)
378
+ text(external["reject_code"])
379
+ sources = manifest["source_files"]
380
+ integer(len(sources))
381
+ for source in sources:
382
+ for name in ("kind", "path", "sha256"):
383
+ text(source[name])
384
+ digest.update(bytes((int(manifest["source_provenance_complete"]),)))
385
+ catalog = dict(manifest["metadata"]["catalog"])
386
+ if isinstance(catalog.get("ratingOutcomes"), list):
387
+ catalog["ratingOutcomes"] = [
388
+ {key: value for key, value in outcome.items() if key != "cost"}
389
+ if isinstance(outcome, dict)
390
+ else outcome
391
+ for outcome in catalog["ratingOutcomes"]
392
+ ]
393
+ text(_canonical_json(catalog))
394
+ return digest.hexdigest()
395
+
396
+
397
+ def _canonical_json(value: Any) -> str:
398
+ """Match the compiler's canonical JSON, including serde_json float notation."""
399
+ if isinstance(value, dict):
400
+ return (
401
+ "{"
402
+ + ",".join(
403
+ f"{json.dumps(key, ensure_ascii=False)}:{_canonical_json(value[key])}"
404
+ for key in sorted(value)
405
+ )
406
+ + "}"
407
+ )
408
+ if isinstance(value, list):
409
+ return "[" + ",".join(_canonical_json(item) for item in value) + "]"
410
+ if isinstance(value, str):
411
+ return json.dumps(value, ensure_ascii=False)
412
+ if value is None:
413
+ return "null"
414
+ if isinstance(value, bool):
415
+ return "true" if value else "false"
416
+ if isinstance(value, int):
417
+ return str(value)
418
+ if isinstance(value, float) and math.isfinite(value):
419
+ rendered = repr(value)
420
+ if "e" in rendered:
421
+ mantissa, exponent = rendered.split("e", 1)
422
+ exponent_value = int(exponent)
423
+ if -5 <= exponent_value <= 15:
424
+ rendered = format(Decimal(rendered), "f")
425
+ else:
426
+ sign = "+" if exponent_value >= 0 else "-"
427
+ rendered = f"{mantissa}e{sign}{abs(exponent_value)}"
428
+ return rendered
429
+ raise BundleError(f"Unsupported JSON value in bundle catalog: {value!r}.")
430
+
431
+
432
+ def _verify_program(program: bytes, manifest: Mapping[str, Any]) -> None:
433
+ declarations = list(_EXPECTED_ID.finditer(program))
434
+ if len(declarations) != 1:
435
+ raise BundleError("The generated program must declare exactly one expected bundle ID.")
436
+ declaration = declarations[0]
437
+ if declaration[1].decode("ascii") != manifest["bundle_id"]:
438
+ raise BundleError("The generated program expects a different bundle ID.")
439
+ normalized = (
440
+ program[: declaration.start(1)]
441
+ + b"__RSLC_EXPECTED_BUNDLE_ID__"
442
+ + program[declaration.end(1) :]
443
+ )
444
+ if hashlib.sha256(normalized).hexdigest() != manifest["program_fingerprint"]:
445
+ raise BundleError("Generated rater program fingerprint mismatch.")
446
+
447
+
448
+ class NativeRuntime:
449
+ """One verified bundle snapshot in the `rslc` Rust runtime (no Python rater)."""
450
+
451
+ def __init__(self, root: Path) -> None:
452
+ try:
453
+ import rslc
454
+ from rslc import _native
455
+ except ImportError as error: # pragma: no cover - rslc is a required dependency
456
+ raise BundleError("Rating requires the native `rslc` runtime package.") from error
457
+ source = root / "rater.rsl"
458
+ if not source.is_file():
459
+ raise BundleError("The bundle does not contain its RSL source (rater.rsl).")
460
+ report = _native.rsl_version_report(source.read_text(encoding="utf-8"), str(root))
461
+ current = _native.rsl_version()
462
+ if report.get("declared") != current:
463
+ declared = report.get("declared") or "0.2 or earlier"
464
+ raise BundleError(
465
+ f"This rater is written in RSL {declared}; Relativities rates only RSL {current} "
466
+ "bundles, natively in Rust. Migrate it with `rslc fmt --migrate`, re-verify it "
467
+ "against its previous release, and publish the migrated bundle."
468
+ )
469
+ try:
470
+ self._rater: Any = rslc.PreparedRater(str(root))
471
+ except ValueError as error:
472
+ raise BundleError(f"The native runtime rejected this bundle: {error}") from error
473
+ describe = self._rater.rate_describe()
474
+ self._describe: dict[str, Any] | None = json.loads(describe) if describe else None
475
+
476
+ @property
477
+ def input_schema(self) -> dict[str, Any] | None:
478
+ return self._describe["inputSchema"] if self._describe else None
479
+
480
+ @property
481
+ def output_schema(self) -> dict[str, Any] | None:
482
+ return self._describe["outputSchema"] if self._describe else None
483
+
484
+ @property
485
+ def rate_supported(self) -> bool:
486
+ return self._rater is not None and self._rater.rate_unsupported_reason is None
487
+
488
+ def rate(self, payload_json: str, *, detailed: bool = False) -> str:
489
+ if self._rater is None:
490
+ raise RuntimeError("The rater bundle has been closed.")
491
+ return cast(str, self._rater.rate(payload_json, detailed))
492
+
493
+ def rate_batch(self, payloads_json: str, *, detailed: bool = False) -> str:
494
+ if self._rater is None:
495
+ raise RuntimeError("The rater bundle has been closed.")
496
+ return cast(str, self._rater.rate_batch(payloads_json, detailed))
497
+
498
+ def close(self) -> None:
499
+ self._rater = None