mlbundle 0.1.0a1__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.
Files changed (91) hide show
  1. mlbundle/__init__.py +82 -0
  2. mlbundle/_base.py +19 -0
  3. mlbundle/_canonical_work.py +67 -0
  4. mlbundle/_catboost/__init__.py +159 -0
  5. mlbundle/_catboost/budget.py +22 -0
  6. mlbundle/_catboost/flatbuffer.py +104 -0
  7. mlbundle/_catboost/native.py +244 -0
  8. mlbundle/_catboost/prediction.py +67 -0
  9. mlbundle/_catboost/scope.py +109 -0
  10. mlbundle/_catboost/state.py +62 -0
  11. mlbundle/_composite/__init__.py +1 -0
  12. mlbundle/_composite/claims.py +133 -0
  13. mlbundle/_composite/io.py +158 -0
  14. mlbundle/_composite/prediction.py +77 -0
  15. mlbundle/_container_rules.py +73 -0
  16. mlbundle/_export_manifest.py +78 -0
  17. mlbundle/_native_io.py +53 -0
  18. mlbundle/_onnx/__init__.py +115 -0
  19. mlbundle/_onnx/_wire_runs.py +103 -0
  20. mlbundle/_onnx/description.py +252 -0
  21. mlbundle/_onnx/prediction.py +136 -0
  22. mlbundle/_onnx/protobuf.py +111 -0
  23. mlbundle/_onnx/session.py +87 -0
  24. mlbundle/_onnx/signatures.py +63 -0
  25. mlbundle/_onnx/validation.py +164 -0
  26. mlbundle/_pipeline_contract.py +104 -0
  27. mlbundle/_prediction_input.py +63 -0
  28. mlbundle/_schema_fields.py +68 -0
  29. mlbundle/_sklearn/__init__.py +183 -0
  30. mlbundle/_sklearn/_width_checks.py +59 -0
  31. mlbundle/_sklearn/archive.py +32 -0
  32. mlbundle/_sklearn/arrays.py +150 -0
  33. mlbundle/_sklearn/column_width.py +198 -0
  34. mlbundle/_sklearn/consistency.py +127 -0
  35. mlbundle/_sklearn/dtypes.py +41 -0
  36. mlbundle/_sklearn/encoder_width.py +123 -0
  37. mlbundle/_sklearn/fitted.py +113 -0
  38. mlbundle/_sklearn/labels.py +52 -0
  39. mlbundle/_sklearn/nodes.py +75 -0
  40. mlbundle/_sklearn/parameters.py +94 -0
  41. mlbundle/_sklearn/payload.py +234 -0
  42. mlbundle/_sklearn/pipeline_scope.py +141 -0
  43. mlbundle/_sklearn/polynomial_names.py +31 -0
  44. mlbundle/_sklearn/prediction.py +119 -0
  45. mlbundle/_sklearn/prediction_state.py +61 -0
  46. mlbundle/_sklearn/references.py +110 -0
  47. mlbundle/_sklearn/serialization.py +80 -0
  48. mlbundle/_sklearn/state.py +144 -0
  49. mlbundle/_sklearn/state_fields.json +228 -0
  50. mlbundle/_sklearn/structure.py +265 -0
  51. mlbundle/_sklearn/transform_names.py +111 -0
  52. mlbundle/_sklearn/transform_width.py +270 -0
  53. mlbundle/_sklearn/transformers.py +69 -0
  54. mlbundle/_sklearn/trusted.py +89 -0
  55. mlbundle/_xgboost/__init__.py +262 -0
  56. mlbundle/_xgboost/arrays.py +47 -0
  57. mlbundle/_xgboost/categories.py +109 -0
  58. mlbundle/_xgboost/native.py +193 -0
  59. mlbundle/_xgboost/prediction.py +59 -0
  60. mlbundle/_xgboost/snapshots.py +41 -0
  61. mlbundle/_xgboost/state.py +147 -0
  62. mlbundle/_xgboost/structure.py +47 -0
  63. mlbundle/_xgboost/ubjson.py +119 -0
  64. mlbundle/_zip_directory.py +173 -0
  65. mlbundle/_zip_members.py +116 -0
  66. mlbundle/adapters.py +33 -0
  67. mlbundle/canonical.py +290 -0
  68. mlbundle/claims.py +124 -0
  69. mlbundle/cli.py +193 -0
  70. mlbundle/container.py +93 -0
  71. mlbundle/contract.py +55 -0
  72. mlbundle/declarations.py +68 -0
  73. mlbundle/environment.py +85 -0
  74. mlbundle/errors.py +92 -0
  75. mlbundle/inspection.py +30 -0
  76. mlbundle/integrity.py +30 -0
  77. mlbundle/limits.py +38 -0
  78. mlbundle/manifest.py +222 -0
  79. mlbundle/model_io.py +151 -0
  80. mlbundle/py.typed +0 -0
  81. mlbundle/registry.py +103 -0
  82. mlbundle/results.py +68 -0
  83. mlbundle/samples.py +71 -0
  84. mlbundle/schema.py +145 -0
  85. mlbundle/training.py +104 -0
  86. mlbundle/writing.py +256 -0
  87. mlbundle-0.1.0a1.dist-info/METADATA +347 -0
  88. mlbundle-0.1.0a1.dist-info/RECORD +91 -0
  89. mlbundle-0.1.0a1.dist-info/WHEEL +4 -0
  90. mlbundle-0.1.0a1.dist-info/entry_points.txt +3 -0
  91. mlbundle-0.1.0a1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,113 @@
