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,252 @@
1
+ """Native graph signatures, output contracts and original label claims."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, cast
5
+
6
+ from pydantic import JsonValue
7
+
8
+ from mlbundle.canonical import canonical_dumps
9
+ from mlbundle.claims import ClassLabels, ModelClaims
10
+ from mlbundle.contract import ModelKind
11
+ from mlbundle.environment import OnnxInfo, Opset
12
+ from mlbundle.errors import InvalidModelError, LimitExceededError
13
+ from mlbundle.limits import HYPERPARAMETER_BYTES, MAX_CLASSES
14
+
15
+ from .signatures import _batch, _inputs, signature
16
+
17
+ LABEL_OPS = frozenset(
18
+ {"ZipMap", "LinearClassifier", "TreeEnsembleClassifier", "SVMClassifier"}
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Description:
24
+ claims: ModelClaims
25
+ inputs: tuple[dict[str, Any], ...]
26
+ output: str
27
+ maps: bool
28
+
29
+
30
+ def _ancestors(graph: Any, output: str) -> list[Any]:
31
+ producers = {name: node for node in graph.node for name in node.output if name}
32
+ pending, seen, result = [output], set(), []
33
+ while pending:
34
+ name = pending.pop()
35
+ if name in seen:
36
+ continue
37
+ seen.add(name)
38
+ node = producers.get(name)
39
+ if node is not None:
40
+ result.append(node)
41
+ pending.extend(node.input)
42
+ return result
43
+
44
+
45
+ def _labels(nodes: list[Any]) -> ClassLabels | None:
46
+ found = []
47
+ for node in nodes:
48
+ if node.domain != "ai.onnx.ml" or node.op_type not in LABEL_OPS:
49
+ continue
50
+ for attr in node.attribute:
51
+ if attr.name == "classlabels_strings":
52
+ found.append([value.decode("utf-8") for value in attr.strings])
53
+ elif attr.name in ("classlabels_ints", "classlabels_int64s"):
54
+ found.append(list(attr.ints))
55
+ if not found:
56
+ return None
57
+ if any(
58
+ canonical_dumps(labels) != canonical_dumps(found[0]) for labels in found[1:]
59
+ ):
60
+ raise InvalidModelError("ONNX native label records disagree.")
61
+ return cast(ClassLabels, [found[0]])
62
+
63
+
64
+ def _native_labels(graph: Any, output: str) -> ClassLabels | None:
65
+ producers = {name: node for node in graph.node for name in node.output if name}
66
+ original = output
67
+ for _ in range(len(graph.node)):
68
+ node = producers.get(output)
69
+ if node is None:
70
+ return None
71
+ if node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS:
72
+ if node.op_type == "ZipMap" or (
73
+ len(node.output) > 1 and output == node.output[1]
74
+ ):
75
+ return _labels([node])
76
+ break
77
+ if (
78
+ node.domain in ("", "ai.onnx")
79
+ and node.op_type in ("Identity", "Cast")
80
+ and len(node.input) == 1
81
+ ):
82
+ output = node.input[0]
83
+ else:
84
+ break
85
+ if any(
86
+ node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS
87
+ for node in _ancestors(graph, original)
88
+ ):
89
+ raise InvalidModelError(
90
+ "Unsupported transformation of ONNX labeled probabilities."
91
+ )
92
+ return None
93
+
94
+
95
+ def _softmax(graph: Any, output: str) -> bool:
96
+ producers = {name: node for node in graph.node for name in node.output if name}
97
+ for _ in range(len(graph.node)):
98
+ node = producers.get(output)
99
+ if node is None or node.domain not in ("", "ai.onnx"):
100
+ return False
101
+ if node.op_type == "Softmax":
102
+ axis = next((attr.i for attr in node.attribute if attr.name == "axis"), -1)
103
+ return axis in (1, -1)
104
+ if node.op_type not in ("Identity", "Cast") or len(node.input) != 1:
105
+ return False
106
+ output = node.input[0]
107
+ return False
108
+
109
+
110
+ def _probability(value: dict[str, Any]) -> bool:
111
+ if value["dtype"].startswith("sequence<map<"):
112
+ return True
113
+ shape = value["shape"]
114
+ return (
115
+ value["dtype"] in ("float32", "float64")
116
+ and shape is not None
117
+ and len(shape) == 2
118
+ and _batch(shape)
119
+ and (not isinstance(shape[1], int) or 1 <= shape[1] <= MAX_CLASSES)
120
+ )
121
+
122
+
123
+ def _label_output(value: dict[str, Any]) -> bool:
124
+ shape = value["shape"]
125
+ return (
126
+ value["dtype"] in ("int64", "string")
127
+ and shape is not None
128
+ and _batch(shape)
129
+ and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1))
130
+ )
131
+
132
+
133
+ def _regression_width(graph: Any, output: str) -> None:
134
+ producers = {name: node for node in graph.node for name in node.output if name}
135
+ for _ in range(len(graph.node)):
136
+ node = producers.get(output)
137
+ if node is None:
138
+ return
139
+ if node.domain == "ai.onnx.ml" and node.op_type in (
140
+ "LinearRegressor",
141
+ "TreeEnsembleRegressor",
142
+ ):
143
+ attr_name = "targets" if node.op_type == "LinearRegressor" else "n_targets"
144
+ width = next(
145
+ (attr.i for attr in node.attribute if attr.name == attr_name), 1
146
+ )
147
+ if width != 1:
148
+ raise InvalidModelError(
149
+ "ONNX native regression operator must expose one target."
150
+ )
151
+ return
152
+ if (
153
+ node.domain not in ("", "ai.onnx")
154
+ or node.op_type not in ("Identity", "Cast")
155
+ or len(node.input) != 1
156
+ ):
157
+ return
158
+ output = node.input[0]
159
+
160
+
161
+ def _outputs(
162
+ graph: Any, outputs: list[dict[str, Any]]
163
+ ) -> tuple[ModelKind, dict[str, Any], ClassLabels | None, int | None]:
164
+ if len(outputs) == 2:
165
+ candidates = [
166
+ value
167
+ for index, value in enumerate(outputs)
168
+ if _probability(value) and _label_output(outputs[1 - index])
169
+ ]
170
+ elif len(outputs) == 1:
171
+ value = outputs[0]
172
+ evidence = _native_labels(graph, value["name"]) is not None or _softmax(
173
+ graph, value["name"]
174
+ )
175
+ candidates = [value] if _probability(value) and evidence else []
176
+ else:
177
+ candidates = []
178
+ if len(candidates) == 1:
179
+ value = candidates[0]
180
+ labels = _native_labels(graph, value["name"])
181
+ count = value["shape"][1] if value["shape"] is not None else None
182
+ if not isinstance(count, int):
183
+ count = len(labels[0]) if labels else None
184
+ if count is None or (labels is not None and len(labels[0]) != count):
185
+ raise InvalidModelError(
186
+ "ONNX probability width and native labels disagree."
187
+ )
188
+ return ModelKind.CLASSIFIER, value, labels, count
189
+ if len(outputs) == 1:
190
+ value = outputs[0]
191
+ shape = value["shape"]
192
+ if (
193
+ value["dtype"] in ("float32", "float64")
194
+ and shape is not None
195
+ and _batch(shape)
196
+ and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1))
197
+ ):
198
+ _regression_width(graph, value["name"])
199
+ return ModelKind.REGRESSOR, value, None, None
200
+ raise InvalidModelError(
201
+ "ONNX kind or probability/regression output layout is indeterminate."
202
+ )
203
+
204
+
205
+ def describe(model: Any) -> Description:
206
+ graph = model.graph
207
+ if not 1 <= len(graph.output) <= 2:
208
+ raise InvalidModelError(
209
+ "ONNX requires one regression or probability output, optionally with labels."
210
+ )
211
+ inputs = _inputs(graph)
212
+ outputs = [signature(value) for value in graph.output]
213
+ kind, output, labels, count = _outputs(graph, outputs)
214
+ width = sum(value["shape"][1] for value in inputs)
215
+ metadata = {item.key: item.value for item in model.metadata_props}
216
+ if len(metadata) != len(model.metadata_props):
217
+ raise InvalidModelError("Duplicate ONNX metadata keys.")
218
+ projection = {
219
+ "inputs": list(inputs),
220
+ "outputs": outputs,
221
+ "metadata_props": metadata,
222
+ }
223
+ try:
224
+ canonical_dumps(projection, max_bytes=HYPERPARAMETER_BYTES, max_depth=16)
225
+ except LimitExceededError:
226
+ raise LimitExceededError(
227
+ "ONNX hyperparameters (inputs/outputs/metadata_props) exceed the "
228
+ f"{HYPERPARAMETER_BYTES}-byte or depth-16 limit."
229
+ ) from None
230
+ claims = ModelClaims(
231
+ estimator_class="ONNX",
232
+ kind=kind,
233
+ hyperparameters=cast(dict[str, JsonValue], projection),
234
+ n_features=width,
235
+ n_classes=None if count is None else [count],
236
+ n_outputs=1,
237
+ feature_names=[value["name"] for value in inputs] if len(inputs) > 1 else None,
238
+ feature_dtypes=[
239
+ value["dtype"] for value in inputs for _ in range(value["shape"][1])
240
+ ],
241
+ class_labels=labels,
242
+ onnx=OnnxInfo(
243
+ opsets=[
244
+ Opset(domain=item.domain, version=item.version)
245
+ for item in sorted(model.opset_import, key=lambda item: item.domain)
246
+ ],
247
+ ir_version=model.ir_version,
248
+ producer_name=model.producer_name,
249
+ producer_version=model.producer_version,
250
+ ),
251
+ )
252
+ return Description(claims, inputs, output["name"], output["shape"] is None)
@@ -0,0 +1,136 @@
1
+ """Numeric tabular feeds and validated tensor/ZipMap outputs, entirely in memory."""
2
+
3
+ import math
4
+ import warnings
5
+ from typing import Any, cast
6
+
7
+ import numpy as np
8
+
9
+ from mlbundle.adapters import ProbabilityOutput, RegressionOutput
10
+ from mlbundle.contract import ModelKind
11
+ from mlbundle.errors import InvalidModelError, PredictionInputError
12
+
13
+ from .description import Description
14
+
15
+ _InputShapeError = PredictionInputError
16
+
17
+
18
+ def _array(data: Any, dtype: str) -> np.ndarray:
19
+ raw = np.asarray(data, dtype=object)
20
+ if np.dtype(dtype).kind in "iu":
21
+ limits = np.iinfo(dtype)
22
+ for value in raw.flat:
23
+ if (
24
+ isinstance(value, (bool, np.bool_))
25
+ or not isinstance(value, (int, float, np.integer, np.floating))
26
+ or not math.isfinite(value)
27
+ or int(value) != value
28
+ or not limits.min <= int(value) <= limits.max
29
+ ):
30
+ raise InvalidModelError(
31
+ "ONNX integer inputs require whole numbers within the dtype range."
32
+ )
33
+ elif any(
34
+ isinstance(value, (bool, np.bool_))
35
+ or not isinstance(value, (int, float, np.integer, np.floating))
36
+ for value in raw.flat
37
+ ):
38
+ raise InvalidModelError("ONNX inputs require finite numeric values.")
39
+ with warnings.catch_warnings():
40
+ warnings.simplefilter("error", RuntimeWarning)
41
+ values = np.asarray(raw, dtype=dtype)
42
+ if not np.isfinite(values).all():
43
+ raise InvalidModelError("ONNX inputs require finite numeric values.")
44
+ return values
45
+
46
+
47
+ class Predictor:
48
+ def __init__(self, session: Any, description: Description) -> None:
49
+ self.session = session
50
+ self.description = description
51
+ self.claims = description.claims
52
+
53
+ def _feeds(self, data: object) -> tuple[dict[str, np.ndarray], int]:
54
+ frame = None
55
+ if any(
56
+ base.__module__.split(".")[0] == "pandas" for base in type(data).__mro__
57
+ ):
58
+ import pandas as pd # noqa: PLC0415
59
+
60
+ if isinstance(data, pd.DataFrame):
61
+ frame = data
62
+ values = np.asarray(data, dtype=object)
63
+ if values.ndim != 2 or values.shape[1] != self.claims.n_features:
64
+ raise _InputShapeError(
65
+ "Prediction input must have the model's feature width."
66
+ )
67
+ rows = values.shape[0]
68
+ feeds = {}
69
+ for index, value in enumerate(self.description.inputs):
70
+ shape = value["shape"]
71
+ if isinstance(shape[0], int) and rows != shape[0]:
72
+ raise _InputShapeError(
73
+ "Prediction row count must match the ONNX static batch."
74
+ )
75
+ if len(self.description.inputs) == 1:
76
+ column = values
77
+ elif frame is not None:
78
+ if not frame.columns.is_unique or value["name"] not in frame.columns:
79
+ raise InvalidModelError(
80
+ "ONNX DataFrame columns must match model input names."
81
+ )
82
+ column = frame[[value["name"]]].to_numpy(dtype=object)
83
+ else:
84
+ column = values[:, index : index + 1]
85
+ feeds[value["name"]] = _array(column, value["dtype"])
86
+ return feeds, rows
87
+
88
+ def _run(self, data: object) -> tuple[Any, int]:
89
+ try:
90
+ feeds, rows = self._feeds(data)
91
+ return self.session.run([self.description.output], feeds)[0], rows
92
+ except (InvalidModelError, _InputShapeError):
93
+ raise
94
+ except Exception:
95
+ raise InvalidModelError(
96
+ "ONNX prediction failed or input values are invalid."
97
+ ) from None
98
+
99
+ def predict(self, data: object) -> RegressionOutput:
100
+ if self.claims.kind != ModelKind.REGRESSOR:
101
+ raise ValueError("predict requires a regressor.")
102
+ raw, rows = self._run(data)
103
+ try:
104
+ values = np.asarray(raw, dtype=float)
105
+ if values.shape == (rows, 1):
106
+ values = values[:, 0]
107
+ if values.shape != (rows,) or not np.isfinite(values).all():
108
+ raise ValueError
109
+ return cast(RegressionOutput, values.tolist())
110
+ except Exception:
111
+ raise InvalidModelError("Invalid ONNX regression output.") from None
112
+
113
+ def predict_proba(self, data: object) -> ProbabilityOutput:
114
+ if self.claims.kind != ModelKind.CLASSIFIER:
115
+ raise ValueError("predict_proba requires a classifier.")
116
+ raw, rows = self._run(data)
117
+ try:
118
+ if self.description.maps:
119
+ labels = (self.claims.class_labels or [[]])[0]
120
+ if not isinstance(raw, list) or any(
121
+ not isinstance(row, dict) or set(row) != set(labels) for row in raw
122
+ ):
123
+ raise ValueError
124
+ raw = [[row[label] for label in labels] for row in raw]
125
+ values = np.asarray(raw, dtype=float)
126
+ if (
127
+ values.shape != (rows, (self.claims.n_classes or [0])[0])
128
+ or not np.isfinite(values).all()
129
+ or np.any(values < 0)
130
+ or np.any(values > 1)
131
+ or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-5)
132
+ ):
133
+ raise ValueError
134
+ return cast(ProbabilityOutput, values.tolist())
135
+ except Exception:
136
+ raise InvalidModelError("Invalid ONNX probability output.") from None
@@ -0,0 +1,111 @@
1
+ """Wire bounds before protobuf constructs objects; no tensor decoding or file I/O."""
2
+
3
+ from typing import Any
4
+
5
+ from mlbundle.errors import InvalidModelError, LimitExceededError
6
+ from mlbundle.limits import ArchiveLimits
7
+
8
+ from ._wire_runs import packed_varints, repeated
9
+
10
+ # Each field/header or packed scalar consumes at least one distinct wire byte.
11
+ MAX_FIELDS = ArchiveLimits().max_member_bytes
12
+ MAX_DEPTH = 32
13
+
14
+
15
+ def _varint(data: bytes, position: int, end: int) -> tuple[int, int]:
16
+ value = 0
17
+ for shift in range(0, 70, 7):
18
+ if position >= end:
19
+ raise InvalidModelError("Truncated ONNX protobuf.")
20
+ byte = data[position]
21
+ position += 1
22
+ value |= (byte & 127) << shift
23
+ if byte < 128:
24
+ if shift == 63 and byte > 1:
25
+ break
26
+ return value, position
27
+ raise InvalidModelError("Invalid ONNX protobuf integer.")
28
+
29
+
30
+ def validate_wire(data: bytes, descriptor: Any) -> None:
31
+ if not data:
32
+ raise InvalidModelError("Empty ONNX model.")
33
+ if len(data) > ArchiveLimits().max_member_bytes:
34
+ raise LimitExceededError("ONNX model exceeds the byte limit.")
35
+ budget = [0]
36
+ _message(data, 0, len(data), descriptor, depth=0, budget=budget)
37
+
38
+
39
+ def _message(data, position, end, descriptor, *, depth, budget):
40
+ if depth > MAX_DEPTH:
41
+ raise LimitExceededError("ONNX protobuf nesting exceeds the limit.")
42
+ seen = set()
43
+ while position < end:
44
+ budget[0] += 1
45
+ if budget[0] > MAX_FIELDS:
46
+ raise LimitExceededError("ONNX protobuf field count exceeds the limit.")
47
+ start = position
48
+ tag, position = _varint(data, position, end)
49
+ number, wire = tag >> 3, tag & 7
50
+ field = descriptor.fields_by_number.get(number)
51
+ if field is None or (not field.is_repeated and number in seen):
52
+ raise InvalidModelError("Unknown or duplicate ONNX protobuf field.")
53
+ seen.add(number)
54
+ value_start = position
55
+ position = _field(data, position, end, field, wire, depth=depth, budget=budget)
56
+ if field.is_repeated and (wire != 2 or field.type in (9, 12)):
57
+ position, count = repeated(
58
+ data, start, end, value_start=value_start, stop=position, wire=wire
59
+ )
60
+ budget[0] += count - 1
61
+ if budget[0] > MAX_FIELDS:
62
+ raise LimitExceededError("ONNX protobuf field count exceeds the limit.")
63
+
64
+
65
+ def _field(data, position, end, field, wire, *, depth, budget):
66
+ expected = (
67
+ 2
68
+ if field.type in (9, 11, 12)
69
+ else {1: 1, 2: 5, 6: 1, 7: 5, 15: 5, 16: 1}.get(field.type, 0)
70
+ )
71
+ packed = field.is_repeated and expected != 2 and wire == 2
72
+ if wire != expected and not packed:
73
+ raise InvalidModelError("Invalid ONNX protobuf wire type.")
74
+ if wire == 0:
75
+ return _varint(data, position, end)[1]
76
+ if wire in (1, 5):
77
+ stop = position + (8 if wire == 1 else 4)
78
+ else:
79
+ size, position = _varint(data, position, end)
80
+ stop = position + size
81
+ if stop > end:
82
+ raise InvalidModelError("Truncated ONNX protobuf field.")
83
+ if field.type == 11:
84
+ _message(
85
+ data, position, stop, field.message_type, depth=depth + 1, budget=budget
86
+ )
87
+ elif packed:
88
+ _packed(data, position, stop, expected, budget)
89
+ return stop
90
+
91
+
92
+ def _packed(data, position, end, wire, budget):
93
+ if wire in (1, 5):
94
+ size = 8 if wire == 1 else 4
95
+ if (end - position) % size:
96
+ raise InvalidModelError("Invalid packed ONNX protobuf field.")
97
+ budget[0] += (end - position) // size
98
+ else:
99
+ while position < end:
100
+ stop, count = packed_varints(data, position, end)
101
+ if not count:
102
+ stop = _varint(data, position, end)[1]
103
+ count = 1
104
+ position = stop
105
+ budget[0] += count
106
+ if budget[0] > MAX_FIELDS:
107
+ raise LimitExceededError(
108
+ "ONNX protobuf scalar count exceeds the limit."
109
+ )
110
+ if budget[0] > MAX_FIELDS:
111
+ raise LimitExceededError("ONNX protobuf scalar count exceeds the limit.")
@@ -0,0 +1,87 @@
1
+ """Fixed runtime settings and agreement with its inferred input/output metadata."""
2
+
3
+ from typing import Any, cast
4
+
5
+ import onnxruntime as ort
6
+
7
+ from mlbundle.errors import InvalidModelError
8
+
9
+ from .description import Description
10
+
11
+ ORT_TYPES = {
12
+ "float32": "tensor(float)",
13
+ "float64": "tensor(double)",
14
+ "int32": "tensor(int32)",
15
+ "int64": "tensor(int64)",
16
+ "string": "tensor(string)",
17
+ }
18
+
19
+
20
+ def _agree(expected: dict[str, Any], actual: Any) -> None:
21
+ dtype = expected["dtype"]
22
+ if dtype.startswith("sequence<map<"):
23
+ dtype = dtype.removeprefix("sequence<map<").removesuffix(">>")
24
+ key, value = dtype.split(",")
25
+ native_type = f"seq(map({key},{ORT_TYPES[value]}))"
26
+ else:
27
+ native_type = ORT_TYPES[dtype]
28
+ if expected["name"] != actual.name or native_type != actual.type:
29
+ raise InvalidModelError("ONNX graph and runtime input/output types disagree.")
30
+ shape = expected["shape"]
31
+ if shape is not None and (
32
+ actual.shape is None
33
+ or len(shape) != len(actual.shape)
34
+ or any(
35
+ isinstance(a, int) and a != b
36
+ for a, b in zip(shape, actual.shape, strict=True)
37
+ )
38
+ ):
39
+ raise InvalidModelError(
40
+ "ONNX graph and runtime input/output dimensions disagree."
41
+ )
42
+
43
+
44
+ def create_session(data: bytes, description: Description) -> Any:
45
+ options = ort.SessionOptions()
46
+ options.intra_op_num_threads = 1
47
+ options.inter_op_num_threads = 1
48
+ options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
49
+ options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
50
+ options.enable_cpu_mem_arena = False
51
+ options.enable_mem_pattern = False
52
+ options.enable_profiling = False
53
+ options.optimized_model_filepath = ""
54
+ options.log_severity_level = 4
55
+ options.add_session_config_entry("session.load_model_format", "ONNX")
56
+ options.add_session_config_entry("session.disable_prepacking", "1")
57
+ session = ort.InferenceSession(
58
+ data,
59
+ sess_options=options,
60
+ providers=["CPUExecutionProvider"],
61
+ read_config_from_model=False,
62
+ )
63
+ inputs, outputs = session.get_inputs(), session.get_outputs()
64
+ expected_outputs = cast(
65
+ list[dict[str, Any]], description.claims.hyperparameters["outputs"]
66
+ )
67
+ if len(inputs) != len(description.inputs) or len(outputs) != len(expected_outputs):
68
+ raise InvalidModelError("ONNX graph and runtime input/output counts disagree.")
69
+ for expected, actual in zip(description.inputs, inputs, strict=True):
70
+ _agree(expected, actual)
71
+ for expected, actual in zip(expected_outputs, outputs, strict=True):
72
+ _agree(expected, actual)
73
+ if actual.name == description.output and expected["shape"] is not None:
74
+ width = (
75
+ 1
76
+ if description.claims.n_classes is None
77
+ else description.claims.n_classes[0]
78
+ )
79
+ if (
80
+ len(actual.shape) == 2
81
+ and isinstance(actual.shape[1], int)
82
+ and actual.shape[1] != width
83
+ ):
84
+ raise InvalidModelError(
85
+ "ONNX runtime prediction width disagrees with native claims."
86
+ )
87
+ return session
@@ -0,0 +1,63 @@
1
+ """Supported tabular graph signatures and feature input layouts."""
2
+
3
+ from typing import Any
4
+
5
+ from mlbundle.errors import InvalidModelError
6
+ from mlbundle.limits import MAX_FEATURES
7
+
8
+ DTYPES = {1: "float32", 11: "float64", 6: "int32", 7: "int64"}
9
+
10
+
11
+ def signature(value: Any) -> dict[str, Any]:
12
+ typ = value.type
13
+ if typ.HasField("tensor_type"):
14
+ tensor = typ.tensor_type
15
+ if not tensor.HasField("shape"):
16
+ raise InvalidModelError("ONNX input/output ranks must be declared.")
17
+ dtype = {**DTYPES, 8: "string"}.get(tensor.elem_type)
18
+ if dtype is None:
19
+ raise InvalidModelError("Unsupported ONNX input/output dtype.")
20
+ shape = [
21
+ dim.dim_value if dim.HasField("dim_value") else dim.dim_param or None
22
+ for dim in tensor.shape.dim
23
+ ]
24
+ return {"name": value.name, "dtype": dtype, "shape": shape}
25
+ if typ.HasField("sequence_type"):
26
+ element = typ.sequence_type.elem_type
27
+ if element.HasField("map_type"):
28
+ mapping = element.map_type
29
+ dtype = DTYPES.get(mapping.value_type.tensor_type.elem_type)
30
+ if mapping.key_type in (7, 8) and dtype in ("float32", "float64"):
31
+ key = "int64" if mapping.key_type == 7 else "string"
32
+ return {
33
+ "name": value.name,
34
+ "dtype": f"sequence<map<{key},{dtype}>>",
35
+ "shape": None,
36
+ }
37
+ raise InvalidModelError("Unsupported ONNX input/output layout.")
38
+
39
+
40
+ def _batch(shape: list[Any]) -> bool:
41
+ return bool(shape) and (not isinstance(shape[0], int) or shape[0] == 1)
42
+
43
+
44
+ def _inputs(graph: Any) -> tuple[dict[str, Any], ...]:
45
+ inputs = tuple(signature(value) for value in graph.input)
46
+ for value in inputs:
47
+ shape = value["shape"]
48
+ if (
49
+ value["dtype"] not in DTYPES.values()
50
+ or shape is None
51
+ or len(shape) != 2
52
+ or not _batch(shape)
53
+ or not isinstance(shape[1], int)
54
+ or not 1 <= shape[1] <= MAX_FEATURES
55
+ ):
56
+ raise InvalidModelError(
57
+ "ONNX inputs require numeric matrices with fixed feature width and dynamic or one-row batches."
58
+ )
59
+ if len(inputs) > 1 and shape[1] != 1:
60
+ raise InvalidModelError(
61
+ "Multiple ONNX inputs require one column per input."
62
+ )
63
+ return inputs