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.
- relativities/__init__.py +51 -0
- relativities/_bundles.py +499 -0
- relativities/_config.py +155 -0
- relativities/_files.py +24 -0
- relativities/_glm_fit.py +45 -0
- relativities/_model_artifact.py +94 -0
- relativities/_model_evaluation.py +106 -0
- relativities/_types.py +86 -0
- relativities/data.py +154 -0
- relativities/datasets.py +68 -0
- relativities/distributions.py +122 -0
- relativities/evaluation.py +111 -0
- relativities/hub.py +356 -0
- relativities/models.py +297 -0
- relativities/pipelines.py +321 -0
- relativities/py.typed +0 -0
- relativities/raters.py +242 -0
- relativities/resources/README.md +62 -0
- relativities/resources/__init__.py +1 -0
- relativities/resources/datasets.json +16 -0
- relativities/resources/example-auto/rater.rsl +54 -0
- relativities/resources/example-auto/rules.html +24 -0
- relativities/resources/example-auto-households.json +193 -0
- relativities/resources/example-auto.zip +0 -0
- relativities/resources/example-claims.json +451 -0
- relativities/results.py +319 -0
- relativities/rsl.py +333 -0
- relativities-0.1.0.dist-info/METADATA +195 -0
- relativities-0.1.0.dist-info/RECORD +31 -0
- relativities-0.1.0.dist-info/WHEEL +4 -0
- relativities-0.1.0.dist-info/licenses/LICENSE +64 -0
relativities/__init__.py
ADDED
|
@@ -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
|
+
]
|
relativities/_bundles.py
ADDED
|
@@ -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
|