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,141 @@
1
+ """Bounded native-placement discovery and closed Pipeline options."""
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ from sklearn import get_config
7
+ from sklearn.base import BaseEstimator
8
+ from sklearn.pipeline import Pipeline
9
+
10
+ from mlbundle._pipeline_contract import OPTIONS, valid_step_names
11
+ from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError
12
+
13
+
14
+ def objects(root: Any):
15
+ pending = [root]
16
+ seen = set()
17
+ visits = 0
18
+ while pending:
19
+ value = pending.pop()
20
+ visits += 1
21
+ if visits > 100_000:
22
+ raise LimitExceededError("Pipeline state work exceeds the limit.")
23
+ if id(value) in seen:
24
+ continue
25
+ seen.add(id(value))
26
+ if isinstance(value, dict):
27
+ children = value.values()
28
+ elif isinstance(value, (list, tuple)):
29
+ children = value
30
+ elif isinstance(value, np.ndarray) and value.dtype.kind == "O":
31
+ children = value.flat
32
+ else:
33
+ yield value
34
+ children = vars(value).values() if isinstance(value, BaseEstimator) else ()
35
+ if len(pending) + len(children) > 100_000:
36
+ raise LimitExceededError("Pipeline state work exceeds the limit.")
37
+ pending.extend(children)
38
+
39
+
40
+ def native_types(root: Any) -> tuple[str, ...]:
41
+ return tuple(
42
+ sorted(
43
+ {
44
+ f"{type(value).__module__}.{type(value).__name__}"
45
+ for value in objects(root)
46
+ if {base.__module__.split(".")[0] for base in type(value).__mro__}
47
+ & {"xgboost", "catboost"}
48
+ }
49
+ )
50
+ )
51
+
52
+
53
+ def composite_candidate(model: Any) -> bool:
54
+ found = native_types(model)
55
+ if not found:
56
+ return False
57
+ if (
58
+ isinstance(model, Pipeline)
59
+ and model.steps
60
+ and {base.__module__.split(".")[0] for base in type(model.steps[-1][1]).__mro__}
61
+ & {"xgboost", "catboost"}
62
+ ):
63
+ if any(native_types(step) for _, step in model.steps[:-1]):
64
+ reject_placement(found)
65
+ validate_options(model)
66
+ return True
67
+ reject_placement(found)
68
+ return False
69
+
70
+
71
+ def reject_placement(types: tuple[str, ...]) -> None:
72
+ raise UnsupportedModelError(
73
+ types,
74
+ reason=(
75
+ "Native estimators are supported only as the last step of the outermost Pipeline. "
76
+ + str(UnsupportedModelError(types))
77
+ ),
78
+ )
79
+
80
+
81
+ def validate_options(model: Any) -> None:
82
+ if (
83
+ type(model) is not Pipeline
84
+ or not isinstance(model.steps, (list, tuple))
85
+ or not 1 <= len(model.steps) <= 128
86
+ ):
87
+ raise UnsupportedModelError(
88
+ reason="Composite models require an exact outer Pipeline with 1 to 128 steps."
89
+ )
90
+ if "_metadata_request" in vars(model) or model.transform_input is not None:
91
+ raise UnsupportedModelError(
92
+ reason="Composite Pipeline transform_input and metadata routing are unsupported."
93
+ )
94
+ if set(vars(model)) != OPTIONS:
95
+ raise InvalidModelError("Unknown outer Pipeline state.")
96
+ if any(
97
+ not isinstance(item, (list, tuple)) or len(item) != 2 for item in model.steps
98
+ ):
99
+ raise UnsupportedModelError(
100
+ reason="Pipeline step names must be unique bounded sklearn names."
101
+ )
102
+ names = [name for name, _ in model.steps]
103
+ if any(
104
+ type(name) is not str or not 1 <= len(name) <= 256 for name in names
105
+ ) or not valid_step_names(names):
106
+ raise UnsupportedModelError(
107
+ reason="Pipeline step names must be unique bounded sklearn names."
108
+ )
109
+ if model.memory is not None:
110
+ raise UnsupportedModelError(
111
+ reason="Composite Pipeline memory must be None; caching is unsupported."
112
+ )
113
+ if type(model.verbose) is not bool:
114
+ raise UnsupportedModelError(
115
+ reason="Composite Pipeline verbose must be a boolean."
116
+ )
117
+
118
+
119
+ def validate_components(model: Pipeline, *, exporting: bool = False) -> None:
120
+ validate_options(model)
121
+ if exporting and get_config()["transform_output"] != "default":
122
+ raise UnsupportedModelError(
123
+ reason="Composite export requires default global transform_output; use per-step set_output."
124
+ )
125
+ for value in objects(model):
126
+ if isinstance(value, BaseEstimator):
127
+ if "_metadata_request" in vars(value):
128
+ raise UnsupportedModelError(
129
+ reason="Composite Pipeline transform_input and metadata routing are unsupported."
130
+ )
131
+ config = vars(value).get("_sklearn_output_config", {})
132
+ if (
133
+ not isinstance(config, dict)
134
+ or set(config) - {"transform"}
135
+ or config.get("transform", "default") not in ("default", "pandas")
136
+ ):
137
+ raise UnsupportedModelError(
138
+ reason="Composite transformers support only default or pandas set_output."
139
+ )
140
+ if isinstance(value, Pipeline) and value is not model:
141
+ validate_options(value)
@@ -0,0 +1,31 @@
1
+ """Bounded polynomial names without sklearn's dense powers_ allocation."""
2
+
3
+ from collections import Counter
4
+ from itertools import combinations, combinations_with_replacement
5
+ from typing import Any
6
+
7
+ from mlbundle.errors import InvalidModelError, LimitExceededError
8
+
9
+
10
+ def polynomial_names(model: Any, names: list[str]) -> list[str]:
11
+ # Count term visits before constructing tuples; unlike powers_, work follows
12
+ # emitted monomials, not output width multiplied by original feature width.
13
+ maximum = (
14
+ min(model._max_degree, len(names))
15
+ if model.interaction_only
16
+ else model._max_degree
17
+ )
18
+ if model.n_output_features_ * max(1, maximum) > 100_000:
19
+ raise LimitExceededError("Transformer feature-name work exceeds the limit.")
20
+ choose = combinations if model.interaction_only else combinations_with_replacement
21
+ result = ["1"] if model.include_bias else []
22
+ for degree in range(max(1, model._min_degree), maximum + 1):
23
+ for term in choose(range(len(names)), degree):
24
+ parts = [
25
+ names[index] + ("" if power == 1 else f"^{power}")
26
+ for index, power in sorted(Counter(term).items())
27
+ ]
28
+ if sum(map(len, parts)) + len(parts) - 1 > 1024:
29
+ raise InvalidModelError("Invalid transformed feature names.")
30
+ result.append(" ".join(parts))
31
+ return result
@@ -0,0 +1,119 @@
1
+ """Tabular prediction with explicit output dimensions and probability validation."""
2
+
3
+ import warnings
4
+ from typing import Any, cast
5
+
6
+ import numpy as np
7
+ from sklearn.compose import ColumnTransformer
8
+ from sklearn.pipeline import FeatureUnion, Pipeline
9
+ from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, TargetEncoder
10
+
11
+ from mlbundle._prediction_input import tabular
12
+ from mlbundle.adapters import ProbabilityOutput, RegressionOutput
13
+ from mlbundle.claims import ModelClaims
14
+ from mlbundle.contract import ModelKind
15
+ from mlbundle.errors import InvalidModelError, MLBundleError
16
+
17
+
18
+ def _string_inputs(model: Any) -> bool:
19
+ pending = [model]
20
+ seen: set[int] = set()
21
+ while pending:
22
+ value = pending.pop()
23
+ if id(value) in seen:
24
+ continue
25
+ seen.add(id(value))
26
+ if isinstance(
27
+ value, (OneHotEncoder, OrdinalEncoder, TargetEncoder)
28
+ ) and hasattr(value, "categories_"):
29
+ return True
30
+ if isinstance(value, Pipeline):
31
+ pending.extend(step for _, step in value.steps)
32
+ elif isinstance(value, ColumnTransformer):
33
+ pending.extend(step for _, step, _ in getattr(value, "transformers_", []))
34
+ elif isinstance(value, FeatureUnion):
35
+ pending.extend(step for _, step in value.transformer_list)
36
+ return False
37
+
38
+
39
+ class Predictor:
40
+ def __init__(self, model: Any, claims: ModelClaims) -> None:
41
+ self.model = model
42
+ self.claims = claims
43
+ self.strings = isinstance(self.model, Pipeline) and _string_inputs(self.model)
44
+
45
+ def _data(self, data: object) -> tuple[object, int]:
46
+ return tabular(data, self.claims.n_features, strings=self.strings)
47
+
48
+ def _call(self, data: object, *, probabilities: bool) -> Any:
49
+ # Positional arrays/lists remain supported for named models; sklearn
50
+ # still enforces DataFrame name/order when those names are supplied.
51
+ try:
52
+ return self._native_call(data, probabilities=probabilities)
53
+ except MLBundleError:
54
+ raise
55
+ except Exception:
56
+ raise InvalidModelError("Sklearn prediction failed.") from None
57
+
58
+ def _native_call(self, data: object, *, probabilities: bool) -> Any:
59
+ with warnings.catch_warnings():
60
+ warnings.filterwarnings(
61
+ "ignore",
62
+ message="X does not have valid feature names, but .*",
63
+ category=UserWarning,
64
+ )
65
+ warnings.filterwarnings(
66
+ "ignore", message="X has feature names, but .*", category=UserWarning
67
+ )
68
+ raw = (
69
+ self.model.predict_proba(data)
70
+ if probabilities
71
+ else self.model.predict(data)
72
+ )
73
+ if probabilities:
74
+ return (
75
+ [np.asarray(item, dtype=float) for item in raw]
76
+ if isinstance(raw, list)
77
+ else np.asarray(raw, dtype=float)
78
+ )
79
+ return np.asarray(raw, dtype=float)
80
+
81
+ def predict(self, data: object) -> RegressionOutput:
82
+ if self.claims.kind != ModelKind.REGRESSOR:
83
+ raise ValueError("predict requires a regressor.")
84
+ data, rows = self._data(data)
85
+ result = np.asarray(self._call(data, probabilities=False), dtype=float)
86
+ expected = (
87
+ (rows,) if self.claims.n_outputs == 1 else (rows, self.claims.n_outputs)
88
+ )
89
+ if self.claims.n_outputs == 1 and result.shape == (rows, 1):
90
+ result = result[:, 0]
91
+ if result.shape != expected or not np.isfinite(result).all():
92
+ raise InvalidModelError(
93
+ "Invalid regression prediction dimensions or values."
94
+ )
95
+ return cast(RegressionOutput, result.tolist())
96
+
97
+ def predict_proba(self, data: object) -> ProbabilityOutput:
98
+ if self.claims.kind != ModelKind.CLASSIFIER:
99
+ raise ValueError("predict_proba requires a classifier.")
100
+ data, rows = self._data(data)
101
+ raw = self._call(data, probabilities=True)
102
+ matrices = raw if isinstance(raw, list) else [raw]
103
+ if not isinstance(matrices, list) or len(matrices) != self.claims.n_outputs:
104
+ raise InvalidModelError("Invalid probability output count.")
105
+ result: list[list[list[float]]] = []
106
+ for matrix, classes in zip(matrices, self.claims.n_classes or [], strict=True):
107
+ values = np.asarray(matrix, dtype=float)
108
+ if (
109
+ values.shape != (rows, classes)
110
+ or not np.isfinite(values).all()
111
+ or np.any(values < 0)
112
+ or np.any(values > 1)
113
+ or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-7, atol=1e-8)
114
+ ):
115
+ raise InvalidModelError(
116
+ "Invalid probability dimensions, values or row sums."
117
+ )
118
+ result.append(values.tolist())
119
+ return result[0] if self.claims.n_outputs == 1 else result
@@ -0,0 +1,61 @@
1
+ """Readable composition facts supplement complete fitted-state commitments."""
2
+
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import skops.io as sio
7
+ from sklearn.base import BaseEstimator
8
+ from sklearn.dummy import DummyClassifier, DummyRegressor
9
+
10
+ from .parameters import parameters
11
+ from .payload import validate_payload
12
+
13
+
14
+ def prediction_state(model: Any) -> dict[str, Any]:
15
+ state = {}
16
+ if hasattr(model, "estimators_"):
17
+ estimators = np.asarray(model.estimators_, dtype=object)
18
+ state["estimator_labels"] = [
19
+ getattr(estimator, "classes_", None) for estimator in estimators.flat
20
+ ]
21
+ for attribute in (
22
+ "estimator_weights_",
23
+ "estimators_features_",
24
+ "_x_mean",
25
+ "_x_std",
26
+ ):
27
+ if hasattr(model, attribute):
28
+ state[attribute] = getattr(model, attribute)
29
+ loss = getattr(model, "_loss", getattr(model, "_base_loss", None))
30
+ if loss is not None:
31
+ state["loss"] = {
32
+ "class": type(loss).__name__,
33
+ "link": type(loss.link).__name__,
34
+ "n_classes": loss.n_classes,
35
+ }
36
+ if hasattr(model, "init_"):
37
+ initializer = model.init_
38
+ if isinstance(initializer, BaseEstimator):
39
+ state["initializer"] = {
40
+ "class": type(initializer).__name__,
41
+ "parameters": parameters(initializer),
42
+ "state_sha256": validate_payload(sio.dumps(initializer)),
43
+ }
44
+ if isinstance(initializer, (DummyClassifier, DummyRegressor)):
45
+ state["initializer"]["values"] = {
46
+ name: getattr(initializer, name)
47
+ for name in (
48
+ "classes_",
49
+ "class_prior_",
50
+ "constant_",
51
+ "n_classes_",
52
+ "n_outputs_",
53
+ )
54
+ if hasattr(initializer, name)
55
+ }
56
+ else:
57
+ state["initializer"] = initializer
58
+ state["learning_rate"] = model.learning_rate
59
+ if hasattr(model, "_probA"):
60
+ state["probability_calibration"] = {"A": model._probA, "B": model._probB}
61
+ return state
@@ -0,0 +1,110 @@
1
+ """Bound skops' recursive audit work before it builds or audits shared nodes."""
2
+
3
+ from typing import Any
4
+
5
+ from mlbundle.canonical import canonical_dumps
6
+ from mlbundle.errors import InvalidModelError
7
+
8
+ MAX_NODES = 100_000
9
+ MAX_REFERENCES = 100_000
10
+ MAX_VISITS = 100_000
11
+ MAX_DEPTH = 32
12
+
13
+
14
+ def _without_ids(value: Any) -> Any:
15
+ if isinstance(value, dict):
16
+ return {
17
+ key: _without_ids(child) for key, child in value.items() if key != "__id__"
18
+ }
19
+ if isinstance(value, list):
20
+ return [_without_ids(child) for child in value]
21
+ return value
22
+
23
+
24
+ class References:
25
+ def __init__(self, schema: Any) -> None:
26
+ self.nodes: list[dict[str, Any]] = []
27
+ self.memo: dict[int, dict[str, Any]] = {}
28
+ pending = [schema]
29
+ self.reference_count = 0
30
+ while pending:
31
+ value = pending.pop()
32
+ if isinstance(value, dict):
33
+ if "__loader__" in value:
34
+ self.nodes.append(value)
35
+ if len(self.nodes) > MAX_NODES:
36
+ raise InvalidModelError("Skops node count exceeds the limit.")
37
+ self._record(value)
38
+ pending.extend(reversed(list(value.values())))
39
+ elif isinstance(value, list):
40
+ pending.extend(reversed(value))
41
+ self._work(schema)
42
+
43
+ def _record(self, value: dict[str, Any]) -> None:
44
+ identity = value.get("__id__")
45
+ if identity is not None and (type(identity) is not int or identity <= 0):
46
+ raise InvalidModelError("Invalid skops object identity.")
47
+ if value["__loader__"] == "CachedNode":
48
+ self.reference_count += 1
49
+ if self.reference_count > MAX_REFERENCES:
50
+ raise InvalidModelError("Skops reference count exceeds the limit.")
51
+ elif identity is not None:
52
+ previous = self.memo.get(identity)
53
+ if previous is not None and (
54
+ previous["__loader__"] != value["__loader__"]
55
+ or (
56
+ value["__loader__"] in ("JsonNode", "NdArrayNode")
57
+ and canonical_dumps(_without_ids(previous))
58
+ != canonical_dumps(_without_ids(value))
59
+ )
60
+ ):
61
+ raise InvalidModelError("Conflicting skops object references.")
62
+ self.memo.setdefault(identity, value)
63
+
64
+ def resolve(self, value: Any) -> dict[str, Any]:
65
+ if not isinstance(value, dict):
66
+ raise InvalidModelError("Invalid skops node reference.")
67
+ if value.get("__loader__") == "CachedNode":
68
+ identity = value.get("__id__")
69
+ if type(identity) is not int:
70
+ raise InvalidModelError("Invalid skops object identity.")
71
+ target = self.memo.get(identity)
72
+ if target is None:
73
+ raise InvalidModelError("Unknown skops object reference.")
74
+ if (target.get("__module__"), target.get("__class__")) != (
75
+ value.get("__module__"),
76
+ value.get("__class__"),
77
+ ):
78
+ raise InvalidModelError("Conflicting skops reference type.")
79
+ return target
80
+ return value
81
+
82
+ def _work(self, schema: Any) -> None:
83
+ memo: dict[int, tuple[int, int]] = {}
84
+ active: set[int] = set()
85
+
86
+ def cost(value):
87
+ if not isinstance(value, (dict, list)):
88
+ return 0, 0
89
+ value = self.resolve(value) if isinstance(value, dict) else value
90
+ identity = id(value)
91
+ if identity in active:
92
+ raise InvalidModelError("Cyclic skops object references.")
93
+ if identity in memo:
94
+ return memo[identity]
95
+ active.add(identity)
96
+ visits, depth = 1, 1
97
+ children = value.values() if isinstance(value, dict) else value
98
+ for child in children:
99
+ child_visits, child_depth = cost(child)
100
+ visits += child_visits
101
+ depth = max(depth, child_depth + 1)
102
+ if visits > MAX_VISITS or depth > MAX_DEPTH:
103
+ raise InvalidModelError(
104
+ "Skops expanded node visits or depth exceed the limit."
105
+ )
106
+ active.remove(identity)
107
+ memo[identity] = visits, depth
108
+ return visits, depth
109
+
110
+ cost(schema)
@@ -0,0 +1,80 @@
1
+ """One owned skops byte boundary for estimators and transformer components."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import skops.io as sio
6
+
7
+ from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError
8
+
9
+ from .payload import validate_payload
10
+ from .trusted import TRUSTED_TYPES
11
+
12
+
13
+ def audit_types(data: bytes) -> None:
14
+ unsupported = tuple(
15
+ sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES))
16
+ )
17
+ if unsupported:
18
+ raise UnsupportedModelError(unsupported)
19
+
20
+
21
+ class SkopsIO:
22
+ """Reuse checked snapshots inside one operation; never across public calls."""
23
+
24
+ def __init__(
25
+ self,
26
+ validate: Callable[[object], None],
27
+ payload: Callable[[bytes], str] = validate_payload,
28
+ ) -> None:
29
+ self.validate = validate
30
+ self.payload = payload
31
+ self._states: list[tuple[object, str]] = []
32
+ self._payload: bytes | None = None
33
+ self._digest: str | None = None
34
+
35
+ def state(self, model: object) -> str:
36
+ for native, digest in self._states:
37
+ if native is model:
38
+ return digest
39
+ return self.payload(sio.dumps(model))
40
+
41
+ def serialize(self, model: object) -> bytes:
42
+ try:
43
+ self._states = []
44
+ self._payload, self._digest = None, None
45
+ data = sio.dumps(model)
46
+ try:
47
+ digest = self.payload(data)
48
+ except UnsupportedModelError:
49
+ audit_types(data)
50
+ raise
51
+ audit_types(data)
52
+ self.validate(model)
53
+ self._states = [(model, digest)]
54
+ self._payload, self._digest = data, digest
55
+ return data
56
+ except MLBundleError:
57
+ raise
58
+ except Exception:
59
+ raise UnsupportedModelError(
60
+ reason="Model cannot be serialized safely with skops."
61
+ ) from None
62
+
63
+ def deserialize(self, data: bytes) -> object:
64
+ try:
65
+ expected = self._digest if data is self._payload else self.payload(data)
66
+ self._payload, self._digest = None, None
67
+ audit_types(data)
68
+ model = sio.loads(data, trusted=list(TRUSTED_TYPES))
69
+ self.validate(model)
70
+ actual = self.payload(sio.dumps(model))
71
+ if expected != actual:
72
+ raise InvalidModelError(
73
+ "Skops state does not survive native construction."
74
+ )
75
+ self._states = [*self._states[-1:], (model, actual)]
76
+ return model
77
+ except MLBundleError:
78
+ raise
79
+ except Exception:
80
+ raise InvalidModelError("Invalid skops model payload.") from None
@@ -0,0 +1,144 @@
1
+ """Closed sklearn state keys and a content digest independent of skops object IDs."""
2
+
3
+ import hashlib
4
+ import json
5
+ from importlib.resources import files
6
+ from io import BytesIO
7
+ from typing import Any
8
+ from zipfile import ZipFile
9
+
10
+ import numpy as np
11
+
12
+ from mlbundle.canonical import canonical_dumps
13
+ from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError
14
+ from mlbundle.limits import MANIFEST_BYTES
15
+
16
+ FIELDS = json.loads(files(__package__).joinpath("state_fields.json").read_text())
17
+
18
+
19
+ def _buffer_digest(data: bytes) -> str:
20
+ if data.startswith(b"PK"):
21
+ with ZipFile(BytesIO(data)) as archive:
22
+ record = {
23
+ name: _buffer_digest(archive.read(name)) for name in archive.namelist()
24
+ }
25
+ return hashlib.sha256(canonical_dumps(record)).hexdigest()
26
+ array = np.load(BytesIO(data), allow_pickle=False)
27
+ if array.dtype.names:
28
+ # Native Tree records have uninitialized padding; only named fields
29
+ # participate in inference and survive the native round trip.
30
+ record = {name: _numeric_digest(array[name]) for name in array.dtype.names}
31
+ return hashlib.sha256(canonical_dumps(record)).hexdigest()
32
+ return _numeric_digest(array)
33
+
34
+
35
+ def _numeric_digest(array: np.ndarray) -> str:
36
+ if array.dtype.kind == "f" and np.isnan(array).any():
37
+ array = array.copy()
38
+ array[np.isnan(array)] = np.nan
39
+ digest = hashlib.sha256(canonical_dumps([array.dtype.str, list(array.shape)]))
40
+ digest.update(np.ascontiguousarray(array).tobytes())
41
+ return digest.hexdigest()
42
+
43
+
44
+ def _hash(domain: bytes, value: Any) -> str:
45
+ if domain == b"scalar:" or (isinstance(value, dict) and "$float" in value):
46
+ encoded = canonical_dumps(value)
47
+ else:
48
+ # These records contain only checked string keys and fixed-width hex digests.
49
+ size = (
50
+ sum(len(key) + 70 for key in value)
51
+ if isinstance(value, dict)
52
+ else len(value) * 67
53
+ if isinstance(value, list)
54
+ else 66
55
+ ) + 1
56
+ if size > MANIFEST_BYTES:
57
+ raise LimitExceededError("JSON exceeds the byte limit.")
58
+ encoded = json.dumps(
59
+ value,
60
+ sort_keys=True,
61
+ ensure_ascii=False,
62
+ allow_nan=False,
63
+ separators=(",", ":"),
64
+ ).encode("utf-8")
65
+ if len(encoded) > MANIFEST_BYTES:
66
+ raise LimitExceededError("JSON exceeds the byte limit.")
67
+ return hashlib.sha256(domain + encoded).hexdigest()
68
+
69
+
70
+ def validate_state(nodes: list[dict[str, Any]], resolve) -> None:
71
+ for node in nodes:
72
+ if node.get("__loader__") != "ObjectNode":
73
+ continue
74
+ name = node.get("__module__", "") + "." + node.get("__class__", "")
75
+ if name not in FIELDS:
76
+ raise UnsupportedModelError((name,))
77
+ state = resolve(node.get("content"))
78
+ if state.get("__loader__") == "JsonNode" and state.get("content") == "null":
79
+ continue
80
+ if state.get("__loader__") != "DictNode":
81
+ raise InvalidModelError("Invalid sklearn object state.")
82
+ attrs = state.get("content")
83
+ if not isinstance(attrs, dict) or set(attrs) - set(FIELDS[name]):
84
+ raise InvalidModelError("Unknown sklearn estimator state.")
85
+ # DictNode otherwise silently truncates attributes through zip().
86
+ keys = resolve(state.get("key_types"))
87
+ if keys.get("__loader__") != "ListNode" or len(keys["content"]) != len(attrs):
88
+ raise InvalidModelError("Invalid sklearn state key types.")
89
+ for raw_key in keys["content"]:
90
+ key = resolve(raw_key)
91
+ if key.get("__loader__") != "TypeNode" or (
92
+ key.get("__module__"),
93
+ key.get("__class__"),
94
+ ) != ("builtins", "str"):
95
+ raise InvalidModelError("Sklearn state keys must be strings.")
96
+
97
+
98
+ def _memoized_hash(cache, key, domain: bytes, value) -> str:
99
+ if key not in cache:
100
+ cache[key] = _hash(domain, value)
101
+ return cache[key]
102
+
103
+
104
+ def _record_key(value):
105
+ if isinstance(value, dict):
106
+ return tuple(sorted(value.items()))
107
+ return tuple(value) if isinstance(value, list) else value
108
+
109
+
110
+ def state_digest(schema: Any, archive, resolve) -> str:
111
+ """Merkle hash all state and buffers, without expanding shared references."""
112
+ memo: dict[int, str] = {}
113
+ buffers: dict[str, str] = {}
114
+ scalars: dict[tuple[type, Any], str] = {}
115
+ records: dict[tuple, str] = {}
116
+
117
+ def record(domain: bytes, value) -> str:
118
+ key = (domain, _record_key(value))
119
+ return _memoized_hash(records, key, domain, value)
120
+
121
+ def digest(value: Any) -> str:
122
+ if isinstance(value, dict):
123
+ node = resolve(value) if "__loader__" in value else value
124
+ if id(node) in memo:
125
+ return memo[id(node)]
126
+ content = {}
127
+ for key, child in node.items():
128
+ if key == "__id__":
129
+ continue
130
+ if key == "file":
131
+ if child not in buffers:
132
+ buffers[child] = _buffer_digest(archive.read(child))
133
+ content[key] = record(b"file:", buffers[child])
134
+ else:
135
+ content[key] = digest(child)
136
+ result = record(b"dict:", content)
137
+ memo[id(node)] = result
138
+ return result
139
+ if isinstance(value, list):
140
+ return record(b"list:", [digest(child) for child in value])
141
+ key = (type(value), value.hex() if type(value) is float else value)
142
+ return _memoized_hash(scalars, key, b"scalar:", value)
143
+
144
+ return digest(schema)