1
+ """Fitted dimensions that inference uses must agree with displayed counts."""
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ from sklearn.dummy import DummyRegressor
7
+ from sklearn.neural_network import MLPClassifier, MLPRegressor
8
+ from sklearn.svm._base import BaseLibSVM
9
+
10
+ from mlbundle.errors import InvalidModelError
11
+
12
+
13
+ def validate_fitted(model: Any, width: int | None) -> None:
14
+ _statistics(model, width)
15
+ _dummy(model)
16
+ if width is None:
17
+ return
18
+ if hasattr(model, "_fit_X") and hasattr(model, "_y"):
19
+ rows = model._fit_X.shape[0]
20
+ if (
21
+ model._fit_X.shape[1] != width
22
+ or len(model._y) != rows
23
+ or getattr(model, "n_samples_fit_", rows) != rows
24
+ ):
25
+ raise InvalidModelError("Neighbor fitted dimensions disagree.")
26
+ if isinstance(model, (MLPClassifier, MLPRegressor)) and hasattr(model, "coefs_"):
27
+ _mlp(model, width)
28
+
29
+
30
+ def _mlp(model: Any, width: int) -> None:
31
+ sizes = [width, *np.atleast_1d(model.hidden_layer_sizes).tolist()]
32
+ sizes.append(model.n_outputs_)
33
+ if (
34
+ model.n_layers_ != len(sizes)
35
+ or len(model.coefs_) != len(sizes) - 1
36
+ or len(model.intercepts_) != len(model.coefs_)
37
+ or any(
38
+ coef.shape != (left, right) or intercept.shape != (right,)
39
+ for coef, intercept, left, right in zip(
40
+ model.coefs_, model.intercepts_, sizes, sizes[1:], strict=False
41
+ )
42
+ )
43
+ ):
44
+ raise InvalidModelError("MLP fitted layer dimensions disagree.")
45
+ if isinstance(model, MLPClassifier):
46
+ classes = len(model.classes_)
47
+ if (
48
+ classes < 2
49
+ or model.n_outputs_ != (1 if classes <= 2 else classes)
50
+ or model.out_activation_ != ("logistic" if classes <= 2 else "softmax")
51
+ ):
52
+ raise InvalidModelError("MLP class count and output activation disagree.")
53
+ elif model.out_activation_ != ("exp" if model.loss == "poisson" else "identity"):
54
+ raise InvalidModelError("MLP regression loss and output activation disagree.")
55
+
56
+
57
+ def _statistics(model: Any, width: int | None) -> None:
58
+ if not hasattr(model, "classes_") or isinstance(model.classes_, list):
59
+ return
60
+ classes = len(model.classes_)
61
+ if width is not None and hasattr(model, "coef_"):
62
+ _coefficient(model, classes, width)
63
+ for name in ("theta_", "var_", "means_", "centroids_"):
64
+ if (
65
+ width is not None
66
+ and hasattr(model, name)
67
+ and np.asarray(getattr(model, name)).shape
68
+ != (
69
+ classes,
70
+ width,
71
+ )
72
+ ):
73
+ raise InvalidModelError(
74
+ "Classifier fitted statistics and dimensions disagree."
75
+ )
76
+ for name in ("class_prior_", "class_log_prior_", "class_count_"):
77
+ if hasattr(model, name) and np.asarray(getattr(model, name)).shape != (
78
+ classes,
79
+ ):
80
+ raise InvalidModelError(
81
+ "Classifier fitted priors and class counts disagree."
82
+ )
83
+ if hasattr(model, "feature_log_prob_"):
84
+ values = model.feature_log_prob_
85
+ shapes = (
86
+ [np.asarray(value).shape for value in values]
87
+ if isinstance(values, list)
88
+ else [np.asarray(values).shape]
89
+ )
90
+ if any(len(shape) != 2 or shape[0] != classes for shape in shapes):
91
+ raise InvalidModelError("Classifier likelihoods and class counts disagree.")
92
+ if (isinstance(values, list) and len(values) != width) or (
93
+ not isinstance(values, list) and shapes[0][1] != width
94
+ ):
95
+ raise InvalidModelError(
96
+ "Classifier likelihoods and feature counts disagree."
97
+ )
98
+
99
+
100
+ def _coefficient(model: Any, classes: int, width: int) -> None:
101
+ if isinstance(model, BaseLibSVM):
102
+ return
103
+ if np.asarray(model.coef_).shape != (1 if classes <= 2 else classes, width):
104
+ raise InvalidModelError("Classifier coefficients and class counts disagree.")
105
+
106
+
107
+ def _dummy(model: Any) -> None:
108
+ if (
109
+ isinstance(model, DummyRegressor)
110
+ and hasattr(model, "constant_")
111
+ and np.asarray(model.constant_).shape != (1, model.n_outputs_)
112
+ ):
113
+ raise InvalidModelError("Dummy regressor constant and output counts disagree.")
@@ -0,0 +1,52 @@
1
+ """One label/count check for every fitted classifier, including nested models."""
2
+
3
+ from typing import Any, cast
4
+
5
+ import numpy as np
6
+ from pydantic import ConfigDict, TypeAdapter
7
+ from sklearn.neural_network import MLPClassifier
8
+
9
+ from mlbundle.canonical import canonical_dumps, canonical_value
10
+ from mlbundle.claims import ClassLabels, validate_label_sets
11
+ from mlbundle.errors import InvalidModelError
12
+
13
+ # The pinned pre-commit Pyright predates Annotated aliases as TypeForm arguments.
14
+ _LABELS = TypeAdapter(
15
+ cast(Any, ClassLabels), config=ConfigDict(strict=True, allow_inf_nan=False)
16
+ )
17
+
18
+
19
+ def validate_classes(model: Any) -> None:
20
+ classes = model.classes_ if isinstance(model.classes_, list) else [model.classes_]
21
+ if not classes or any(np.asarray(labels).ndim != 1 for labels in classes):
22
+ raise InvalidModelError("Invalid classifier label dimensions.")
23
+ counts = np.asarray([len(labels) for labels in classes])
24
+ labels = _LABELS.validate_python(canonical_value(classes))
25
+ validate_label_sets(labels)
26
+ binarizer = getattr(model, "_label_binarizer", None)
27
+ if binarizer is not None and canonical_dumps(model.classes_) != canonical_dumps(
28
+ binarizer.classes_
29
+ ):
30
+ raise InvalidModelError("Classifier and label binarizer classes disagree.")
31
+ _outputs(model, len(classes))
32
+ if hasattr(model, "n_classes_"):
33
+ native = np.asarray(model.n_classes_)
34
+ if (
35
+ native.dtype.kind not in "iu"
36
+ or native.ndim != (0 if len(classes) == 1 else 1)
37
+ or not np.array_equal(np.atleast_1d(native), counts)
38
+ ):
39
+ raise InvalidModelError(
40
+ "Classifier labels and native class counts disagree."
41
+ )
42
+ if hasattr(model, "tree_") and not np.array_equal(model.tree_.n_classes, counts):
43
+ raise InvalidModelError("Classifier labels and tree class counts disagree.")
44
+
45
+
46
+ def _outputs(model: Any, count: int) -> None:
47
+ if (
48
+ not isinstance(model, MLPClassifier)
49
+ and hasattr(model, "n_outputs_")
50
+ and model.n_outputs_ != count
51
+ ):
52
+ raise InvalidModelError("Classifier labels and native output counts disagree.")
@@ -0,0 +1,75 @@
1
+ """Closed skops node layouts: no unused state, loaders or hidden child objects."""
2
+
3
+ from typing import Any
4
+
5
+ from mlbundle.errors import InvalidModelError, UnsupportedModelError
6
+
7
+ from .trusted import FUNCTIONS, OPTIMIZER_TYPES, SCALAR_NAMES
8
+
9
+ FIELDS = {
10
+ "ObjectNode": {"content"},
11
+ "TreeNode": {"content", "__reduce__"},
12
+ "LossNode": {"content", "__reduce__"},
13
+ "DictNode": {"content", "key_types"},
14
+ "ListNode": {"content"},
15
+ "TupleNode": {"content"},
16
+ "SetNode": {"content"},
17
+ "JsonNode": {"content", "is_json"},
18
+ "TypeNode": set(),
19
+ "FunctionNode": set(),
20
+ "CachedNode": set(),
21
+ "NdArrayNode": {"type", "file", "content", "shape"},
22
+ "SparseMatrixNode": {"type", "file"},
23
+ "RandomStateNode": {"content"},
24
+ "RandomGeneratorNode": {"content"},
25
+ "MaskedArrayNode": {"content"},
26
+ "DTypeNode": {"content"},
27
+ "SliceNode": {"content"},
28
+ "ConstructorFromReduceNode": {"content"},
29
+ "BytesNode": {"file"},
30
+ "BytearrayNode": {"file"},
31
+ }
32
+ COMMON = {"__class__", "__module__", "__loader__", "__id__"}
33
+
34
+
35
+ def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None:
36
+ for node in nodes:
37
+ loader = node["__loader__"]
38
+ name = node.get("__module__", "") + "." + node.get("__class__", "")
39
+ _special_loader(node, name, loader)
40
+ if name in OPTIMIZER_TYPES and loader not in ("ObjectNode", "CachedNode"):
41
+ raise InvalidModelError("Invalid skops optimizer loader.")
42
+ if (
43
+ node.get("__module__") == "sklearn.tree._tree"
44
+ and node.get("__class__") == "Tree"
45
+ and loader not in ("TreeNode", "CachedNode")
46
+ ):
47
+ raise InvalidModelError("Invalid skops native tree loader.")
48
+ if loader not in FIELDS:
49
+ raise UnsupportedModelError((name,))
50
+ allowed = (
51
+ COMMON
52
+ | FIELDS[loader]
53
+ | ({"protocol", "_skops_version"} if node is root else set())
54
+ )
55
+ if set(node) - allowed:
56
+ raise InvalidModelError("Unknown skops node state.")
57
+ if loader in ("TreeNode", "LossNode") and set(node["__reduce__"]) != {"args"}:
58
+ raise InvalidModelError("Unknown skops constructor state.")
59
+
60
+
61
+ def _special_loader(node, name, loader) -> None:
62
+ if name in FUNCTIONS and loader not in ("FunctionNode", "CachedNode"):
63
+ raise InvalidModelError("Invalid skops function loader.")
64
+ if loader == "FunctionNode" and name not in FUNCTIONS:
65
+ raise UnsupportedModelError((name,))
66
+ if name == "numpy.dtype" and loader not in ("DTypeNode", "CachedNode"):
67
+ raise InvalidModelError("Invalid skops dtype loader.")
68
+ if loader == "DTypeNode" and name != "numpy.dtype":
69
+ raise InvalidModelError("Invalid skops dtype identity.")
70
+ if (
71
+ loader == "TypeNode"
72
+ and node.get("__module__") == "numpy"
73
+ and name not in SCALAR_NAMES
74
+ ):
75
+ raise UnsupportedModelError((name,))
@@ -0,0 +1,94 @@
1
+ """Deterministic sklearn parameters; these records never select loading code."""
2
+
3
+ from typing import cast
4
+
5
+ import numpy as np
6
+ from pydantic import JsonValue
7
+ from sklearn.base import BaseEstimator
8
+
9
+ from mlbundle.canonical import canonical_value
10
+ from mlbundle.errors import LimitExceededError, UnsupportedModelError
11
+ from mlbundle.limits import HYPERPARAMETER_BYTES
12
+
13
+ from .trusted import dtype_identifier, reference_identifier
14
+
15
+ MAX_VISITS = 100_000
16
+
17
+
18
+ def _parameter(
19
+ value: object,
20
+ ancestors: frozenset[int],
21
+ budget: list[int] | None = None,
22
+ *,
23
+ estimator_ref: bool = False,
24
+ ) -> JsonValue:
25
+ budget = [0] if budget is None else budget
26
+ budget[0] += 1
27
+ if budget[0] > MAX_VISITS or len(ancestors) > 16:
28
+ raise LimitExceededError("Sklearn parameter work exceeds the limit.")
29
+ if id(value) in ancestors:
30
+ raise UnsupportedModelError(reason="Cyclic hyperparameters are unsupported.")
31
+ path = ancestors | {id(value)}
32
+ if isinstance(value, BaseEstimator):
33
+ if not type(value).__module__.startswith("sklearn."):
34
+ raise UnsupportedModelError(
35
+ (f"{type(value).__module__}.{type(value).__name__}",)
36
+ )
37
+ return _estimator(value, path, budget, estimator_ref)
38
+ if isinstance(value, np.random.RandomState):
39
+ return {"$random_state": canonical_value(value.get_state())}
40
+ if isinstance(value, dict):
41
+ if any(type(key) is not str for key in value):
42
+ raise UnsupportedModelError(reason="Hyperparameter keys must be strings.")
43
+ return {key: _parameter(item, path, budget) for key, item in value.items()}
44
+ if isinstance(value, (list, tuple)):
45
+ return [
46
+ _parameter(item, path, budget, estimator_ref=estimator_ref)
47
+ for item in value
48
+ ]
49
+ return _leaf(value)
50
+
51
+
52
+ def _leaf(value: object) -> JsonValue:
53
+ if isinstance(value, np.dtype):
54
+ return {"$dtype": dtype_identifier(value)}
55
+ if callable(value):
56
+ identifier = reference_identifier(value)
57
+ if identifier is not None:
58
+ return {"$type" if isinstance(value, type) else "$function": identifier}
59
+ raise UnsupportedModelError(
60
+ reason="Callable and type hyperparameters are unsupported."
61
+ )
62
+ return canonical_value(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16)
63
+
64
+
65
+ def _estimator(value, ancestors, budget, reference) -> JsonValue:
66
+ # Named deep keys own step parameters; constructor lists retain routing/order.
67
+ if reference:
68
+ return {"$estimator_ref": type(value).__name__}
69
+ return {
70
+ "$estimator": {
71
+ "class": type(value).__name__,
72
+ "params": parameters(value, ancestors, budget),
73
+ }
74
+ }
75
+
76
+
77
+ def parameters(
78
+ model: BaseEstimator,
79
+ ancestors: frozenset[int] = frozenset(),
80
+ budget: list[int] | None = None,
81
+ ) -> dict[str, JsonValue]:
82
+ budget = [0] if budget is None else budget
83
+ return cast(
84
+ dict[str, JsonValue],
85
+ {
86
+ key: _parameter(
87
+ value,
88
+ ancestors,
89
+ budget,
90
+ estimator_ref=key in ("steps", "transformers", "transformer_list"),
91
+ )
92
+ for key, value in model.get_params(deep=True).items()
93
+ },
94
+ )
@@ -0,0 +1,234 @@
1
+ """Bound the nested skops ZIP before skops reads schema or numeric buffers."""
2
+
3
+ from io import BytesIO
4
+ from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
5
+
6
+ import numpy as np
7
+ from pydantic import JsonValue
8
+
9
+ from mlbundle.canonical import canonical_loads
10
+ from mlbundle.errors import InvalidModelError
11
+ from mlbundle.limits import (
12
+ MANIFEST_BYTES,
13
+ MAX_CLASSES,
14
+ MAX_FEATURES,
15
+ MAX_OUTPUTS,
16
+ ArchiveLimits,
17
+ )
18
+
19
+ from .archive import bounded_zip
20
+ from .arrays import MAX_FILES, array_header, sparse_headers
21
+ from .dtypes import validate_dtypes
22
+ from .nodes import validate_nodes
23
+ from .references import References
24
+ from .state import state_digest, validate_state
25
+
26
+ BUFFER_BYTES = ArchiveLimits().max_member_bytes
27
+
28
+
29
+ def validate_payload(data: bytes) -> str:
30
+ limits = ArchiveLimits()
31
+ with bounded_zip(data, MAX_FILES) as archive:
32
+ entries = archive.infolist()
33
+ names = [entry.filename for entry in entries]
34
+ if (
35
+ len(names) > MAX_FILES
36
+ or len(names) != len(set(names))
37
+ or "schema.json" not in names
38
+ or any("/" in name or "\\" in name or ".." in name for name in names)
39
+ or sum(entry.file_size for entry in entries) > limits.max_member_bytes
40
+ ):
41
+ raise InvalidModelError("Invalid nested skops archive.")
42
+ schema_entry = archive.getinfo("schema.json")
43
+ if schema_entry.file_size > MANIFEST_BYTES:
44
+ raise InvalidModelError("Skops schema exceeds the byte limit.")
45
+ for entry in entries:
46
+ if (
47
+ entry.flag_bits & 1
48
+ or entry.compress_type not in (ZIP_STORED, ZIP_DEFLATED)
49
+ or entry.file_size > limits.max_member_bytes
50
+ or entry.file_size > limits.max_compression_ratio * entry.compress_size
51
+ ):
52
+ raise InvalidModelError("Unsafe nested skops member bounds.")
53
+ schema = canonical_loads(archive.read(schema_entry))
54
+ references = References(schema)
55
+ validate_nodes(references.nodes, schema)
56
+ validate_state(references.nodes, references.resolve)
57
+ arrays = {name for name in names if name.endswith(".npy")} | _array_references(
58
+ schema
59
+ )
60
+ sparse = {
61
+ node["file"]
62
+ for node in references.nodes
63
+ if node.get("__loader__") == "SparseMatrixNode"
64
+ }
65
+ remaining = limits.max_member_bytes - sum(
66
+ archive.getinfo(name).file_size for name in arrays if name in names
67
+ )
68
+ sparse_sizes = {}
69
+ for name in sparse:
70
+ sparse_sizes[name] = sparse_headers(archive.read(name), remaining)
71
+ remaining -= sparse_sizes[name]
72
+ _buffer_budget(references, archive, sparse_sizes)
73
+ headers = {}
74
+ for name in arrays:
75
+ if name not in names:
76
+ raise InvalidModelError("Missing skops numeric array member.")
77
+ headers[name] = _array_header(
78
+ archive, name, archive.getinfo(name).file_size
79
+ )
80
+ validate_dtypes(references.nodes, references.resolve, archive)
81
+ _tree_constructors(references, archive, headers)
82
+ files = _array_references(schema) | sparse | {"schema.json"}
83
+ if set(names) != files:
84
+ raise InvalidModelError("Unreferenced skops member.")
85
+ return state_digest(schema, archive, references.resolve)
86
+
87
+
88
+ def _array_references(schema: JsonValue) -> set[str]:
89
+ arrays: set[str] = set()
90
+ pending = [schema]
91
+ while pending:
92
+ node = pending.pop()
93
+ if isinstance(node, dict):
94
+ if node.get("__loader__") == "NdArrayNode" and "file" in node:
95
+ name = node["file"]
96
+ if not isinstance(name, str):
97
+ raise InvalidModelError("Invalid skops numeric array reference.")
98
+ arrays.add(name)
99
+ pending.extend(node.values())
100
+ elif isinstance(node, list):
101
+ pending.extend(node)
102
+ return arrays
103
+
104
+
105
+ def _buffer_budget(
106
+ references: References, archive: ZipFile, sparse: dict[str, int]
107
+ ) -> None:
108
+ seen: set[int] = set()
109
+ total = 0
110
+ for node in references.nodes:
111
+ if (
112
+ node.get("__loader__") not in ("NdArrayNode", "SparseMatrixNode")
113
+ or "file" not in node
114
+ ):
115
+ continue
116
+ identity = node.get("__id__", id(node))
117
+ if identity in seen:
118
+ continue
119
+ seen.add(identity)
120
+ total += sparse.get(node["file"], archive.getinfo(node["file"]).file_size)
121
+ if total > BUFFER_BYTES:
122
+ raise InvalidModelError("Skops materialized buffer bytes exceed the limit.")
123
+
124
+
125
+ _array_header = array_header
126
+
127
+
128
+ def _tree_constructors(references: References, archive: ZipFile, headers) -> None:
129
+ seen: set[int] = set()
130
+ for node in references.nodes:
131
+ identity = node.get("__id__", id(node))
132
+ if node.get("__loader__") == "TreeNode" and identity not in seen:
133
+ seen.add(identity)
134
+ reduced = node.get("__reduce__")
135
+ if not isinstance(reduced, dict):
136
+ raise InvalidModelError("Invalid skops tree constructor.")
137
+ args = references.resolve(reduced.get("args"))
138
+ state = references.resolve(node.get("content"))
139
+ _tree_constructor(
140
+ {**node, "__reduce__": {"args": args}, "content": state},
141
+ references.memo,
142
+ archive,
143
+ headers,
144
+ )
145
+
146
+
147
+ def _resolve(
148
+ value: JsonValue, memo: dict[int, dict[str, JsonValue]]
149
+ ) -> dict[str, JsonValue]:
150
+ if not isinstance(value, dict):
151
+ raise InvalidModelError("Invalid skops tree constructor schema.")
152
+ identity = value.get("__id__")
153
+ if type(identity) is int and identity in memo:
154
+ return memo[identity]
155
+ if value.get("__loader__") == "CachedNode":
156
+ raise InvalidModelError("Invalid skops tree constructor reference.")
157
+ return value
158
+
159
+
160
+ def _integer(value: JsonValue, memo: dict[int, dict[str, JsonValue]]) -> int:
161
+ node = _resolve(value, memo)
162
+ content = node.get("content")
163
+ if (
164
+ node.get("__loader__") != "JsonNode"
165
+ or node.get("is_json") is not True
166
+ or not isinstance(content, str)
167
+ ):
168
+ raise InvalidModelError("Invalid skops tree constructor integer.")
169
+ result = canonical_loads(content.encode())
170
+ if type(result) is not int:
171
+ raise InvalidModelError("Invalid skops tree constructor integer.")
172
+ return result
173
+
174
+
175
+ def _numeric_header(
176
+ value: JsonValue, memo: dict[int, dict[str, JsonValue]], headers
177
+ ) -> tuple[str, tuple[int, ...], np.dtype]:
178
+ node = _resolve(value, memo)
179
+ name = node.get("file")
180
+ if (
181
+ node.get("__loader__") != "NdArrayNode"
182
+ or node.get("type") != "numpy"
183
+ or not isinstance(name, str)
184
+ ):
185
+ raise InvalidModelError("Invalid skops tree numeric storage.")
186
+ shape, dtype = headers[name]
187
+ return name, shape, dtype
188
+
189
+
190
+ def _tree_constructor(
191
+ node: dict[str, JsonValue],
192
+ memo: dict[int, dict[str, JsonValue]],
193
+ archive: ZipFile,
194
+ headers,
195
+ ) -> None:
196
+ reduced = node.get("__reduce__")
197
+ arguments = reduced.get("args") if isinstance(reduced, dict) else None
198
+ if not isinstance(arguments, dict):
199
+ raise InvalidModelError("Invalid skops tree constructor arguments.")
200
+ args = arguments.get("content")
201
+ if (
202
+ arguments.get("__loader__") != "TupleNode"
203
+ or not isinstance(args, list)
204
+ or len(args) != 3
205
+ ):
206
+ raise InvalidModelError("Invalid skops tree constructor arguments.")
207
+ width, outputs = _integer(args[0], memo), _integer(args[2], memo)
208
+ if not 1 <= width <= MAX_FEATURES or not 1 <= outputs <= MAX_OUTPUTS:
209
+ raise InvalidModelError("Unsafe skops tree constructor dimensions.")
210
+ name, shape, dtype = _numeric_header(args[1], memo, headers)
211
+ if shape != (outputs,) or dtype != np.dtype(np.intp):
212
+ raise InvalidModelError("Invalid skops tree class-count dimensions.")
213
+ classes = np.load(BytesIO(archive.read(name)), allow_pickle=False)
214
+ if np.any(classes < 1) or np.any(classes > MAX_CLASSES):
215
+ raise InvalidModelError("Unsafe skops tree class counts.")
216
+ state = node.get("content")
217
+ if not isinstance(state, dict):
218
+ raise InvalidModelError("Invalid skops tree state.")
219
+ attributes = state.get("content")
220
+ if not isinstance(attributes, dict):
221
+ raise InvalidModelError("Invalid skops tree state.")
222
+ if set(attributes) != {"node_count", "max_depth", "nodes", "values"}:
223
+ raise InvalidModelError("Unknown skops tree state.")
224
+ count = _integer(attributes.get("node_count"), memo)
225
+ depth = _integer(attributes.get("max_depth"), memo)
226
+ _, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, headers)
227
+ _, values_shape, _ = _numeric_header(attributes.get("values"), memo, headers)
228
+ if (
229
+ count < 1
230
+ or not 0 <= depth < count
231
+ or nodes_shape != (count,)
232
+ or values_shape != (count, outputs, int(max(classes)))
233
+ ):
234
+ raise InvalidModelError("Invalid skops tree storage dimensions.")