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,158 @@
1
+ """Composite orchestration; each byte member belongs to its existing loader."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from pydantic import JsonValue
7
+ from sklearn.pipeline import Pipeline
8
+
9
+ from mlbundle._native_io import NativeExport, prepare_native
10
+ from mlbundle._pipeline_contract import PipelineInfo
11
+ from mlbundle._sklearn import transformers
12
+ from mlbundle._sklearn.pipeline_scope import validate_components
13
+ from mlbundle.claims import ModelClaims, verify_claims, verify_structure
14
+ from mlbundle.contract import Library, MemberName, SerializationFormat
15
+ from mlbundle.declarations import ExportDeclarations
16
+ from mlbundle.errors import LimitExceededError, UnsupportedModelError
17
+ from mlbundle.limits import ArchiveLimits
18
+ from mlbundle.registry import get_adapter
19
+
20
+ from .claims import extract, outer_claims, rebuild, structure, verify
21
+ from .prediction import Predictor
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CompositeExport:
26
+ leaf: NativeExport
27
+ transformer_adapter: transformers.Adapter
28
+ prefix: Pipeline | None
29
+ restored_prefix: Pipeline | None
30
+ step_names: list[str]
31
+ verbose: bool
32
+ claims: ModelClaims
33
+ members: dict[MemberName, bytes]
34
+ library: Library = Library.SKLEARN
35
+ serialization: SerializationFormat = SerializationFormat.PIPELINE
36
+
37
+ @property
38
+ def structure(self) -> dict[str, JsonValue] | None:
39
+ return self.leaf.structure
40
+
41
+ def finish(self, names: list[str]) -> tuple[PipelineInfo, dict[str, JsonValue]]:
42
+ expected = extract(
43
+ self.prefix,
44
+ self.leaf.claims,
45
+ self.leaf.library,
46
+ self.step_names,
47
+ self.verbose,
48
+ names=names,
49
+ transformer_adapter=self.transformer_adapter,
50
+ )
51
+ actual_leaf = self.leaf.adapter.extract_claims(self.leaf.restored)
52
+ actual_model = rebuild(
53
+ self.restored_prefix, self.leaf.restored, self.step_names, self.verbose
54
+ )
55
+ validate_components(actual_model)
56
+ actual_claims = outer_claims(self.restored_prefix, actual_leaf, self.verbose)
57
+ verify_claims(self.claims, actual_claims)
58
+ actual = extract(
59
+ self.restored_prefix,
60
+ actual_leaf,
61
+ self.leaf.library,
62
+ self.step_names,
63
+ self.verbose,
64
+ names=names,
65
+ transformer_adapter=self.transformer_adapter,
66
+ )
67
+ verify(expected, actual)
68
+ original = structure(self.leaf.structure, expected)
69
+ encoded = verify_structure(
70
+ original,
71
+ structure(self.leaf.adapter.extract_structure(self.leaf.restored), actual),
72
+ )
73
+ self.members[MemberName.STRUCTURE] = encoded
74
+ return expected, original
75
+
76
+
77
+ def prepare(
78
+ model: Any, declarations: ExportDeclarations, limits: ArchiveLimits
79
+ ) -> CompositeExport:
80
+ validate_components(model, exporting=True)
81
+ leaf_model = model.steps[-1][1]
82
+ roots = {base.__module__.split(".")[0] for base in type(leaf_model).__mro__}
83
+ library = Library.XGBOOST if "xgboost" in roots else Library.CATBOOST
84
+ adapter = get_adapter(library)
85
+ if not adapter.recognizes(leaf_model):
86
+ raise UnsupportedModelError(
87
+ reason="Composite final estimator must be a supported XGBoost or CatBoost wrapper."
88
+ )
89
+ leaf = prepare_native(leaf_model, adapter, declarations, limits)
90
+ prefix = restored = None
91
+ transformer_adapter = transformers.Adapter()
92
+ members = dict(leaf.members)
93
+ if len(model.steps) > 1:
94
+ prefix = Pipeline(
95
+ list(model.steps[:-1]),
96
+ memory=None,
97
+ verbose=model.verbose,
98
+ transform_input=None,
99
+ )
100
+ data = transformer_adapter.serialize(prefix)
101
+ if len(data) > limits.member_bytes(MemberName.SKOPS):
102
+ raise LimitExceededError("Model member size limit exceeded.")
103
+ members[MemberName.SKOPS] = data
104
+ restored = transformer_adapter.deserialize(data)
105
+ claims = outer_claims(prefix, leaf.claims, model.verbose)
106
+ return CompositeExport(
107
+ leaf,
108
+ transformer_adapter,
109
+ prefix,
110
+ restored,
111
+ [name for name, _ in model.steps],
112
+ model.verbose,
113
+ claims,
114
+ members,
115
+ )
116
+
117
+
118
+ def load_components(
119
+ recipe: PipelineInfo,
120
+ members: dict[MemberName, bytes],
121
+ names: list[str],
122
+ verbose: bool,
123
+ ) -> tuple[Pipeline, Predictor, ModelClaims, dict[str, JsonValue]]:
124
+ adapter = get_adapter(recipe.estimator.library)
125
+ transformer_adapter = transformers.Adapter()
126
+ leaf = adapter.deserialize(members[adapter.model_member])
127
+ prefix = (
128
+ None
129
+ if recipe.transformers is None
130
+ else transformer_adapter.deserialize(members[MemberName.SKOPS])
131
+ )
132
+ native_claims = adapter.extract_claims(leaf)
133
+ model = rebuild(prefix, leaf, recipe.step_names, verbose)
134
+ validate_components(model)
135
+ actual = extract(
136
+ prefix,
137
+ native_claims,
138
+ adapter.library,
139
+ recipe.step_names,
140
+ verbose,
141
+ names=names,
142
+ transformer_adapter=transformer_adapter,
143
+ )
144
+ verify(recipe, actual)
145
+ claims = outer_claims(prefix, native_claims, verbose)
146
+ extracted = structure(adapter.extract_structure(leaf), actual)
147
+ settings = native_claims.hyperparameters.get("prediction")
148
+ categorical = (
149
+ adapter.library == Library.XGBOOST
150
+ and isinstance(settings, dict)
151
+ and settings.get("enable_categorical") is True
152
+ )
153
+ return (
154
+ model,
155
+ Predictor(prefix, adapter.predictor(leaf), claims, categorical=categorical),
156
+ claims,
157
+ extracted,
158
+ )
@@ -0,0 +1,77 @@
1
+ """Transform original inputs, then delegate to the cached native predictor."""
2
+
3
+ from typing import Any
4
+
5
+ from sklearn import config_context
6
+ from sklearn.pipeline import Pipeline
7
+
8
+ from mlbundle._prediction_input import tabular
9
+ from mlbundle._sklearn.prediction import _string_inputs
10
+ from mlbundle.adapters import (
11
+ Predictor as NativePredictor,
12
+ ProbabilityOutput,
13
+ RegressionOutput,
14
+ )
15
+ from mlbundle.claims import ModelClaims
16
+ from mlbundle.contract import ModelKind
17
+ from mlbundle.errors import InvalidModelError, MLBundleError, PredictionInputError
18
+
19
+
20
+ class Predictor:
21
+ def __init__(
22
+ self,
23
+ prefix: Pipeline | None,
24
+ native: NativePredictor,
25
+ claims: ModelClaims,
26
+ *,
27
+ categorical: bool = False,
28
+ ) -> None:
29
+ self.prefix, self.native, self.claims = prefix, native, claims
30
+ self.strings = prefix is not None and _string_inputs(prefix)
31
+ self.categorical = categorical
32
+
33
+ def _transform(self, data: Any) -> Any:
34
+ # A single-step composite preserves the leaf's categorical admission.
35
+ if self.prefix is None:
36
+ return data
37
+ data, rows = tabular(
38
+ data,
39
+ self.claims.n_features,
40
+ strings=self.strings,
41
+ categorical=self.categorical,
42
+ )
43
+ names = getattr(data, "columns", None)
44
+ if (
45
+ names is not None
46
+ and self.claims.feature_names is not None
47
+ and list(names) != self.claims.feature_names
48
+ ):
49
+ raise PredictionInputError(
50
+ "Prediction columns must match model feature names and order."
51
+ )
52
+ try:
53
+ with config_context(
54
+ transform_output="default", enable_metadata_routing=False
55
+ ):
56
+ result = self.prefix.transform(data)
57
+ if len(result.shape) != 2 or result.shape[0] != rows:
58
+ raise InvalidModelError(
59
+ "Composite transformation must preserve prediction rows."
60
+ )
61
+ return result
62
+ except MLBundleError:
63
+ raise
64
+ except Exception:
65
+ raise InvalidModelError(
66
+ "Composite Pipeline transformation failed."
67
+ ) from None
68
+
69
+ def predict(self, data: object) -> RegressionOutput:
70
+ if self.claims.kind != ModelKind.REGRESSOR:
71
+ raise ValueError("predict requires a regressor.")
72
+ return self.native.predict(self._transform(data))
73
+
74
+ def predict_proba(self, data: object) -> ProbabilityOutput:
75
+ if self.claims.kind != ModelKind.CLASSIFIER:
76
+ raise ValueError("predict_proba requires a classifier.")
77
+ return self.native.predict_proba(self._transform(data))
@@ -0,0 +1,73 @@
1
+ """Shared container names, member sets and resource checks."""
2
+
3
+ from collections.abc import Iterable
4
+ from os import PathLike
5
+ from typing import BinaryIO
6
+
7
+ from .contract import MODEL_MEMBERS, MemberName
8
+ from .errors import InvalidContainerError, LimitExceededError
9
+ from .limits import ArchiveLimits
10
+ from .manifest import Manifest
11
+
12
+ BundleSource = str | PathLike[str] | bytes | BinaryIO
13
+ CHUNK_BYTES = 64 * 1024
14
+ MEMBER_ORDER = (
15
+ MemberName.MANIFEST,
16
+ MemberName.SKOPS,
17
+ MemberName.UBJSON,
18
+ MemberName.CBM,
19
+ MemberName.ONNX,
20
+ MemberName.SCHEMA,
21
+ MemberName.STRUCTURE,
22
+ MemberName.SAMPLES,
23
+ )
24
+
25
+
26
+ def effective_limits(limits: ArchiveLimits | None) -> ArchiveLimits:
27
+ # Revalidate even model_copy/model_construct values; ceilings are mandatory.
28
+ return ArchiveLimits.model_validate((limits or ArchiveLimits()).model_dump())
29
+
30
+
31
+ def member_name(raw: bytes | str) -> MemberName:
32
+ try:
33
+ return MemberName(raw if isinstance(raw, str) else raw.decode("ascii"))
34
+ except (UnicodeError, ValueError):
35
+ raise InvalidContainerError("Unknown or unsafe member name.") from None
36
+
37
+
38
+ def check_members(
39
+ names: Iterable[MemberName], manifest: Manifest | None = None
40
+ ) -> None:
41
+ present = set(names)
42
+ if MemberName.MANIFEST not in present:
43
+ raise InvalidContainerError("Missing manifest.json.")
44
+ models = {pair[1] for pair in MODEL_MEMBERS.values()}
45
+ selected = present & models
46
+ if len(selected) != 1 and selected not in (
47
+ {MemberName.SKOPS, MemberName.UBJSON},
48
+ {MemberName.SKOPS, MemberName.CBM},
49
+ ):
50
+ raise InvalidContainerError("Invalid model member combination.")
51
+ if manifest is not None and present != {MemberName.MANIFEST, *manifest.integrity}:
52
+ raise InvalidContainerError("Members disagree with manifest references.")
53
+
54
+
55
+ def check_sizes(
56
+ sizes: Iterable[tuple[MemberName, int, int]], limits: ArchiveLimits
57
+ ) -> None:
58
+ compressed = uncompressed = count = 0
59
+ for name, packed, unpacked in sizes:
60
+ count += 1
61
+ compressed += packed
62
+ uncompressed += unpacked
63
+ if packed > limits.max_compressed_bytes or unpacked > limits.member_bytes(name):
64
+ raise LimitExceededError(f"Member size limit exceeded: {name.value}.")
65
+ if unpacked > limits.max_compression_ratio * packed:
66
+ raise LimitExceededError(f"Compression ratio limit exceeded: {name.value}.")
67
+ if count > limits.max_members:
68
+ raise LimitExceededError("Member count limit exceeded.")
69
+ if (
70
+ compressed > limits.max_compressed_bytes
71
+ or uncompressed > limits.max_total_bytes
72
+ ):
73
+ raise LimitExceededError("Total member size limit exceeded.")
@@ -0,0 +1,78 @@
1
+ """Manifest construction shared by standalone and composite export."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from datetime import UTC, datetime
5
+ from importlib.metadata import version
6
+ from uuid import uuid4
7
+
8
+ from pydantic import JsonValue
9
+
10
+ from .claims import ModelClaims
11
+ from .contract import (
12
+ FORMAT_NAME,
13
+ FORMAT_VERSION,
14
+ FeatureOrigin,
15
+ Library,
16
+ MemberName,
17
+ SerializationFormat,
18
+ )
19
+ from .environment import capture_environment
20
+ from .training import TrainingDeclaration
21
+
22
+
23
+ def manifest_parts(
24
+ claims: ModelClaims,
25
+ library: Library,
26
+ serialization: SerializationFormat,
27
+ *,
28
+ structure: dict[str, JsonValue] | None,
29
+ feature_names: Sequence[str] | None,
30
+ training: TrainingDeclaration | None,
31
+ target_name: str | None,
32
+ metadata: Mapping[str, JsonValue] | None,
33
+ ) -> dict[str, object]:
34
+ names = claims.feature_names
35
+ origin = FeatureOrigin.MODEL
36
+ if names is None:
37
+ origin = (
38
+ FeatureOrigin.DECLARED
39
+ if feature_names is not None
40
+ else FeatureOrigin.GENERATED
41
+ )
42
+ names = (
43
+ list(feature_names)
44
+ if feature_names is not None
45
+ else [f"feature_{index}" for index in range(1, claims.n_features + 1)]
46
+ )
47
+ if training is not None:
48
+ names, origin = training.names, FeatureOrigin.DATAFRAME
49
+ model = claims.model_dump(
50
+ mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"}
51
+ )
52
+ model.update(library=library.value, serialization=serialization.value)
53
+ return {
54
+ "format_name": FORMAT_NAME,
55
+ "format_version": FORMAT_VERSION,
56
+ "bundle_id": str(uuid4()),
57
+ "created_at": datetime.now(UTC).isoformat(),
58
+ "writer_version": version("mlbundle"),
59
+ "model": model,
60
+ "features": {
61
+ "names": names,
62
+ "dtypes": [None] * claims.n_features
63
+ if training is None
64
+ else training.dtypes,
65
+ "model_dtypes": claims.feature_dtypes,
66
+ "origin": origin.value,
67
+ },
68
+ "target": {"class_labels": claims.class_labels, "name": target_name},
69
+ "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"),
70
+ "references": {
71
+ "structure": None if structure is None else MemberName.STRUCTURE.value,
72
+ "schema": None if training is None else MemberName.SCHEMA.value,
73
+ "samples": None
74
+ if training is None or training.samples is None
75
+ else MemberName.SAMPLES.value,
76
+ },
77
+ "user_metadata": {} if metadata is None else dict(metadata),
78
+ }
mlbundle/_native_io.py ADDED
@@ -0,0 +1,53 @@
1
+ """Shared native preparation and full round-trip verification."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import JsonValue
6
+
7
+ from .adapters import Adapter
8
+ from .claims import ModelClaims, verify_claims, verify_structure
9
+ from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat
10
+ from .declarations import ExportDeclarations, resolve_labels
11
+ from .errors import LimitExceededError
12
+ from .limits import ArchiveLimits
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class NativeExport:
17
+ adapter: Adapter
18
+ model: object
19
+ restored: object
20
+ claims: ModelClaims
21
+ structure: dict[str, JsonValue] | None
22
+ members: dict[MemberName, bytes]
23
+
24
+ @property
25
+ def library(self) -> Library:
26
+ return self.adapter.library
27
+
28
+ @property
29
+ def serialization(self) -> SerializationFormat:
30
+ return MODEL_MEMBERS[self.library][0]
31
+
32
+
33
+ def prepare_native(
34
+ model: object,
35
+ adapter: Adapter,
36
+ declarations: ExportDeclarations,
37
+ limits: ArchiveLimits,
38
+ ) -> NativeExport:
39
+ model = adapter.prepare_export(model, declarations)
40
+ data = adapter.serialize(model)
41
+ if len(data) > limits.member_bytes(adapter.model_member):
42
+ raise LimitExceededError("Model member size limit exceeded.")
43
+ claims = resolve_labels(adapter.extract_claims(model), declarations)
44
+ structure = adapter.extract_structure(model)
45
+ restored = adapter.deserialize(data)
46
+ verify_claims(
47
+ claims, resolve_labels(adapter.extract_claims(restored), declarations)
48
+ )
49
+ structure_bytes = verify_structure(structure, adapter.extract_structure(restored))
50
+ members = {adapter.model_member: data}
51
+ if structure is not None:
52
+ members[MemberName.STRUCTURE] = structure_bytes
53
+ return NativeExport(adapter, model, restored, claims, structure, members)
@@ -0,0 +1,115 @@
1
+ """Original ONNX bytes with a validated CPU session; optional imports stay here."""
2
+
3
+ from dataclasses import dataclass
4
+ from os import PathLike
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import onnx
9
+ from pydantic import JsonValue, ValidationError
10
+
11
+ from mlbundle.claims import ModelClaims
12
+ from mlbundle.contract import Library, MemberName
13
+ from mlbundle.declarations import ExportDeclarations, resolve_labels
14
+ from mlbundle.errors import InvalidModelError, LimitExceededError, MLBundleError
15
+ from mlbundle.limits import ArchiveLimits
16
+
17
+ from .description import Description, describe
18
+ from .prediction import Predictor
19
+ from .session import create_session
20
+ from .validation import parse_model
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class OnnxModel:
25
+ """Validated native inference session and the exact bytes that created it."""
26
+
27
+ data: bytes
28
+ session: Any
29
+
30
+
31
+ def _description(data: bytes) -> Description:
32
+ try:
33
+ return describe(parse_model(data))
34
+ except MLBundleError:
35
+ raise
36
+ except ValidationError as error:
37
+ failure = error.errors(include_input=False, include_url=False)[0]
38
+ # Disclose closed field names, never author-supplied keys or values.
39
+ fields = {"producer_name", "producer_version", "feature_names", "class_labels"}
40
+ field = next((part for part in failure["loc"] if part in fields), None)
41
+ if field is not None and failure["type"] == "string_too_long":
42
+ limit = failure.get("ctx", {})["max_length"]
43
+ raise InvalidModelError(
44
+ f"ONNX {field} exceeds the {limit}-character limit."
45
+ ) from None
46
+ raise InvalidModelError("Invalid ONNX model claims.") from None
47
+ except Exception:
48
+ raise InvalidModelError("Invalid ONNX model claims.") from None
49
+
50
+
51
+ class Adapter:
52
+ library = Library.ONNX
53
+ model_member = MemberName.ONNX
54
+
55
+ def __init__(self) -> None:
56
+ self._data: bytes | None = None
57
+ self._validated: Description | None = None
58
+
59
+ def _describe(self, data: bytes) -> Description:
60
+ # An adapter belongs to one export/load, never a global cache or bundle.
61
+ if data is not self._data or self._validated is None:
62
+ description = _description(data)
63
+ self._data, self._validated = data, description
64
+ return self._validated
65
+
66
+ def recognizes(self, model: object) -> bool:
67
+ return isinstance(model, (bytes, onnx.ModelProto, OnnxModel)) or (
68
+ isinstance(model, (str, PathLike)) and Path(model).suffix.lower() == ".onnx"
69
+ )
70
+
71
+ def prepare_export(self, model: object, declarations: ExportDeclarations) -> object:
72
+ try:
73
+ if isinstance(model, OnnxModel):
74
+ data = model.data
75
+ elif isinstance(model, bytes):
76
+ data = model
77
+ elif isinstance(model, onnx.ModelProto):
78
+ if model.ByteSize() > ArchiveLimits().max_member_bytes:
79
+ raise LimitExceededError("ONNX model exceeds the byte limit.")
80
+ data = model.SerializeToString()
81
+ else:
82
+ path = Path(model) # type: ignore[arg-type]
83
+ cap = ArchiveLimits().max_member_bytes
84
+ if path.stat().st_size > cap:
85
+ raise LimitExceededError("ONNX model exceeds the byte limit.")
86
+ with path.open("rb") as stream:
87
+ data = stream.read(cap + 1)
88
+ description = self._describe(data)
89
+ resolve_labels(description.claims, declarations)
90
+ return self.deserialize(data)
91
+ except MLBundleError:
92
+ raise
93
+ except Exception:
94
+ raise InvalidModelError("Cannot read ONNX model input.") from None
95
+
96
+ def serialize(self, model: Any) -> bytes:
97
+ return model.data
98
+
99
+ def deserialize(self, data: bytes) -> OnnxModel:
100
+ description = self._describe(data)
101
+ try:
102
+ return OnnxModel(data, create_session(data, description))
103
+ except MLBundleError:
104
+ raise
105
+ except Exception:
106
+ raise InvalidModelError("ONNX CPU session creation failed.") from None
107
+
108
+ def extract_claims(self, model: Any) -> ModelClaims:
109
+ return self._describe(model.data).claims
110
+
111
+ def extract_structure(self, model: object) -> dict[str, JsonValue] | None: # noqa: ARG002
112
+ return None
113
+
114
+ def predictor(self, model: Any) -> Predictor:
115
+ return Predictor(model.session, self._describe(model.data))
@@ -0,0 +1,103 @@
1
+ """Bounded C regex scans of repeated wire scalars; no protobuf objects allocated."""
2
+
3
+ import re
4
+ from functools import lru_cache
5
+
6
+ import numpy as np
7
+
8
+ # Match exactly the same uint64 encodings accepted by the scalar walker,
9
+ # including overlong encodings; byte ten may contain only bit zero.
10
+ VARINT = rb"(?:[\x00-\x7f]|[\x80-\xff]{1,8}[\x00-\x7f]|[\x80-\xff]{9}[\x00-\x01])"
11
+ CHUNK_BYTES = 64 * 1024
12
+ PACKED = re.compile(rb"(?:" + VARINT + rb")+")
13
+
14
+
15
+ @lru_cache(maxsize=128)
16
+ def _pattern(tag: bytes, wire: int, value: bytes):
17
+ scalar = VARINT if wire == 0 else rb".{%d}" % (8 if wire == 1 else 4)
18
+ unit = re.escape(tag) + scalar if wire != 2 else re.escape(value)
19
+ return re.compile(rb"(?:" + unit + rb")+", re.DOTALL)
20
+
21
+
22
+ def _varint_chunk(data: bytes, start: int, end: int, tag: bytes):
23
+ values = np.frombuffer(data, dtype=np.uint8, count=end - start, offset=start)
24
+ terminals = np.flatnonzero(values < 128)
25
+ count = len(terminals) // 2
26
+ if not count:
27
+ return start, 0
28
+ pairs = terminals[: count * 2].reshape(-1, 2)
29
+ starts = np.concatenate(([0], pairs[:-1, 1] + 1))
30
+ valid = pairs[:, 0] == starts + len(tag) - 1
31
+ for offset, byte in enumerate(tag):
32
+ # Clipping is only for malformed, too-short tags; their offset check fails.
33
+ valid &= values[np.minimum(starts + offset, len(values) - 1)] == byte
34
+ widths = pairs[:, 1] - pairs[:, 0]
35
+ valid &= (widths <= 10) & ((widths != 10) | (values[pairs[:, 1]] <= 1))
36
+ invalid = np.flatnonzero(~valid)
37
+ count = int(invalid[0]) if len(invalid) else count
38
+ return start + int(pairs[count - 1, 1]) + 1 if count else start, count
39
+
40
+
41
+ def _fixed_chunk(data: bytes, start: int, end: int, tag: bytes, wire: int):
42
+ size = len(tag) + (8 if wire == 1 else 4)
43
+ count = (end - start) // size
44
+ values = np.frombuffer(
45
+ data, dtype=np.uint8, count=count * size, offset=start
46
+ ).reshape(-1, size)
47
+ valid = np.all(values[:, : len(tag)] == np.frombuffer(tag, dtype=np.uint8), axis=1)
48
+ invalid = np.flatnonzero(~valid)
49
+ count = int(invalid[0]) if len(invalid) else count
50
+ return start + count * size, count
51
+
52
+
53
+ def repeated(
54
+ data: bytes, start: int, end: int, *, value_start: int, stop: int, wire: int
55
+ ):
56
+ """Include the already validated first field, then scan equivalent wire units."""
57
+ if wire == 2 and stop - start > 4096:
58
+ return stop, 1
59
+ tag = data[start:value_start]
60
+ pattern = _pattern(tag, wire, data[start:stop] if wire == 2 else b"")
61
+ probe = pattern.match(data, start, min(end, start + 64))
62
+ vectorize = wire != 2 and probe is not None and probe.end() - start >= 32
63
+ chunk_bytes = 128 if vectorize else CHUNK_BYTES
64
+ count, position = 0, start
65
+ while position < end and data.startswith(tag, position, end):
66
+ chunk_end = min(end, position + chunk_bytes)
67
+ if vectorize:
68
+ next_position, units = (
69
+ _varint_chunk(data, position, chunk_end, tag)
70
+ if wire == 0
71
+ else _fixed_chunk(data, position, chunk_end, tag, wire)
72
+ )
73
+ # Grow only after proportional progress; short alternating fields
74
+ # never speculate over a full 64 KiB window.
75
+ if next_position - position >= (chunk_end - position) // 2 and units:
76
+ count += units
77
+ position = next_position
78
+ chunk_bytes = min(CHUNK_BYTES, 2 * chunk_bytes)
79
+ continue
80
+ match = pattern.match(data, position, chunk_end)
81
+ if match is None:
82
+ break
83
+ next_position = match.end()
84
+ if wire == 0:
85
+ # Every field has one terminating tag byte and one scalar byte.
86
+ values = np.frombuffer(
87
+ data, dtype=np.uint8, count=next_position - position, offset=position
88
+ )
89
+ count += int(np.count_nonzero(values < 128)) // 2
90
+ else:
91
+ size = stop - start if wire == 2 else len(tag) + (8 if wire == 1 else 4)
92
+ count += (next_position - position) // size
93
+ position = next_position
94
+ return position, count
95
+
96
+
97
+ def packed_varints(data: bytes, position: int, end: int):
98
+ match = PACKED.match(data, position, min(end, position + CHUNK_BYTES))
99
+ if match is None:
100
+ return position, 0
101
+ stop = match.end()
102
+ values = np.frombuffer(data, dtype=np.uint8, count=stop - position, offset=position)
103
+ return stop, int(np.count_nonzero(values < 128))