From 262ba56349fd836aa02fac5835508e55639a8dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Sun, 11 Oct 2026 23:15:49 +0200 Subject: [PATCH 01/15] feat: define versioned bundle contract (#2) --- .github/workflows/ci.yml | 6 +- AGENTS.md | 51 ++-- README.md | 28 +- SECURITY.md | 8 +- docs/format.md | 342 +++++++++++++++++++++++ pyproject.toml | 17 +- src/mlbundle/__init__.py | 64 ++++- src/mlbundle/_base.py | 19 ++ src/mlbundle/adapters.py | 29 ++ src/mlbundle/canonical.py | 153 +++++++++++ src/mlbundle/claims.py | 106 ++++++++ src/mlbundle/contract.py | 49 ++++ src/mlbundle/environment.py | 83 ++++++ src/mlbundle/errors.py | 71 +++++ src/mlbundle/integrity.py | 30 +++ src/mlbundle/limits.py | 38 +++ src/mlbundle/manifest.py | 180 +++++++++++++ src/mlbundle/registry.py | 47 ++++ src/mlbundle/results.py | 66 +++++ tests/conftest.py | 46 ++++ tests/test_canonical.py | 147 ++++++++++ tests/test_claims.py | 63 +++++ tests/test_environment.py | 46 ++++ tests/test_errors.py | 39 +++ tests/test_integrity.py | 36 +++ tests/test_limits.py | 31 +++ tests/test_manifest.py | 255 ++++++++++++++++++ tests/test_package.py | 21 +- tests/test_registry.py | 143 ++++++++++ tests/test_results.py | 68 +++++ uv.lock | 521 ++++++++++++++++++++++++++++++++++++ 31 files changed, 2768 insertions(+), 35 deletions(-) create mode 100644 docs/format.md create mode 100644 src/mlbundle/_base.py create mode 100644 src/mlbundle/adapters.py create mode 100644 src/mlbundle/canonical.py create mode 100644 src/mlbundle/claims.py create mode 100644 src/mlbundle/contract.py create mode 100644 src/mlbundle/environment.py create mode 100644 src/mlbundle/errors.py create mode 100644 src/mlbundle/integrity.py create mode 100644 src/mlbundle/limits.py create mode 100644 src/mlbundle/manifest.py create mode 100644 src/mlbundle/registry.py create mode 100644 src/mlbundle/results.py create mode 100644 tests/conftest.py create mode 100644 tests/test_canonical.py create mode 100644 tests/test_claims.py create mode 100644 tests/test_environment.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_integrity.py create mode 100644 tests/test_limits.py create mode 100644 tests/test_manifest.py create mode 100644 tests/test_registry.py create mode 100644 tests/test_results.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27be622..fcee882 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install UV CLI run: python -m pip install uv==0.12.19 - name: Install development dependencies from lockfile - run: uv sync --locked --group dev + run: uv sync --locked --group dev --all-extras - name: Check lint run: uv run ruff check . - name: Check formatting @@ -42,7 +42,7 @@ jobs: - name: Install UV CLI run: python -m pip install uv==0.12.19 - name: Install development dependencies from lockfile - run: uv sync --locked --group dev + run: uv sync --locked --group dev --all-extras - name: Check types run: uv run pyright @@ -63,7 +63,7 @@ jobs: - name: Install UV CLI run: python -m pip install uv==0.12.19 - name: Install development dependencies from lockfile - run: uv sync --locked --group dev + run: uv sync --locked --group dev --all-extras - name: Run tests with coverage run: uv run pytest - name: Upload coverage report diff --git a/AGENTS.md b/AGENTS.md index 5c3cffe..1a66af2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,9 +5,11 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phase 0 is a package and tooling bootstrap. No bundle API, reader, writer or CLI -exists yet. The format is not final. The rules below constrain later phases; -they must not be described as implemented until code and tests prove them. +Phase 1 defines the version 1 format contract, strict manifest models, canonical +JSON, integrity helpers, environment capture and adapter/result contracts. +No bundle API, archive reader, writer, model adapter or CLI exists yet. The rules +below constrain later phases; they must not be described as implemented until +code and tests prove them. `docs/format.md` owns the wire contract. ## What makes mlbundle special? @@ -41,7 +43,7 @@ Version 0.1 supports three libraries: scikit-learn via `skops.io.dump` and `skops.io.load`, XGBoost via native `save_model("model.ubj")`, and the user's ONNX file unchanged. Do not convert models. Adapters import their library lazily; their dependencies belong in the optional extras `mlbundle[sklearn]`, -`mlbundle[xgboost]` and `mlbundle[onnx]`, to be declared in Phase 1. +`mlbundle[xgboost]` and `mlbundle[onnx]`, declared in Phase 1. The design takes ideas from MLflow's versioned manifest and native model storage, not its code. MLflow is Apache-2.0; mlbundle is MIT. @@ -62,7 +64,9 @@ still preserve validation, security and reproducibility. - **adapter** — mlbundle-owned code for one supported model library, including serialization, loading and shared extraction of model-derived claims. - **claim** — information asserted by the bundle's author; model-derived claims - require comparison with the loaded model. + require comparison with the loaded model using canonical JSON byte equality. +- **predictor** — the adapter's uniform probability/regression interface; the + native model remains available separately for runtime explanations. - **schema** — locally inferred mlschema fields plus report definitions. - **structure** — model internals for visualisation, tied to the actual model. - **trusted types** — the mlbundle-owned allowlist used for skops loading. @@ -81,7 +85,7 @@ still preserve validation, security and reproducibility. ## Hit every affected layer -- **Format.** Manifest validation, allowed members, version handling and later +- **Format.** Manifest validation, allowed members, version handling and `docs/format.md` must agree. Reject unknown versions instead of guessing. - **Writer.** Compute the schema locally, record the environment and fail early for unsupported model types. Samples require explicit user intent. @@ -119,8 +123,8 @@ still preserve validation, security and reproducibility. ### Repository rules - No source file may exceed 300 lines. Split by responsibility before the limit. -- Add no runtime dependency without a concrete reason. Phase 0 has none; - Phase 1 must explain each dependency it adds. +- Add no runtime dependency without a concrete reason. The sole core dependency + is Pydantic v2 for strict manifest validation; model libraries belong in extras. - Keep a feature's tests in one file covering its success case and every error case. Test observable behavior and supported contracts. - Avoid Python 3.14-only syntax; widening the Python range should be a metadata @@ -145,12 +149,13 @@ mlschema 0.2.1 supports that version. Keep `uv_build` as the build backend. ```shell uv python install 3.14 -uv sync --locked +uv sync --locked --all-extras uv run pre-commit install ``` After changing dependency metadata, use `uv sync` to regenerate `uv.lock` and -commit the lockfile. CI uses `uv sync --locked --group dev` on Python 3.14. +commit the lockfile. CI uses `uv sync --locked --group dev --all-extras` on Python 3.14. +The default dev group installs `mlbundle[all]`; base users can omit the dev group. Ruff handles lint and formatting; Pyright uses the family's basic mode. ## Verifying @@ -180,8 +185,7 @@ environment or behavior was verified when it was not. - `README.md` owns purpose, honest implementation status and development setup. - `SECURITY.md` owns the threat model, guarantees, limits and private reporting. -- `docs/format.md` will own the format specification in Phase 1; do not create - that specification during bootstrap. +- `docs/format.md` owns the version 1 wire specification and verification rules. - Rewrite stale guidance instead of appending competing accounts. Keep agent policy in this root file. Do not add nested `AGENTS.md` files. @@ -196,8 +200,9 @@ The conversation, issues, code, tests and final diff are the work record. After training, the planned writer will infer the schema locally, extract the model's claims and structure, serialize the model in its native supported format and produce a zip with a closed member list: required `manifest.json`, exactly -one of `model.skops`, `model.ubj` or `model.onnx`, plus `schema.json` and -`structure.json`. No additional archive members are allowed. +one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and +`structure.json` when the supported model has extractable structure. Optional +`samples.json` requires explicit user intent. No other members are allowed. The manifest must record format name/version, bundle id, UTC creation time and writer version; model library, estimator class, classifier/regressor kind, @@ -206,6 +211,10 @@ and origin; original class labels and target name; training Python, OS, architecture and package versions; member SHA-256 hashes and sizes; and free-form user metadata. Sample rows are allowed only on explicit request. All of this is untrusted input, and model-derived fields must be verified. +Class counts and original labels are output-major arrays, including for a single +output. Model-exposed dtypes are verified separately from declared training dtypes. +The writer must test serialization/reloading and reject claims or structure that +cannot survive the native round trip. Default safety ceilings may only be lowered. MLSuite will receive the file. The reader will validate the archive and manifest, check integrity, load through its closed adapter and recompute claims @@ -214,7 +223,19 @@ model; it cannot attest that model-derived claims match the model. ## Where code lives -- `src/mlbundle/` — the typed Python package; currently only `__version__`. +- `src/mlbundle/__init__.py` — public contract models, enums, errors and package version. +- `src/mlbundle/_base.py` — strict frozen model configuration and string bounds. +- `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. +- `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. +- `src/mlbundle/canonical.py` — deterministic JSON encoding and bounded strict decoding. +- `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. +- `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. +- `src/mlbundle/integrity.py` — member SHA-256/size records and verification. +- `src/mlbundle/environment.py` — training declarations and distribution metadata capture. +- `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. +- `src/mlbundle/registry.py` — closed lazy imports of future `_sklearn`, `_xgboost`, `_onnx` modules; each provides an `Adapter` class. +- `src/mlbundle/results.py` — metadata-only and loaded reader result types. +- `docs/format.md` — version 1 format specification; container/adapters remain unimplemented. - `tests/` — one file per feature, with success and error cases together. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. diff --git a/README.md b/README.md index 2365353..bf491f3 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ manifest, its input schema and its internal structure in one compressed `.mlbundle` file. The same package will write bundles on the training machine and read them with validation in a runtime. -**Status: pre-alpha.** This repository currently contains the package scaffold -and development tooling only. The format is not final, no reader, writer or CLI -exists yet, and nothing has been published to PyPI. The format specification -will be added in Phase 1 at `docs/format.md`. +**Status: pre-alpha, Phase 1.** The [version 1 format contract](docs/format.md), +strict manifest models, canonical JSON, integrity helpers, environment capture +and adapter/result contracts are implemented and tested. No archive reader, +writer, model adapter, public export/load/inspect API or CLI exists yet, and +nothing has been published to PyPI. ## Why it exists @@ -26,8 +27,8 @@ will be excluded unless the user explicitly asks to include samples. The first version will support scikit-learn through skops, XGBoost through its native UBJSON format, and existing ONNX files without conversion. Model-library -dependencies will be optional extras with lazy imports. They are not declared -in this bootstrap. The design takes ideas from MLflow's versioned manifest and +dependencies are optional extras (`sklearn`, `xgboost`, `onnx`, `all`), with lazy +adapter resolution. The design takes ideas from MLflow's versioned manifest and native model formats; it copies no MLflow code. mlbundle is MIT licensed. ## Security model @@ -42,18 +43,21 @@ operation must work without deserializing the model. Hashes detect corruption; they do not authenticate the author or prevent deliberate tampering. Signing is outside the project's scope. These guarantees -are requirements for future implementation, not capabilities of this scaffold. +are requirements for the future reader and adapters; today's contract helpers +do not load or validate a ZIP archive or model. See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. ## Development Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows mlschema 0.2.1; source code must avoid syntax that requires Python 3.14 so the -supported range can be widened later. There are no runtime dependencies yet. +supported range can be widened later. Pydantic v2 is the sole core runtime +dependency, for strict manifest validation. Model libraries are optional; +mlschema integration comes in Phase 5. ```shell uv python install 3.14 -uv sync --locked +uv sync --locked --all-extras uv run pre-commit install uv run ruff check . uv run ruff format --check . @@ -62,7 +66,11 @@ uv run pytest uv build ``` -The committed `uv.lock` fixes development dependency versions. `uv run pytest` +The default dev group installs all model extras. Base consumers can use +`uv sync --no-dev` without installing model libraries. The committed `uv.lock` +fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX +Runtime to the target MLSuite runtime baseline; this is not yet evidence of +adapter compatibility. `uv run pytest` produces terminal and XML coverage reports and enforces an 80% minimum. CI runs lint, type checks, and Python 3.14 tests on Ubuntu and Windows. `uv build` creates local wheel and source archives; it does not publish them. diff --git a/SECURITY.md b/SECURITY.md index 581c4d3..2e9ef15 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,10 @@ # Security policy -mlbundle is pre-alpha. This bootstrap has no bundle reader or writer, and its -format is not final. The requirements below define the security boundary later -implementation must enforce; they are not guarantees provided by today's code. +mlbundle is pre-alpha. Phase 1 implements strict manifest validation and contract +helpers; it has no archive reader, writer or model adapters. The version 1 +contract is defined in [docs/format.md](docs/format.md). The requirements below +define the security boundary later implementation must enforce; they are not +reader guarantees provided by today's code. ## Threat model diff --git a/docs/format.md b/docs/format.md new file mode 100644 index 0000000..0f2bb4b --- /dev/null +++ b/docs/format.md @@ -0,0 +1,342 @@ +# mlbundle format version 1 + +This document defines the wire contract for `format_name = "mlbundle"` and +`format_version = "1"`. Format versions are independent of package versions. +“Must” denotes a compatibility or security requirement. Phase 1 implements +the manifest, encoding, limits and adapter contracts. Archive reading, writing, +inspection, model adapters and schema inference are implemented in later phases. + +## Container and resource limits + +A bundle is one ZIP file with the `.mlbundle` extension. Its root contains only +the case-sensitive names below. Names are ASCII, with no directory entries, +slashes, backslashes, `..`, NULs or alternative spellings. Unknown or duplicate +names, symlinks, other non-regular entries, encrypted entries, unsupported +compression methods, multi-disk archives and malformed ZIP metadata must be +rejected. Only ZIP_STORED (method 0) and ZIP_DEFLATED (method 8) are allowed. +ZIP64 is allowed within the limits. Extra fields and archive/member comments +have no semantics and cannot authorize additional members or loading behavior. + +Readers must read named members into memory, never extract them to disk. They +must inspect directory metadata and enforce limits before reading, allocating +member buffers or loading models; they must also limit actual bytes produced +during decompression, rather than trusting directory sizes. Reject inconsistencies +between headers, directory entries, actual sizes and ZIP CRC checks. No path, +module name, trusted-types list or external file reference can select loading +code. ONNX external tensor data is unsupported: the original file must be +self-contained. Native model formats do not sandbox their parsers or execution. + +One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: + +| Limit | Maximum | +| --- | ---: | +| Member count, including manifest | 5 (minimum configured value 2) | +| Entire archive, including headers and comments | 512 MiB | +| Sum of compressed member sizes | 512 MiB | +| Any uncompressed member | 512 MiB | +| Sum of all uncompressed member sizes, including manifest | 1 GiB | +| Each member's uncompressed/compressed ratio | 200 | +| `manifest.json` | 8 MiB | +| `structure.json` | 256 MiB | +| `schema.json` | 16 MiB | +| `samples.json` | 16 MiB | +| JSON container nesting depth | 32 | + +The effective member cap is the smaller of its specific cap and the general +member cap. Check the ratio as `uncompressed_size <= 200 * compressed_size`; +an empty member with both sizes zero is exempt, a nonempty member with compressed +size zero is invalid. This applies to stored members too. Writers can choose +stored entries if deflation would exceed the ratio ceiling. These limits admit +forests whose native model is tens of MiB and whose JSON tree arrays are larger. +Callers may lower archive limits, never raise the ceilings in version 1. +Consumers still need process memory, CPU and prediction limits; a 1 GiB byte +budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. + +## Members + +| Member | Rule and contents | +| --- | --- | +| `manifest.json` | Required; the manifest described below. | +| `model.skops` | Exactly one model member; scikit-learn, skops format. | +| `model.ubj` | Exactly one model member; XGBoost native UBJSON format. | +| `model.onnx` | Exactly one model member; original, self-contained ONNX bytes, unchanged. | +| `schema.json` | Optional; object with `fields` and `reports` arrays from local schema/report generation. No training DataFrame. The schema integration phase defines the supported mlschema field/report vocabulary. | +| `structure.json` | Optional only for families without extractable structure; object as defined below. | +| `samples.json` | Optional; array of row objects keyed by feature name, in declared feature order when consumed. Only explicit user intent may enable it. Values use the canonical JSON value rules. Samples are real user data. | + +Presence of schema, structure or samples must match both the manifest reference +and an integrity record. There is no member for arbitrary user files. A training +DataFrame must never be included. For samples, each row must contain exactly the +declared feature names, without a target column or arbitrary extra columns; the +byte/depth limits apply even when no rows are present. + +For version 1, structure extraction is mandatory for supported scikit-learn +models exposing `tree_`, tree ensembles with constituent `tree_` estimators, +models exposing `coef_`/`intercept_`, and models exposing `feature_importances_`; +and for supported XGBoost tree boosters. A member is required whenever the +adapter's extraction returns an object. Other supported sklearn/XGBoost families +return null; ONNX is opaque for visualization in version 1 and returns null. +Do not manufacture a visualization for an opaque model. The loaded reader must +extract structure even when the member is absent: non-null extraction with a +missing member, or null extraction with a present member, is a claim mismatch. +Metadata-only inspection cannot determine this requirement from the model. + +## Manifest + +All objects representing typed records reject unknown keys. Types are strict: +booleans do not stand for integers, strings do not stand for numbers and enum +values are exact. JSON object keys are strings. Arrays are ordered. Null is +distinct from an empty array/object. Unless marked optional, fields are required. +Optional fields can be omitted or null; writers emit defaults explicitly. +`user_metadata` may be omitted and defaults to `{}`. + +### Identity + +| Field | Type | Meaning | +| --- | --- | --- | +| `format_name` | literal string `"mlbundle"` | Format identity. | +| `format_version` | literal string `"1"` | Wire version; unknown strings raise `UnsupportedFormatVersionError`. Wrong types are invalid manifests. | +| `bundle_id` | UUID string | Author-assigned bundle identity; writers use lowercase, hyphenated UUIDs. Readers accept UUID spellings supported by UUID parsing and normalize on output. | +| `created_at` | ISO 8601 UTC timestamp string | Creation declaration, spelled `YYYY-MM-DDTHH:MM:SS[.ffffff]Z` or with `+00:00` instead of `Z`; fractions have 1–6 digits. Writers emit `Z`. Reject epoch strings/numbers, naive timestamps, nonzero offsets and extra fractional precision. | +| `writer_version` | nonempty string, at most 256 characters | mlbundle package version that wrote the bundle. Must equal `environment.packages.mlbundle`. | + +Identity fields are declarations, not proof of provenance. + +### `model` + +| Field | Type | Meaning / verification | +| --- | --- | --- | +| `library` | `"sklearn"`, `"xgboost"`, `"onnx"` | Closed adapter identifier; validated against serialization/member pairing. | +| `serialization` | `"skops"`, `"ubjson"`, `"onnx"` | Must pair respectively with the three libraries and model members above. | +| `estimator_class` | nonempty string, at most 256 characters | Verified class name (not a Python import path); ONNX uses the adapter's fixed `"ONNX"` identity. Never instantiate from this field. | +| `kind` | `"classifier"`, `"regressor"` | Verified prediction kind. | +| `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's `get_params(deep=False)`; native models without `get_params` use their recoverable native parameter representation. ONNX uses `{}`. At most 1 MiB canonical bytes and depth 16. | +| `n_features` | integer 1–100,000 | Verified ordered input width. | +| `n_classes` | null or array of integers 1–100,000 | Verified per-output class counts; classifier array length equals `n_outputs`, regressor value is null. | +| `n_outputs` | integer 1–1,024 | Verified target output count. Probability columns are classes, not separate target outputs. | + +Hyperparameter values preserve JSON scalars, nested containers and numeric tags. +Do not stringify unsupported objects or call `repr` as a fallback. If an adapter +supports nested estimators in parameters, their representation is an object +`{"$estimator":{"class":CLASS_NAME,"params":PARAMETER_OBJECT}}`, recursively, +with classes drawn from its own closed allowlist. This is a comparison record, +never an instruction to import or construct a class. Unsupported callable, +custom-object or otherwise unrecoverable parameters must fail export. +The writer must serialize, reload and compare extracted claims and structure +before emitting a bundle. Native formats may omit training-time parameters: +export must fail when the chosen claims cannot survive that round trip; the +manifest must never restore missing parameters into a reader's model. + +### `features` + +| Field | Type | Meaning / verification | +| --- | --- | --- | +| `names` | array of 1–100,000 unique nonempty strings, each at most 1,024 characters | Effective input names, in prediction order; length equals `n_features`. | +| `dtypes` | equally sized array of null or nonempty strings of at most 256 characters | Training DataFrame dtype declarations in feature order; null means unknown. They do not prove model input types. | +| `model_dtypes` | optional equally sized array of nonempty strings of at most 256 characters | Verified dtypes exposed by the native model, or null if it exposes none; separate from training dtypes. ONNX uses normalized NumPy dtype names such as `float32`, `float64`, `int32`, `int64`. | +| `origin` | `"model"`, `"dataframe"`, `"generated"` | Source of the effective names. | + +If a model exposes feature names, origin must be `model` and names must equal +its extracted names in order. Otherwise `dataframe` names are author declarations +from the locally supplied frame; the reader verifies their count, not their +historical truth. With no model names or frame names, origin is `generated` and +names must be `feature_1` through `feature_N`. Claim extraction represents missing +model feature names as null, so falsely declaring a named model's origin as +`dataframe` or `generated` also fails comparison. Model dtype extraction is +independent of name origin and must be compared even when its claim is null. + +### `target` + +| Field | Type | Meaning / verification | +| --- | --- | --- | +| `class_labels` | null or array of label arrays | Verified original labels in prediction-column order, **one array per target output**, even for a single output. Regressors use null. Each output has 1–100,000 unique labels and its length equals the corresponding class count. | +| `name` | optional nonempty string, at most 1,024 characters | Target name when known; an author declaration. | + +Labels are strict strings (including an empty original label), integers, finite +binary64 numbers or booleans. Their +types are preserved; `1`, `1.0`, `true` and `"1"` have distinct canonical bytes. +NaN/infinite class labels are unsupported. String labels are at most 1,024 +characters. Do not convert labels to display strings in the bundle or silently +reorder them; a consumer may format them for display separately. + +### `environment` + +| Field | Type | Meaning / verification | +| --- | --- | --- | +| `python_version` | nonempty string, max 256 | Training Python version. | +| `python_implementation` | nonempty string, max 256 | Implementation, e.g. `CPython`. | +| `os` | nonempty string, max 256 | OS name. | +| `os_version` | nonempty string, max 256 | OS release. | +| `architecture` | nonempty string, max 256 | Machine architecture, e.g. `AMD64`. | +| `packages` | object with only the keys listed below | Installed distribution versions, nonempty strings max 256. Uninstalled distributions are omitted. | +| `onnx` | optional ONNX record | Required for ONNX, null/absent otherwise; verified from the graph. | + +Allowed package keys are `mlbundle`, `mlschema`, `numpy`, `pandas`, `scipy`, +`scikit-learn`, `xgboost`, `onnx`, `onnxruntime` and `skops` (maximum ten). +Capture versions using distribution metadata, without importing the libraries. +`mlbundle` is required by the writer-version consistency rule. Other environment +fields are unverifiable declarations about the training machine, including the +ONNX/ONNX Runtime package versions; they are not evidence of compatibility. + +The ONNX record contains required fields: `opsets` (1–128 objects with `domain`, +a string of 0–256 characters, and `version`, an integer 1–2^31−1), `ir_version` +(integer 1–2^31−1), `producer_name` and `producer_version` (strings 0–256). +Opset domains are unique and sorted by Unicode code point. All these graph +fields are verified claims. Matching producer text proves agreement with the +file, not who produced or trained it. + +### `integrity`, `references` and `user_metadata` + +`integrity` is an object mapping **every other present member** to exactly +`{"sha256":HEX,"size":BYTES}`. The digest is 64 lowercase hexadecimal characters; +size is an integer 0–512 MiB, further bounded by the specific member cap. +It has 1–4 keys: exactly the selected model and referenced optional members. +There is no manifest hash, since the manifest cannot contain its own digest. +Declared sizes must equal both actual uncompressed sizes and ZIP metadata. + +`references` is a required object with optional `schema`, `structure` and +`samples` fields. Each is null/absent or respectively the literal `"schema.json"`, +`"structure.json"`, `"samples.json"`. There are no URLs or arbitrary paths. + +`user_metadata` is a free JSON object for name, description, author, tags, +training metrics, dataset description or other annotations. Maximum 1,024 +top-level keys, 64 KiB canonical UTF-8 bytes and depth 8. Nested collection and +string sizes are bounded by that byte budget. Metadata is entirely declarative; +unknown keys here are permitted and never affect adapter selection or trust. + +## Canonical JSON and claim comparison + +All JSON writers use the following deterministic encoding. Readers accept other +valid whitespace/key orders but always normalize for comparison. Reject duplicate +keys at every depth, invalid UTF-8, unpaired Unicode surrogates, malformed JSON, +bare NaN/Infinity tokens, numbers overflowing binary64, and excessive size/depth. +Check byte size before UTF-8 decoding and lexical container depth before parsing. +Depth counts nested objects/arrays (root container is depth 1, scalar is depth 0). +The default decoder bound is 8 MiB/32; use each member's explicit byte cap for +larger JSON members. The typed manifest remains subject to its tighter budgets. + +1. Encode UTF-8 without BOM or a trailing newline, with no insignificant spaces. +2. Sort object keys lexicographically by Unicode code point; preserve array order. + Do not apply Unicode normalization. Non-ASCII characters are emitted directly. + Escape quotes, backslashes and control characters exactly as Python's + `json.dumps(ensure_ascii=False, separators=(",", ":"))` does: short escapes + for backspace, tab, newline, form feed and carriage return, otherwise lowercase + four-digit `\u` escapes for U+0000–U+001F. `/` is not escaped. +3. Null, booleans and integers use JSON literal spellings and base-10 integer + representation. Integers are arbitrary precision up to 4,096 decimal digits + (excluding a minus sign); longer integer tokens or values are rejected. +4. Finite floats use CPython's shortest round-tripping binary64 `repr` spelling, + including `.0` for integral floats and `-0.0` for negative zero. Use lowercase + `e`, an explicit sign and at least two exponent digits in scientific notation; + scientific notation is used when decimal exponent is below −4 or at least 16. + Examples: `0.1`, `1.0`, `-0.0`, `1e-07`, `1e+20`, `5e-324`. + This is the mlbundle encoding, not RFC 8785/JCS. Readers in other languages + must preserve integer/float distinctions and use the same numeric spelling. +5. Native NaN and infinities are encoded as exact one-key objects + `{"$float":"nan"}`, `{"$float":"+inf"}`, `{"$float":"-inf"}`. `$float` is + reserved: additional keys or another value are rejected. Decoding retains the + tagged objects; it does not create non-finite JSON floats. NaN payload/sign + distinctions are intentionally discarded; finite values round-trip exactly. +6. Writer-side NumPy bool/integer/real/string scalars map to their Python scalar + values; arrays map via `tolist()` preserving dimensions and index order. + float32 is promoted exactly to binary64 before encoding, without rounding to + a decimal display precision. Complex, datetime, byte-string and other values + lacking a supported scalar representation fail. Tuples encode as arrays. + +SHA-256 is computed over the **stored uncompressed member bytes**, with size +measured in bytes. Writer-generated JSON member bytes use this encoding; native +model bytes are hashed unchanged. Readers verify original bytes before parsing, +not a re-encoding that could conceal corruption. For verified claims and structure, +compare canonical encoded values for exact byte equality, with no tolerance, +unordered-array comparison or coercion. Null/absent extracted information is +compared too. Unknown-field, count/alignment and reference checks are distinct +from recomputing claims: plausible metadata alone never verifies a model. + +The manifest's claim projection is `ModelClaims`: estimator class, kind, +hyperparameters, feature/class/output counts, model-origin feature names (null +otherwise), model dtypes, original output-major class labels and ONNX graph +metadata. Library/serialization/member pairing is validated by the closed +registry. Structure is compared separately using the same canonical encoding. +Schema, training dtypes, frame-origin names, target name, training environment, +user metadata, identity and samples are not verified against the model; they must +still pass their own validation and integrity checks. Inspection verifies format +and integrity only, and must never attest that model claims match the model. + +## `structure.json` + +Structure is never embedded in the manifest. It is a JSON object with required +`family` and `feature_importances` fields, plus exactly the family fields below. +`feature_importances` is null or an ordered numeric array of length `n_features`. +Numbers use the canonical encoding, including non-finite tags. Arrays and tree +lists preserve native order; no pruning, rounding, flattening of output/class +dimensions or display-oriented simplification is permitted. + +| `family` | Additional required fields | +| --- | --- | +| `decision_tree` | `tree`: one tree object. | +| `tree_ensemble` | `estimator_shape`: original shape of the ensemble's estimator collection; `trees`: row-major flattened list of tree objects. Product of shape equals list length. A one-dimensional collection uses `[N]`. | +| `linear` | `coef`, `intercept`: native numeric arrays/scalars converted without reshaping. | +| `xgboost` | `trees`: ordered list of parsed JSON tree dumps, each from `get_dump(dump_format="json", with_stats=True)`. Retain all native fields, including node IDs, split/leaf values, missing-child IDs, gain/cover and children. Do not embed dump strings. | +| `feature_importances` | No additional fields; the importance array must be non-null. | + +A scikit-learn tree object has exactly `node_count` (positive integer), +`n_features` (positive integer), `n_outputs` (positive integer), `n_classes` +(native per-output class-count array), and these native `tree_` arrays: +`children_left`, `children_right`, `feature`, `threshold`, `value`, `impurity`, +`n_node_samples`, `weighted_n_node_samples`. Except `value`, each array's length +is `node_count`; `value` retains its native `[node, output, class/value]` shape. +Preserve leaf and undefined-feature sentinel values, and sklearn's native value +normalization for the installed version. Do not reinterpret `value` as counts. +Trees use each constituent tree's own dimensions, not an ensemble-wide guess. + +Select family in this precedence: a single `tree_`, a tree ensemble, linear +coefficients/intercept, then feature importances only. An ensemble exposing +unsupported non-tree constituents must not masquerade as a supported tree +ensemble. Unsupported or oversized mandatory structure fails export rather than +being silently omitted. ONNX graph visualizations and additional structure +families require a deliberate future contract change. + +## Adapter and result contracts + +The internal `Adapter` protocol in `mlbundle.adapters` owns recognition, +serialization, loading, claim extraction, structure extraction and a `Predictor`. +The registry imports only the three constant mlbundle module paths `_sklearn`, +`_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library +imports must be lazy, with dependency failures naming the extra to install. +Missing adapter modules currently raise `AdapterNotImplementedError`; there +are no public export/load/inspect placeholders. + +Predictors accept adapter-supported tabular objects without a core pandas/numpy +dependency. Regressors return `list[float]` for a single output or row-major +`list[list[float]]` for multiple outputs. Classifiers return row-major +`list[list[float]]` for one output or an output-major list of those matrices for +multiple outputs, aligned with class labels. Validate probability dimensions, +finite probabilities, range and row sums in the adapter. Classifier prediction +uses `predict_proba`; regressor prediction uses `predict`. Unsupported probability +or regression contracts fail rather than synthesizing outputs. + +`MetadataView` carries `manifest` and a tuple of `MemberName` values including +the manifest; it has no model. `LoadedBundle` additionally carries the exact +native object, a uniform predictor, optional schema/structure/sample values, and +properties for kind, library, class name, ordered feature names and origin, and +output-major original class labels. Calling the prediction method for the wrong +kind raises a bounded `ValueError`. Result records and typed models prevent field +reassignment, but nested JSON values and the native model are not deeply frozen. +Construction itself performs no model validation; only a future validating reader +can supply the loaded-result guarantee. Consumers must not mutate a validated +model and continue treating its original metadata as verified. + +## Versioning and integrity + +Readers must reject unknown format versions rather than guess. Any change to +required fields, accepted identifiers, member rules, claim semantics, numeric +encoding or mandatory structure shapes requires a new format version and an +explicit reader implementation. Tightening caller-selected resource limits does +not change the format. Corrections to documentation that preserve wire behavior +and implementation changes behind these contracts do not require a version bump. + +Hashes detect corruption, not deliberate tampering: an attacker can replace a +model and update every record. Claim recomputation prevents a bundle from showing +metadata/structure different from its prediction model; it does not establish +training history, model quality, fairness or author identity. Signing is out of +scope. See [SECURITY.md](../SECURITY.md) for the complete threat model. diff --git a/pyproject.toml b/pyproject.toml index 2d19c99..0670d97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,17 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">=3.14" -dependencies = [] +dependencies = ["pydantic>=2.13.4,<3"] + +[project.optional-dependencies] +# sklearn's floor matches skops; other floors match the target runtime baseline. +sklearn = ["scikit-learn>=1.2", "skops>=0.16.0"] +xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] +onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] +all = [ + "scikit-learn>=1.2", "skops>=0.16.0", "xgboost>=3.4.1", + "onnx>=1.23.0", "onnxruntime>=1.30.0", +] [project.urls] Homepage = "https://github.com/UlloaSP/mlbundle" @@ -47,6 +57,8 @@ dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", "pre-commit>=4.6.0", + # Install every adapter in the default development environment. + "mlbundle[all]", ] [build-system] @@ -101,7 +113,8 @@ directory = "htmlcov" [tool.ruff] line-length = 88 -target-version = "py314" +# Keep source syntax portable while package metadata requires Python 3.14. +target-version = "py313" src = ["src", "tests"] extend-exclude = [".git", ".venv", "__pycache__", "build", "dist", "*.egg-info"] diff --git a/src/mlbundle/__init__.py b/src/mlbundle/__init__.py index d326661..ddc5db4 100644 --- a/src/mlbundle/__init__.py +++ b/src/mlbundle/__init__.py @@ -2,6 +2,68 @@ from importlib.metadata import version as _version +from .claims import ModelClaims +from .contract import ( + FORMAT_NAME, + FORMAT_VERSION, + FeatureOrigin, + Library, + MemberName, + ModelKind, + SerializationFormat, +) +from .environment import OnnxInfo, Opset, TrainingEnvironment +from .errors import ( + AdapterNotImplementedError, + ClaimMismatchError, + IntegrityMismatchError, + InvalidContainerError, + InvalidJSONError, + InvalidManifestError, + LimitExceededError, + MissingOptionalDependencyError, + MLBundleError, + UnsupportedFormatVersionError, + UnsupportedModelError, +) +from .integrity import IntegrityRecord +from .limits import ArchiveLimits +from .manifest import FeatureInfo, Manifest, MemberReferences, ModelInfo, TargetInfo +from .results import LoadedBundle, MetadataView + __version__ = _version("mlbundle") -__all__ = ["__version__"] +__all__ = [ + "FORMAT_NAME", + "FORMAT_VERSION", + "AdapterNotImplementedError", + "ArchiveLimits", + "ClaimMismatchError", + "FeatureInfo", + "FeatureOrigin", + "IntegrityMismatchError", + "IntegrityRecord", + "InvalidContainerError", + "InvalidJSONError", + "InvalidManifestError", + "Library", + "LimitExceededError", + "LoadedBundle", + "MLBundleError", + "Manifest", + "MemberName", + "MemberReferences", + "MetadataView", + "MissingOptionalDependencyError", + "ModelClaims", + "ModelInfo", + "ModelKind", + "OnnxInfo", + "Opset", + "SerializationFormat", + "TargetInfo", + "TrainingEnvironment", + "UnsupportedFormatVersionError", + "UnsupportedModelError", + "__version__", +] diff --git a/src/mlbundle/_base.py b/src/mlbundle/_base.py new file mode 100644 index 0000000..d9f1c20 --- /dev/null +++ b/src/mlbundle/_base.py @@ -0,0 +1,19 @@ +"""Shared strict model configuration and bounded field types.""" + +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, StringConstraints + +ShortString = Annotated[str, StringConstraints(min_length=1, max_length=256)] +Name = Annotated[str, StringConstraints(min_length=1, max_length=1024)] + + +class ContractModel(BaseModel): + model_config = ConfigDict( + strict=True, + extra="forbid", + frozen=True, + validate_default=True, + hide_input_in_errors=True, + allow_inf_nan=False, + ) diff --git a/src/mlbundle/adapters.py b/src/mlbundle/adapters.py new file mode 100644 index 0000000..93fa7e3 --- /dev/null +++ b/src/mlbundle/adapters.py @@ -0,0 +1,29 @@ +"""Adapter and predictor contracts, independent of optional library types.""" + +from typing import Protocol + +from pydantic import JsonValue + +from .claims import ModelClaims +from .contract import Library, MemberName + +RegressionOutput = list[float] | list[list[float]] +ProbabilityOutput = list[list[float]] | list[list[list[float]]] + + +class Predictor(Protocol): + def predict(self, data: object) -> RegressionOutput: ... + def predict_proba(self, data: object) -> ProbabilityOutput: ... + + +class Adapter(Protocol): + @property + def library(self) -> Library: ... + @property + def model_member(self) -> MemberName: ... + def recognizes(self, model: object) -> bool: ... + def serialize(self, model: object) -> bytes: ... + def deserialize(self, data: bytes) -> object: ... + def extract_claims(self, model: object) -> ModelClaims: ... + def extract_structure(self, model: object) -> dict[str, JsonValue] | None: ... + def predictor(self, model: object) -> Predictor: ... diff --git a/src/mlbundle/canonical.py b/src/mlbundle/canonical.py new file mode 100644 index 0000000..aaa6ce2 --- /dev/null +++ b/src/mlbundle/canonical.py @@ -0,0 +1,153 @@ +"""Canonical UTF-8 JSON with explicit non-finite numbers and bounded parsing.""" + +import json +import math +from typing import cast + +from pydantic import JsonValue + +from .errors import InvalidJSONError, LimitExceededError +from .limits import JSON_DEPTH, MANIFEST_BYTES + +_FLOAT_TAG = "$float" +_INTEGER_CEILING = 10**4096 + + +def _float(value: float, depth: int, max_depth: int) -> JsonValue: + if math.isfinite(value): + return value + if depth >= max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + token = "nan" if math.isnan(value) else "+inf" if value > 0 else "-inf" + return {_FLOAT_TAG: token} + + +def _numpy(value: object, depth: int, max_depth: int) -> JsonValue: + # Writer-side numpy conversion needs no import in the base package. + if type(value).__module__.split(".")[0] == "numpy": + array = type(value).__name__ == "ndarray" + kind = getattr(getattr(value, "dtype", None), "kind", None) + if kind not in ("b", "i", "u", "f", "U") and not (array and kind == "O"): + raise InvalidJSONError + method = "tolist" if array else "item" + convert = getattr(value, method, None) + if callable(convert): + converted = convert() + if type(converted) is not type(value): + return _normalize(converted, depth, max_depth) + raise InvalidJSONError + + +def _normalize(value: object, depth: int, max_depth: int) -> JsonValue: + if isinstance(value, (dict, list, tuple)) and depth >= max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + if type(value) is int and abs(value) >= _INTEGER_CEILING: + raise InvalidJSONError + if value is None or type(value) in (bool, int, str): + return cast(JsonValue, value) + if type(value) is float: + return _float(value, depth, max_depth) + if isinstance(value, dict): + if any(type(key) is not str for key in value): + raise InvalidJSONError + if _FLOAT_TAG in value and ( + len(value) != 1 or value[_FLOAT_TAG] not in ("nan", "+inf", "-inf") + ): + raise InvalidJSONError + return { + key: _normalize(item, depth + 1, max_depth) for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_normalize(item, depth + 1, max_depth) for item in value] + return _numpy(value, depth, max_depth) + + +def canonical_dumps( + value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> bytes: + try: + encoded = json.dumps( + _normalize(value, 0, max_depth), + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (ValueError, TypeError, UnicodeError, RecursionError): + raise InvalidJSONError from None + if len(encoded) > max_bytes: + raise LimitExceededError("JSON exceeds the byte limit.") + return encoded + + +def _check_depth(text: str, max_depth: int) -> None: + depth = 0 + quoted = escaped = False + for character in text: + if quoted: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quoted = False + elif character == '"': + quoted = True + elif character in "[{": + depth += 1 + if depth > max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + elif character in "]}": + depth -= 1 + + +def _object(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} + for key, value in pairs: + if key in result: + raise InvalidJSONError + result[key] = value + if _FLOAT_TAG in result and ( + len(result) != 1 or result[_FLOAT_TAG] not in ("nan", "+inf", "-inf") + ): + raise InvalidJSONError + return result + + +def _finite_float(token: str) -> float: + result = float(token) + if not math.isfinite(result): + raise InvalidJSONError + return result + + +def _constant(_token: str) -> JsonValue: + raise InvalidJSONError + + +def _integer(token: str) -> int: + if len(token.lstrip("-")) > 4096: + raise InvalidJSONError + return int(token) + + +def canonical_loads( + data: bytes, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> JsonValue: + if len(data) > max_bytes: + raise LimitExceededError("JSON exceeds the byte limit.") + try: + text = data.decode("utf-8") + _check_depth(text, max_depth) + value = json.loads( + text, + object_pairs_hook=_object, + parse_constant=_constant, + parse_float=_finite_float, + parse_int=_integer, + ) + # Reject invalid Unicode and enforce the encoder's bounds too. + canonical_dumps(value, max_bytes=max_bytes, max_depth=max_depth) + except (ValueError, TypeError, UnicodeError, RecursionError): + raise InvalidJSONError from None + return cast(JsonValue, value) diff --git a/src/mlbundle/claims.py b/src/mlbundle/claims.py new file mode 100644 index 0000000..be01756 --- /dev/null +++ b/src/mlbundle/claims.py @@ -0,0 +1,106 @@ +"""One representation for extracted claims and deterministic comparison.""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import Field, JsonValue, field_validator, model_validator + +from ._base import ContractModel, Name, ShortString +from .canonical import canonical_dumps +from .contract import ModelKind +from .environment import OnnxInfo +from .errors import ClaimMismatchError +from .limits import ( + HYPERPARAMETER_BYTES, + MAX_CLASSES, + MAX_FEATURES, + MAX_OUTPUTS, + STRUCTURE_BYTES, +) + +Label = Annotated[str, Field(max_length=1024)] | int | float | bool +LabelSet = Annotated[list[Label], Field(min_length=1, max_length=MAX_CLASSES)] +ClassLabels = Annotated[list[LabelSet], Field(min_length=1, max_length=MAX_OUTPUTS)] +ClassCounts = Annotated[ + list[Annotated[int, Field(gt=0, le=MAX_CLASSES)]], + Field(min_length=1, max_length=MAX_OUTPUTS), +] +FeatureNames = Annotated[list[Name], Field(min_length=1, max_length=MAX_FEATURES)] +FeatureDtypes = Annotated[ + list[ShortString], Field(min_length=1, max_length=MAX_FEATURES) +] + + +class EstimatorInfo(ContractModel): + estimator_class: ShortString + kind: ModelKind + hyperparameters: dict[str, JsonValue] = Field(max_length=4096) + n_features: int = Field(gt=0, le=MAX_FEATURES) + n_classes: ClassCounts | None + n_outputs: int = Field(gt=0, le=MAX_OUTPUTS) + + @field_validator("hyperparameters") + @classmethod + def bounded_parameters(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + canonical_dumps(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) + return value + + @model_validator(mode="after") + def counts_match_kind(self) -> EstimatorInfo: + if self.kind == ModelKind.CLASSIFIER: + if self.n_classes is None or len(self.n_classes) != self.n_outputs: + raise ValueError("Classifier counts must describe each output.") + elif self.n_classes is not None: + raise ValueError("Regressors cannot declare class counts.") + return self + + +def validate_labels(info: EstimatorInfo, labels: ClassLabels | None) -> None: + if info.kind == ModelKind.REGRESSOR: + if labels is not None: + raise ValueError("Regressors cannot declare class labels.") + return + if labels is None or info.n_classes != [len(output) for output in labels]: + raise ValueError("Class labels must match the output class counts.") + for output in labels: + encoded = [canonical_dumps(label) for label in output] + if len(encoded) != len(set(encoded)): + raise ValueError("Class labels must be unique within an output.") + + +class ModelClaims(EstimatorInfo): + feature_names: FeatureNames | None + feature_dtypes: FeatureDtypes | None = None + class_labels: ClassLabels | None + onnx: OnnxInfo | None = None + + @model_validator(mode="after") + def consistent_claims(self) -> ModelClaims: + validate_labels(self, self.class_labels) + if self.feature_names is not None and ( + len(self.feature_names) != self.n_features + or len(set(self.feature_names)) != len(self.feature_names) + ): + raise ValueError("Model feature names must match the feature count.") + if ( + self.feature_dtypes is not None + and len(self.feature_dtypes) != self.n_features + ): + raise ValueError("Model dtypes must match the feature count.") + return self + + +def verify_claims(expected: ModelClaims, actual: ModelClaims) -> None: + expected_fields = expected.model_dump(mode="json") + actual_fields = actual.model_dump(mode="json") + for name, value in expected_fields.items(): + if canonical_dumps(value) != canonical_dumps(actual_fields[name]): + raise ClaimMismatchError(name) + + +def verify_structure(expected: JsonValue, actual: JsonValue) -> None: + if canonical_dumps(expected, max_bytes=STRUCTURE_BYTES) != canonical_dumps( + actual, max_bytes=STRUCTURE_BYTES + ): + raise ClaimMismatchError("structure") diff --git a/src/mlbundle/contract.py b/src/mlbundle/contract.py new file mode 100644 index 0000000..b16ee36 --- /dev/null +++ b/src/mlbundle/contract.py @@ -0,0 +1,49 @@ +"""Closed format identifiers; these never select external Python classes.""" + +from enum import StrEnum +from types import MappingProxyType + +FORMAT_NAME = "mlbundle" +FORMAT_VERSION = "1" + + +class Library(StrEnum): + SKLEARN = "sklearn" + XGBOOST = "xgboost" + ONNX = "onnx" + + +class ModelKind(StrEnum): + CLASSIFIER = "classifier" + REGRESSOR = "regressor" + + +class SerializationFormat(StrEnum): + SKOPS = "skops" + UBJSON = "ubjson" + ONNX = "onnx" + + +class FeatureOrigin(StrEnum): + MODEL = "model" + DATAFRAME = "dataframe" + GENERATED = "generated" + + +class MemberName(StrEnum): + MANIFEST = "manifest.json" + SKOPS = "model.skops" + UBJSON = "model.ubj" + ONNX = "model.onnx" + SCHEMA = "schema.json" + STRUCTURE = "structure.json" + SAMPLES = "samples.json" + + +MODEL_MEMBERS = MappingProxyType( + { + Library.SKLEARN: (SerializationFormat.SKOPS, MemberName.SKOPS), + Library.XGBOOST: (SerializationFormat.UBJSON, MemberName.UBJSON), + Library.ONNX: (SerializationFormat.ONNX, MemberName.ONNX), + } +) diff --git a/src/mlbundle/environment.py b/src/mlbundle/environment.py new file mode 100644 index 0000000..7c6dd4f --- /dev/null +++ b/src/mlbundle/environment.py @@ -0,0 +1,83 @@ +"""Environment declarations without importing optional model libraries.""" + +from __future__ import annotations + +import platform +from importlib import metadata +from typing import Annotated, Literal + +from pydantic import Field, model_validator + +from ._base import ContractModel, ShortString + +PackageName = Literal[ + "mlbundle", + "mlschema", + "numpy", + "pandas", + "scipy", + "scikit-learn", + "xgboost", + "onnx", + "onnxruntime", + "skops", +] +PACKAGE_NAMES: tuple[PackageName, ...] = ( + "mlbundle", + "mlschema", + "numpy", + "pandas", + "scipy", + "scikit-learn", + "xgboost", + "onnx", + "onnxruntime", + "skops", +) + + +class Opset(ContractModel): + domain: Annotated[str, Field(max_length=256)] + version: int = Field(gt=0, le=2**31 - 1) + + +class OnnxInfo(ContractModel): + opsets: list[Opset] = Field(min_length=1, max_length=128) + ir_version: int = Field(gt=0, le=2**31 - 1) + producer_name: Annotated[str, Field(max_length=256)] + producer_version: Annotated[str, Field(max_length=256)] + + @model_validator(mode="after") + def unique_domains(self) -> OnnxInfo: + domains = [opset.domain for opset in self.opsets] + if domains != sorted(set(domains)): + raise ValueError("Opsets must have unique, sorted domains.") + return self + + +class TrainingEnvironment(ContractModel): + python_version: ShortString + python_implementation: ShortString + os: ShortString + os_version: ShortString + architecture: ShortString + packages: dict[PackageName, ShortString] = Field(max_length=len(PACKAGE_NAMES)) + onnx: OnnxInfo | None = None + + +def capture_environment(*, onnx: OnnxInfo | None = None) -> TrainingEnvironment: + packages: dict[PackageName, str] = {} + for name in PACKAGE_NAMES: + try: + packages[name] = metadata.version(name) + except metadata.PackageNotFoundError: + continue + return TrainingEnvironment( + python_version=platform.python_version(), + python_implementation=platform.python_implementation(), + os=platform.system(), + os_version=platform.release(), + architecture=platform.machine(), + packages=packages, + onnx=onnx, + ) diff --git a/src/mlbundle/errors.py b/src/mlbundle/errors.py new file mode 100644 index 0000000..c1e260b --- /dev/null +++ b/src/mlbundle/errors.py @@ -0,0 +1,71 @@ +"""Bounded errors at bundle trust boundaries.""" + + +def _identifier(value: str) -> str: + if ( + len(value) <= 160 + and value.isascii() + and all(character.isalnum() or character in "._-" for character in value) + ): + return value + return "unrecognized" + + +class MLBundleError(Exception): + """Base for failures reported by mlbundle.""" + + +class InvalidContainerError(MLBundleError): + """The archive violates the container contract.""" + + +class LimitExceededError(MLBundleError): + """An input exceeds a resource limit.""" + + +class UnsupportedFormatVersionError(MLBundleError): + def __init__(self) -> None: + super().__init__("Unsupported mlbundle format version.") + + +class InvalidManifestError(MLBundleError): + def __init__(self) -> None: + super().__init__("Invalid mlbundle manifest.") + + +class InvalidJSONError(MLBundleError): + def __init__(self) -> None: + super().__init__("Invalid or unsupported JSON value.") + + +class IntegrityMismatchError(MLBundleError): + def __init__(self, member: str) -> None: + self.member = _identifier(member) + super().__init__(f"Integrity mismatch for member {self.member}.") + + +class ClaimMismatchError(MLBundleError): + def __init__(self, claim: str) -> None: + self.claim = _identifier(claim) + super().__init__(f"Model claim mismatch: {self.claim}.") + + +class UnsupportedModelError(MLBundleError): + def __init__(self, untrusted_types: tuple[str, ...] = ()) -> None: + self.untrusted_types = untrusted_types + names = ", ".join(_identifier(name) for name in untrusted_types[:20]) + super().__init__( + f"Unsupported model ({len(untrusted_types)} untrusted types): {names}." + ) + + +class MissingOptionalDependencyError(MLBundleError): + def __init__(self, extra: str) -> None: + self.extra = _identifier(extra) + super().__init__(f"Install mlbundle[{self.extra}] for this adapter.") + + +class AdapterNotImplementedError(MLBundleError): + def __init__(self, library: str) -> None: + self.library = _identifier(library) + super().__init__(f"The {self.library} adapter is not implemented yet.") diff --git a/src/mlbundle/integrity.py b/src/mlbundle/integrity.py new file mode 100644 index 0000000..aa8b9c2 --- /dev/null +++ b/src/mlbundle/integrity.py @@ -0,0 +1,30 @@ +"""Integrity of stored member bytes; this does not authenticate an author.""" + +import hashlib +import hmac +from typing import Annotated + +from pydantic import Field, StringConstraints + +from ._base import ContractModel +from .contract import MemberName +from .errors import IntegrityMismatchError +from .limits import MIB + + +class IntegrityRecord(ContractModel): + sha256: Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + size: int = Field(ge=0, le=512 * MIB) + + +def hash_member(data: bytes) -> IntegrityRecord: + return IntegrityRecord(sha256=hashlib.sha256(data).hexdigest(), size=len(data)) + + +def verify_integrity( + member: MemberName, data: bytes, expected: IntegrityRecord +) -> None: + if len(data) != expected.size or not hmac.compare_digest( + hashlib.sha256(data).hexdigest(), expected.sha256 + ): + raise IntegrityMismatchError(member.value) diff --git a/src/mlbundle/limits.py b/src/mlbundle/limits.py new file mode 100644 index 0000000..689014c --- /dev/null +++ b/src/mlbundle/limits.py @@ -0,0 +1,38 @@ +"""Format safety ceilings. Readers and writers may only lower them.""" + +from pydantic import Field + +from ._base import ContractModel +from .contract import MemberName + +MIB = 1024 * 1024 +MANIFEST_BYTES = 8 * MIB +STRUCTURE_BYTES = 256 * MIB +METADATA_BYTES = 64 * 1024 +HYPERPARAMETER_BYTES = MIB +JSON_DEPTH = 32 +MAX_FEATURES = 100_000 +MAX_CLASSES = 100_000 +MAX_OUTPUTS = 1024 + + +class ArchiveLimits(ContractModel): + max_members: int = Field(default=5, ge=2, le=5) + max_archive_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) + max_compressed_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) + max_member_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) + max_total_bytes: int = Field(default=1024 * MIB, gt=0, le=1024 * MIB) + max_compression_ratio: int = Field(default=200, gt=0, le=200) + max_manifest_bytes: int = Field(default=MANIFEST_BYTES, gt=0, le=MANIFEST_BYTES) + max_structure_bytes: int = Field(default=STRUCTURE_BYTES, gt=0, le=STRUCTURE_BYTES) + max_schema_bytes: int = Field(default=16 * MIB, gt=0, le=16 * MIB) + max_samples_bytes: int = Field(default=16 * MIB, gt=0, le=16 * MIB) + + def member_bytes(self, member: MemberName) -> int: + cap = { + MemberName.MANIFEST: self.max_manifest_bytes, + MemberName.STRUCTURE: self.max_structure_bytes, + MemberName.SCHEMA: self.max_schema_bytes, + MemberName.SAMPLES: self.max_samples_bytes, + }.get(member, self.max_member_bytes) + return min(cap, self.max_member_bytes) diff --git a/src/mlbundle/manifest.py b/src/mlbundle/manifest.py new file mode 100644 index 0000000..14341e3 --- /dev/null +++ b/src/mlbundle/manifest.py @@ -0,0 +1,180 @@ +"""Version 1 manifest validation and canonical JSON boundaries.""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import Field, JsonValue, ValidationError, field_validator, model_validator + +from ._base import ContractModel, Name, ShortString +from .canonical import canonical_dumps, canonical_loads +from .claims import ( + ClassLabels, + EstimatorInfo, + FeatureDtypes, + FeatureNames, + ModelClaims, + validate_labels, +) +from .contract import ( + FORMAT_VERSION, + MODEL_MEMBERS, + FeatureOrigin, + Library, + MemberName, + SerializationFormat, +) +from .environment import TrainingEnvironment +from .errors import ( + InvalidJSONError, + InvalidManifestError, + UnsupportedFormatVersionError, +) +from .integrity import IntegrityRecord +from .limits import METADATA_BYTES, ArchiveLimits + +_UTC_TIMESTAMP = re.compile( + r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}" + r"(?:\.[0-9]{1,6})?(?:Z|\+00:00)" +) + + +class ModelInfo(EstimatorInfo): + library: Library + serialization: SerializationFormat + + @model_validator(mode="after") + def native_serialization(self) -> ModelInfo: + if self.serialization != MODEL_MEMBERS[self.library][0]: + raise ValueError("Library and serialization must agree.") + return self + + +class FeatureInfo(ContractModel): + names: FeatureNames + dtypes: list[ShortString | None] = Field(min_length=1, max_length=100_000) + model_dtypes: FeatureDtypes | None = None + origin: FeatureOrigin + + @model_validator(mode="after") + def aligned_features(self) -> FeatureInfo: + if len(self.names) != len(self.dtypes) or len(set(self.names)) != len( + self.names + ): + raise ValueError("Feature names and dtypes must align and names be unique.") + if self.model_dtypes is not None and len(self.model_dtypes) != len(self.names): + raise ValueError("Model dtypes must align with feature names.") + if self.origin == FeatureOrigin.GENERATED and self.names != [ + f"feature_{index}" for index in range(1, len(self.names) + 1) + ]: + raise ValueError("Generated names must follow the format convention.") + return self + + +class TargetInfo(ContractModel): + class_labels: ClassLabels | None + name: Name | None = None + + +class MemberReferences(ContractModel): + schema_: Literal["schema.json"] | None = Field(default=None, alias="schema") + structure: Literal["structure.json"] | None = None + samples: Literal["samples.json"] | None = None + + +class Manifest(ContractModel): + format_name: Literal["mlbundle"] + format_version: Literal["1"] + bundle_id: UUID + created_at: datetime + writer_version: ShortString + model: ModelInfo + features: FeatureInfo + target: TargetInfo + environment: TrainingEnvironment + integrity: dict[MemberName, IntegrityRecord] = Field(min_length=1, max_length=4) + references: MemberReferences + user_metadata: dict[str, JsonValue] = Field(default_factory=dict, max_length=1024) + + @field_validator("format_version", mode="before") + @classmethod + def supported_version(cls, value: object) -> object: + if isinstance(value, str) and value != FORMAT_VERSION: + raise UnsupportedFormatVersionError + return value + + @field_validator("created_at") + @classmethod + def utc_time(cls, value: datetime) -> datetime: + offset = value.utcoffset() + if offset is None or offset.total_seconds() != 0: + raise ValueError("Creation time must have a UTC offset.") + return value + + @field_validator("created_at", mode="before") + @classmethod + def timestamp_spelling(cls, value: object) -> object: + if isinstance(value, str): + if _UTC_TIMESTAMP.fullmatch(value) is None: + raise ValueError("Creation time must be an ISO UTC timestamp.") + return datetime.fromisoformat(value) + return value + + @field_validator("user_metadata") + @classmethod + def bounded_metadata(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + canonical_dumps(value, max_bytes=METADATA_BYTES, max_depth=8) + return value + + @model_validator(mode="after") + def consistent_manifest(self) -> Manifest: + if len(self.features.names) != self.model.n_features: + raise ValueError("Feature count and names must agree.") + validate_labels(self.model, self.target.class_labels) + expected = {MODEL_MEMBERS[self.model.library][1]} + expected.update( + MemberName(member) + for member in self.references.model_dump().values() + if member is not None + ) + if set(self.integrity) != expected: + raise ValueError("Integrity must cover exactly the referenced members.") + limits = ArchiveLimits() + if any( + record.size > limits.member_bytes(name) + for name, record in self.integrity.items() + ): + raise ValueError("A member exceeds its format size cap.") + if (self.model.library == Library.ONNX) != (self.environment.onnx is not None): + raise ValueError("ONNX graph metadata is required only for ONNX models.") + if self.environment.packages.get("mlbundle") != self.writer_version: + raise ValueError("Writer and environment mlbundle versions must agree.") + return self + + def verified_claims(self) -> ModelClaims: + fields = self.model.model_dump(exclude={"library", "serialization"}) + return ModelClaims( + **fields, + feature_names=( + self.features.names + if self.features.origin == FeatureOrigin.MODEL + else None + ), + class_labels=self.target.class_labels, + feature_dtypes=self.features.model_dtypes, + onnx=self.environment.onnx, + ) + + def to_bytes(self) -> bytes: + return canonical_dumps(self.model_dump(mode="json", by_alias=True)) + + @classmethod + def from_bytes(cls, data: bytes) -> Manifest: + try: + value = canonical_loads(data) + return cls.model_validate_json(canonical_dumps(value)) + except (ValidationError, InvalidJSONError): + raise InvalidManifestError from None diff --git a/src/mlbundle/registry.py b/src/mlbundle/registry.py new file mode 100644 index 0000000..0cc2fd0 --- /dev/null +++ b/src/mlbundle/registry.py @@ -0,0 +1,47 @@ +"""Only mlbundle-owned module paths can select an adapter.""" + +from importlib import import_module +from types import MappingProxyType +from typing import cast + +from .adapters import Adapter +from .contract import MODEL_MEMBERS, Library +from .errors import ( + AdapterNotImplementedError, + MissingOptionalDependencyError, + UnsupportedModelError, +) + +_ADAPTERS = MappingProxyType( + { + Library.SKLEARN: ( + "mlbundle._sklearn", + "sklearn", + {"sklearn", "skops", "numpy", "scipy"}, + ), + Library.XGBOOST: ( + "mlbundle._xgboost", + "xgboost", + {"xgboost", "sklearn", "numpy", "scipy"}, + ), + Library.ONNX: ("mlbundle._onnx", "onnx", {"onnx", "onnxruntime", "numpy"}), + } +) + + +def get_adapter(library: Library) -> Adapter: + if not isinstance(library, Library): + raise UnsupportedModelError + path, extra, dependencies = _ADAPTERS[library] + try: + module = import_module(path) + adapter = cast(Adapter, module.Adapter()) + except ModuleNotFoundError as error: + if error.name == path: + raise AdapterNotImplementedError(library.value) from None + if error.name and error.name.split(".")[0] in dependencies: + raise MissingOptionalDependencyError(extra) from None + raise + if adapter.library != library or adapter.model_member != MODEL_MEMBERS[library][1]: + raise UnsupportedModelError + return adapter diff --git a/src/mlbundle/results.py b/src/mlbundle/results.py new file mode 100644 index 0000000..522b1c5 --- /dev/null +++ b/src/mlbundle/results.py @@ -0,0 +1,66 @@ +"""Stable metadata-only and loaded result contracts for future readers.""" + +from dataclasses import dataclass, field + +from pydantic import JsonValue + +from .adapters import Predictor, ProbabilityOutput, RegressionOutput +from .claims import ClassLabels +from .contract import FeatureOrigin, Library, MemberName, ModelKind +from .manifest import Manifest + + +@dataclass(frozen=True) +class MetadataView: + """Integrity-checked metadata; model claims remain unverified by inspection.""" + + manifest: Manifest + members: tuple[MemberName, ...] + + +@dataclass(frozen=True) +class LoadedBundle: + """A reader constructs this only after validating model claims and structure.""" + + manifest: Manifest + members: tuple[MemberName, ...] + native_model: object = field(repr=False) + predictor: Predictor = field(repr=False) + schema: dict[str, JsonValue] | None = None + structure: dict[str, JsonValue] | None = None + samples: list[dict[str, JsonValue]] | None = None + + @property + def kind(self) -> ModelKind: + return self.manifest.model.kind + + @property + def library(self) -> Library: + return self.manifest.model.library + + @property + def class_name(self) -> str: + return self.manifest.model.estimator_class + + @property + def feature_names(self) -> tuple[str, ...]: + return tuple(self.manifest.features.names) + + @property + def feature_origin(self) -> FeatureOrigin: + return self.manifest.features.origin + + @property + def class_labels(self) -> ClassLabels | None: + labels = self.manifest.target.class_labels + return None if labels is None else [list(output) for output in labels] + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + return self.predictor.predict_proba(data) + + def predict(self, data: object) -> RegressionOutput: + if self.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + return self.predictor.predict(data) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c79112 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +"""Small wire-format fixture shared by contract feature tests.""" + +import pytest + +from mlbundle import __version__ +from mlbundle.integrity import hash_member + + +@pytest.fixture +def manifest_data() -> dict: + return { + "format_name": "mlbundle", + "format_version": "1", + "bundle_id": "f0d87d83-8cf9-4cf5-af08-e1c885156513", + "created_at": "2026-10-11T10:00:00Z", + "writer_version": __version__, + "model": { + "library": "sklearn", + "estimator_class": "DecisionTreeClassifier", + "kind": "classifier", + "serialization": "skops", + "hyperparameters": {"max_depth": 3, "random_state": 0}, + "n_features": 2, + "n_classes": [2], + "n_outputs": 1, + }, + "features": { + "names": ["a", "b"], + "dtypes": ["float64", None], + "model_dtypes": None, + "origin": "model", + }, + "target": {"class_labels": [[1, "positive"]], "name": "outcome"}, + "environment": { + "python_version": "3.14.0", + "python_implementation": "CPython", + "os": "Windows", + "os_version": "11", + "architecture": "AMD64", + "packages": {"mlbundle": __version__, "scikit-learn": "1.9.1"}, + "onnx": None, + }, + "integrity": {"model.skops": hash_member(b"native model bytes").model_dump()}, + "references": {"schema": None, "structure": None, "samples": None}, + "user_metadata": {"name": "example", "metrics": {"accuracy": 0.95}}, + } diff --git a/tests/test_canonical.py b/tests/test_canonical.py new file mode 100644 index 0000000..190fdf4 --- /dev/null +++ b/tests/test_canonical.py @@ -0,0 +1,147 @@ +"""Canonical numbers, numpy normalization and hostile JSON parsing.""" + +import math +import struct + +import numpy as np +import pytest + +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.errors import InvalidJSONError, LimitExceededError + + +def test_determinism_and_unicode(): + assert canonical_dumps({"z": 0, "a": "á"}) == '{"a":"á","z":0}'.encode() + assert canonical_dumps({"z": 0, "a": "á"}) == canonical_dumps({"a": "á", "z": 0}) + assert canonical_loads(b' { "z" : 0 } ') == {"z": 0} + + +@pytest.mark.parametrize( + "number", + [0.0, -0.0, 0.1, 1.2345678901234567, 5e-324, 1.7976931348623157e308, 1e-7, 1e20], +) +def test_float_round_trip(number): + result = canonical_loads(canonical_dumps(number)) + assert struct.pack("!d", result) == struct.pack("!d", number) + + +def test_nonfinite_and_numpy(): + result = canonical_loads(canonical_dumps([math.nan, math.inf, -math.inf])) + assert result == [{"$float": "nan"}, {"$float": "+inf"}, {"$float": "-inf"}] + assert canonical_dumps(result) == canonical_dumps([math.nan, math.inf, -math.inf]) + values = { + "ints": np.array([[1, 2]], dtype=np.int64), + "real": np.float32(0.1), + "bool": np.bool_(True), + } + assert canonical_loads(canonical_dumps(values)) == { + "ints": [[1, 2]], + "real": float(np.float32(0.1)), + "bool": True, + } + + +@pytest.mark.parametrize( + "data", + [ + b'{"a":1,"a":2}', + b'{"x":{"a":1,"a":2}}', + b"NaN", + b"Infinity", + b"-Infinity", + b"1e999", + b'{"$float":"wrong"}', + b'{"$float":[]}', + b'{"$float":"nan","x":1}', + b'"\\ud800"', + b"\xff", + b"{", + ], +) +def test_invalid_json(data): + with pytest.raises(InvalidJSONError): + canonical_loads(data) + + +@pytest.mark.parametrize( + "value", + [ + object(), + {1: "value"}, + {"$float": "wrong"}, + complex(1, 2), + np.complex128(1), + "\ud800", + ], +) +def test_invalid_encoding(value): + with pytest.raises(InvalidJSONError): + canonical_dumps(value) + + +def test_depth_bounds_before_parse_and_on_encode(): + data = b"[" * 1000 + b"0" + b"]" * 1000 + with pytest.raises(LimitExceededError): + canonical_loads(data, max_depth=4) + for value in ([[[]]], [[0]]): + with pytest.raises(LimitExceededError): + canonical_dumps(value, max_depth=1) + assert canonical_loads(b'["[\\"{}]",{}]', max_depth=2) == ['["{}]', {}] + assert canonical_loads(b"[[]]", max_depth=2) == [[]] + assert canonical_dumps([[]], max_depth=2) == b"[[]]" + + +def test_size_bounds(): + with pytest.raises(LimitExceededError): + canonical_loads(b'"long"', max_bytes=3) + with pytest.raises(LimitExceededError): + canonical_dumps("long", max_bytes=3) + + +def test_cycles_are_bounded(): + value = [] + value.append(value) + with pytest.raises(LimitExceededError): + canonical_dumps(value) + + +def test_integer_digit_bound(): + assert canonical_loads(canonical_dumps(10**4095)) == 10**4095 + with pytest.raises(InvalidJSONError): + canonical_loads(b"1" + b"0" * 4096) + with pytest.raises(InvalidJSONError): + canonical_dumps(10**4096) + + +@pytest.mark.parametrize( + "value", + [ + np.datetime64("2024-01-01", "ns"), + np.datetime64("NaT"), + np.timedelta64(1, "ns"), + np.timedelta64("NaT"), + np.array(["2024-01-01"], dtype="datetime64[ns]"), + np.array([1], dtype="timedelta64[ns]"), + np.array([(1, 2)], dtype=[("a", "i4"), ("b", "i4")]), + ], +) +def test_numpy_semantics_are_not_erased(value): + with pytest.raises(InvalidJSONError): + canonical_dumps(value) + + +def test_numpy_object_arrays_preserve_valid_scalar_types(): + assert canonical_loads(canonical_dumps(np.array([1, "a", None], dtype=object))) == [ + 1, + "a", + None, + ] + with pytest.raises(InvalidJSONError): + canonical_dumps(np.array([np.datetime64("NaT")], dtype=object)) + + +def test_tag_depth_and_malformed_unicode_escapes(): + with pytest.raises(LimitExceededError): + canonical_dumps([math.inf], max_depth=1) + with pytest.raises(InvalidJSONError): + canonical_loads(b'"\\uXXXX"') diff --git a/tests/test_claims.py b/tests/test_claims.py new file mode 100644 index 0000000..8200893 --- /dev/null +++ b/tests/test_claims.py @@ -0,0 +1,63 @@ +"""Canonical claim comparison preserves order, types and numeric identity.""" + +import pytest +from pydantic import ValidationError + +from mlbundle import ClaimMismatchError, Manifest, ModelClaims +from mlbundle.canonical import canonical_dumps +from mlbundle.claims import verify_claims, verify_structure + + +def test_equal_claims_and_structure(manifest_data): + expected = Manifest.from_bytes(canonical_dumps(manifest_data)).verified_claims() + actual = ModelClaims.model_validate(expected.model_dump()) + verify_claims(expected, actual) + verify_structure( + {"b": [1.0], "a": {"$float": "nan"}}, {"a": {"$float": "nan"}, "b": [1.0]} + ) + verify_structure(None, None) + + +@pytest.mark.parametrize( + "field,value", + [ + ("estimator_class", "OtherClassifier"), + ("hyperparameters", {"max_depth": 4}), + ("feature_names", ["b", "a"]), + ("feature_dtypes", ["float32", "int64"]), + ("class_labels", [["positive", 1]]), + ("class_labels", [[1.0, "positive"]]), + ], +) +def test_claim_mismatch(manifest_data, field, value): + expected = Manifest.from_bytes(canonical_dumps(manifest_data)).verified_claims() + fields = expected.model_dump() + fields[field] = value + actual = ModelClaims.model_validate(fields) + with pytest.raises(ClaimMismatchError, match=field): + verify_claims(expected, actual) + + +@pytest.mark.parametrize( + "field,value", + [ + ("feature_names", ["a"]), + ("feature_names", ["a", "a"]), + ("feature_dtypes", ["float32"]), + ], +) +def test_invalid_extracted_features(manifest_data, field, value): + claims = ( + Manifest.from_bytes(canonical_dumps(manifest_data)) + .verified_claims() + .model_dump() + ) + claims[field] = value + with pytest.raises(ValidationError): + ModelClaims.model_validate(claims) + + +@pytest.mark.parametrize("actual", [{"coef": [0.0]}, {"coef": [1]}, None]) +def test_structure_mismatch(actual): + with pytest.raises(ClaimMismatchError, match="structure"): + verify_structure({"coef": [1.0]}, actual) diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..bded544 --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,46 @@ +"""Environment declarations use installed distribution metadata.""" + +from importlib import metadata + +import pytest +from pydantic import ValidationError + +from mlbundle import OnnxInfo, Opset, environment as module + + +def test_installed_environment(): + environment = module.capture_environment() + assert environment.packages["mlbundle"] == metadata.version("mlbundle") + assert environment.python_version.startswith("3.14") + assert environment.python_implementation + assert environment.os and environment.os_version and environment.architecture + assert environment.onnx is None + + +def test_missing_distributions_and_onnx(monkeypatch): + def version(name): + if name == "mlbundle": + return "0.1.0" + raise metadata.PackageNotFoundError(name) + + monkeypatch.setattr(module.metadata, "version", version) + graph = OnnxInfo( + opsets=[Opset(domain="", version=21)], + ir_version=10, + producer_name="", + producer_version="", + ) + environment = module.capture_environment(onnx=graph) + assert environment.packages == {"mlbundle": "0.1.0"} + assert environment.onnx == graph + + +@pytest.mark.parametrize("domains", [["ai.onnx.ml", ""], ["", ""]]) +def test_onnx_opset_order_and_uniqueness(domains): + with pytest.raises(ValidationError): + OnnxInfo( + opsets=[Opset(domain=domain, version=21) for domain in domains], + ir_version=10, + producer_name="", + producer_version="", + ) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..487a1e7 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,39 @@ +"""All contract errors are catchable together and diagnostics are bounded.""" + +import pytest + +import mlbundle + + +@pytest.mark.parametrize( + "name,args", + [ + ("InvalidContainerError", ("Invalid zip.",)), + ("LimitExceededError", ("Limit exceeded.",)), + ("UnsupportedFormatVersionError", ()), + ("InvalidManifestError", ()), + ("InvalidJSONError", ()), + ("IntegrityMismatchError", ("model.ubj",)), + ("ClaimMismatchError", ("n_features",)), + ("UnsupportedModelError", (("custom.Model",),)), + ("MissingOptionalDependencyError", ("onnx",)), + ("AdapterNotImplementedError", ("onnx",)), + ], +) +def test_hierarchy(name, args): + error = getattr(mlbundle, name)(*args) + assert isinstance(error, mlbundle.MLBundleError) + assert str(error) + + +def test_untrusted_types_and_sanitized_messages(): + types = ("custom.Model", "x" * 10000, "bad\nname") + error = mlbundle.UnsupportedModelError(types) + assert error.untrusted_types == types + assert "custom.Model" in str(error) + assert len(str(error)) < 200 + assert "\n" not in str(error) + assert "unrecognized" in str(mlbundle.ClaimMismatchError("x" * 10000)) + assert "mlbundle[sklearn]" in str( + mlbundle.MissingOptionalDependencyError("sklearn") + ) diff --git a/tests/test_integrity.py b/tests/test_integrity.py new file mode 100644 index 0000000..f299317 --- /dev/null +++ b/tests/test_integrity.py @@ -0,0 +1,36 @@ +"""Member digests and size checks.""" + +import pytest +from pydantic import ValidationError + +from mlbundle import IntegrityMismatchError, IntegrityRecord, MemberName +from mlbundle.integrity import hash_member, verify_integrity + + +def test_digest_and_success(): + expected = hash_member(b"abc") + assert expected.size == 3 + assert ( + expected.sha256 + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + verify_integrity(MemberName.SKOPS, b"abc", expected) + assert hash_member(b"").size == 0 + + +@pytest.mark.parametrize("data,size", [(b"abd", 3), (b"abc", 4)]) +def test_mismatch(data, size): + expected = IntegrityRecord(sha256=hash_member(b"abc").sha256, size=size) + with pytest.raises(IntegrityMismatchError, match=r"model\.skops"): + verify_integrity(MemberName.SKOPS, data, expected) + + +@pytest.mark.parametrize( + "field,value", + [("sha256", "A" * 64), ("sha256", "a" * 65), ("size", -1), ("size", "3")], +) +def test_invalid_record(field, value): + data = hash_member(b"abc").model_dump() + data[field] = value + with pytest.raises(ValidationError): + IntegrityRecord.model_validate(data) diff --git a/tests/test_limits.py b/tests/test_limits.py new file mode 100644 index 0000000..ae5256e --- /dev/null +++ b/tests/test_limits.py @@ -0,0 +1,31 @@ +"""Safety ceilings can be lowered, never raised.""" + +import pytest +from pydantic import ValidationError + +from mlbundle import ArchiveLimits, MemberName + + +def test_limits(): + limits = ArchiveLimits() + assert limits.max_members == 5 + assert limits.member_bytes(MemberName.SKOPS) == 512 * 1024**2 + assert limits.member_bytes(MemberName.STRUCTURE) == 256 * 1024**2 + assert ( + ArchiveLimits(max_member_bytes=1024).member_bytes(MemberName.MANIFEST) == 1024 + ) + + +@pytest.mark.parametrize( + "fields", + [ + {"max_members": 6}, + {"max_compression_ratio": 201}, + {"max_total_bytes": 0}, + {"max_structure_bytes": "10"}, + {"unexpected": 1}, + ], +) +def test_invalid_limits(fields): + with pytest.raises(ValidationError): + ArchiveLimits(**fields) diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..74f86ab --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,255 @@ +"""Strict manifest acceptance and rejection, including cross-field invariants.""" + +import pytest +from pydantic import ValidationError + +from mlbundle import ( + FeatureOrigin, + InvalidManifestError, + LimitExceededError, + Manifest, + UnsupportedFormatVersionError, +) +from mlbundle.canonical import canonical_dumps +from mlbundle.integrity import hash_member + + +def read(data: dict) -> Manifest: + return Manifest.from_bytes(canonical_dumps(data)) + + +def test_round_trip_and_claim_projection(manifest_data): + manifest = read(manifest_data) + assert Manifest.from_bytes(manifest.to_bytes()) == manifest + assert manifest.to_bytes() == canonical_dumps(manifest_data) + claims = manifest.verified_claims() + assert claims.feature_names == ["a", "b"] + assert claims.class_labels == [[1, "positive"]] + assert type(claims.class_labels[0][0]) is int + with pytest.raises(ValidationError): + manifest.writer_version = "new" + + +def test_utc_timestamp_and_empty_original_class_label(manifest_data): + manifest_data["created_at"] = "2026-10-11T10:00:00.1234+00:00" + manifest_data["target"]["class_labels"] = [["", "positive"]] + manifest = read(manifest_data) + assert manifest.target.class_labels == [["", "positive"]] + assert manifest.created_at.microsecond == 123400 + assert b"2026-10-11T10:00:00.123400Z" in manifest.to_bytes() + + +@pytest.mark.parametrize( + "section", [None, "model", "features", "target", "environment", "references"] +) +def test_unknown_fields(manifest_data, section): + (manifest_data if section is None else manifest_data[section])["unexpected"] = ( + "x" * 1000 + ) + with pytest.raises(InvalidManifestError) as error: + read(manifest_data) + assert "x" * 100 not in str(error.value) + + +@pytest.mark.parametrize( + "field,value", + [ + ("n_features", "2"), + ("n_features", True), + ("n_features", 2.0), + ("n_classes", ["2"]), + ("n_outputs", 0), + ("hyperparameters", []), + ("estimator_class", "x" * 257), + ("kind", "clusterer"), + ("library", "pytorch"), + ("serialization", "unknown"), + ("serialization", "onnx"), + ], +) +def test_bad_model_fields(manifest_data, field, value): + manifest_data["model"][field] = value + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +@pytest.mark.parametrize("version", ["2", "0", "1.0"]) +def test_unknown_version(manifest_data, version): + manifest_data["format_version"] = version + with pytest.raises(UnsupportedFormatVersionError): + read(manifest_data) + + +@pytest.mark.parametrize( + "field,value", + [ + ("format_name", "other"), + ("format_version", 1), + ("bundle_id", "invalid"), + ("created_at", "2026-10-11T10:00:00"), + ("created_at", "2026-10-11T10:00:00+02:00"), + ("created_at", "0"), + ("created_at", "1679616000.123"), + ("created_at", "2026-10-11T10:00:00.1234567Z"), + ("writer_version", 1), + ], +) +def test_bad_identity(manifest_data, field, value): + manifest_data[field] = value + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +@pytest.mark.parametrize( + "name", + [ + "../model.skops", + "dir/model.skops", + "dir\\model.skops", + "unknown", + "manifest.json", + "model.onnx", + ], +) +def test_bad_member_names(manifest_data, name): + manifest_data["integrity"] = {name: hash_member(b"data").model_dump()} + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +@pytest.mark.parametrize( + "field,value", + [ + ("names", ["a", "a"]), + ("names", ["a"]), + ("names", ["a", 1]), + ("dtypes", ["float64"]), + ("model_dtypes", ["float32"]), + ("origin", "unknown"), + ("origin", "generated"), + ], +) +def test_bad_features(manifest_data, field, value): + manifest_data["features"][field] = value + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +@pytest.mark.parametrize( + "labels", [None, [[1]], [[1, 1]], [[1, 2], [3, 4]], [[None, "x"]]] +) +def test_bad_labels(manifest_data, labels): + manifest_data["target"]["class_labels"] = labels + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +def test_multioutput_and_nonmodel_features(manifest_data): + manifest_data["model"].update(n_outputs=2, n_classes=[2, 3]) + manifest_data["target"]["class_labels"] = [[False, True], [0.0, 1.0, "other"]] + manifest_data["features"].update( + names=["feature_1", "feature_2"], origin="generated" + ) + manifest = read(manifest_data) + assert manifest.verified_claims().feature_names is None + assert type(manifest.target.class_labels[1][0]) is float + assert manifest.features.origin == FeatureOrigin.GENERATED + manifest_data["features"]["origin"] = "dataframe" + assert read(manifest_data).verified_claims().feature_names is None + + +def test_regressor(manifest_data): + manifest_data["model"].update(kind="regressor", n_classes=None) + manifest_data["target"]["class_labels"] = None + assert read(manifest_data).verified_claims().class_labels is None + manifest_data["target"]["class_labels"] = [[0, 1]] + with pytest.raises(InvalidManifestError): + read(manifest_data) + manifest_data["model"]["n_classes"] = [2] + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +def test_invalid_class_counts(manifest_data): + manifest_data["model"]["n_classes"] = None + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +@pytest.mark.parametrize( + "section,field,size", + [("user_metadata", "large", 65536), ("model", "hyperparameters", 1024 * 1024)], +) +def test_bounded_metadata(manifest_data, section, field, size): + if section == "model": + manifest_data[section][field] = {"large": "x" * size} + else: + manifest_data[section][field] = "x" * size + with pytest.raises(LimitExceededError): + read(manifest_data) + + +def test_metadata_depth(manifest_data): + nested = {} + for _ in range(9): + nested = {"nested": nested} + manifest_data["user_metadata"] = nested + with pytest.raises(LimitExceededError): + read(manifest_data) + + +def test_top_level_metadata_count(manifest_data): + manifest_data["user_metadata"] = {str(index): 0 for index in range(1025)} + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +def test_references(manifest_data): + for name in ("schema", "structure", "samples"): + member = f"{name}.json" + manifest_data["references"][name] = member + manifest_data["integrity"][member] = hash_member(b"{}").model_dump() + manifest = read(manifest_data) + assert manifest.references.schema_ == "schema.json" + manifest_data["integrity"].pop("schema.json") + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +def test_member_cap_and_bad_integrity(manifest_data): + manifest_data["references"]["structure"] = "structure.json" + manifest_data["integrity"]["structure.json"] = { + "size": 256 * 1024**2 + 1, + "sha256": "a" * 64, + } + with pytest.raises(InvalidManifestError): + read(manifest_data) + manifest_data["integrity"]["model.skops"]["sha256"] = "bad" + with pytest.raises(InvalidManifestError): + read(manifest_data) + + +def test_environment_invariants_and_onnx(manifest_data): + manifest_data["environment"]["packages"]["mlbundle"] = "different" + with pytest.raises(InvalidManifestError): + read(manifest_data) + manifest_data["environment"]["packages"]["mlbundle"] = manifest_data[ + "writer_version" + ] + manifest_data["model"].update(library="onnx", serialization="onnx") + manifest_data["integrity"] = {"model.onnx": hash_member(b"onnx").model_dump()} + with pytest.raises(InvalidManifestError): + read(manifest_data) + manifest_data["environment"]["onnx"] = { + "opsets": [{"domain": "", "version": 21}], + "ir_version": 10, + "producer_name": "producer", + "producer_version": "1", + } + assert read(manifest_data).verified_claims().onnx.ir_version == 10 + + +@pytest.mark.parametrize("data", [b'{"a":1,"a":2}', b"NaN", b"[]", b"{}", b"\xff"]) +def test_invalid_manifest_json(data): + with pytest.raises(InvalidManifestError): + Manifest.from_bytes(data) diff --git a/tests/test_package.py b/tests/test_package.py index 0bf5f30..7f4bd03 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,5 +1,7 @@ """Smoke test for the installed package.""" +import subprocess +import sys from importlib.metadata import version import mlbundle @@ -7,4 +9,21 @@ def test_package_version() -> None: assert mlbundle.__version__ == version("mlbundle") - assert mlbundle.__all__ == ["__version__"] + assert all(hasattr(mlbundle, name) for name in mlbundle.__all__) + + +def test_base_import_does_not_touch_model_libraries(): + script = """ +import sys +class BlockOptional: + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'sklearn', 'xgboost', 'onnx', 'onnxruntime', 'skops', 'numpy', 'pandas', 'scipy'}: + raise AssertionError(fullname) +sys.meta_path.insert(0, BlockOptional()) +import mlbundle +assert mlbundle.__version__ +""" + result = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..c7c8e0f --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,143 @@ +"""Closed lazy resolution using a complete in-test adapter.""" + +from types import SimpleNamespace + +import pytest + +from mlbundle import ( + AdapterNotImplementedError, + Library, + Manifest, + MemberName, + MissingOptionalDependencyError, + UnsupportedModelError, + registry, +) +from mlbundle.canonical import canonical_dumps, canonical_loads + + +class FakePredictor: + def predict(self, data): + return [float(row[0]) for row in data] + + def predict_proba(self, data): + return [[0.25, 0.75] for _ in data] + + +class FakeAdapter: + library = Library.SKLEARN + model_member = MemberName.SKOPS + + def __init__(self, claims): + self.claims = claims + + def recognizes(self, model): + return isinstance(model, dict) and "weights" in model + + def serialize(self, model): + return canonical_dumps(model) + + def deserialize(self, data): + return canonical_loads(data) + + def extract_claims(self, model): + return self.claims + + def extract_structure(self, model): + return {"family": "linear", "coef": model["weights"], "intercept": [0.0]} + + def predictor(self, model): + return FakePredictor() + + +def test_fake_adapter_contract(monkeypatch, manifest_data): + claims = Manifest.from_bytes(canonical_dumps(manifest_data)).verified_claims() + fake = FakeAdapter(claims) + paths = [] + + def import_module(path): + paths.append(path) + return SimpleNamespace(Adapter=lambda: fake) + + monkeypatch.setattr(registry, "import_module", import_module) + adapter = registry.get_adapter(Library.SKLEARN) + assert paths == ["mlbundle._sklearn"] + native = {"weights": [1.0, 2.0]} + assert adapter.recognizes(native) + assert not adapter.recognizes(object()) + restored = adapter.deserialize(adapter.serialize(native)) + assert restored == native + assert adapter.extract_claims(restored) == claims + assert adapter.extract_structure(restored)["coef"] == [1.0, 2.0] + assert adapter.predictor(restored).predict([[2]]) == [2.0] + assert adapter.predictor(restored).predict_proba([[2]]) == [[0.25, 0.75]] + + +@pytest.mark.parametrize("library", list(Library)) +def test_missing_adapter_module(monkeypatch, library): + def missing(path): + raise ModuleNotFoundError(name=path) + + monkeypatch.setattr(registry, "import_module", missing) + with pytest.raises(AdapterNotImplementedError, match=library.value): + registry.get_adapter(library) + + +@pytest.mark.parametrize( + "library,dependency", + [ + (Library.SKLEARN, "skops"), + (Library.XGBOOST, "sklearn"), + (Library.ONNX, "onnxruntime"), + ], +) +def test_missing_optional_dependency(monkeypatch, library, dependency): + def missing(path): + raise ModuleNotFoundError(name=dependency) + + monkeypatch.setattr(registry, "import_module", missing) + with pytest.raises(MissingOptionalDependencyError) as error: + registry.get_adapter(library) + assert error.value.extra == library.value + + +def test_internal_import_failure_is_not_hidden(monkeypatch): + def missing(path): + raise ModuleNotFoundError(name="mlbundle._mistake") + + monkeypatch.setattr(registry, "import_module", missing) + with pytest.raises(ModuleNotFoundError): + registry.get_adapter(Library.ONNX) + + +def test_missing_dependency_during_adapter_construction(monkeypatch): + def construct(): + raise ModuleNotFoundError(name="onnxruntime") + + monkeypatch.setattr( + registry, "import_module", lambda path: SimpleNamespace(Adapter=construct) + ) + with pytest.raises(MissingOptionalDependencyError, match="mlbundle\\[onnx\\]"): + registry.get_adapter(Library.ONNX) + + +def test_unknown_library_cannot_supply_module_path(monkeypatch): + def unexpected(path): + pytest.fail("Invalid input must never reach import_module") + + monkeypatch.setattr(registry, "import_module", unexpected) + with pytest.raises(UnsupportedModelError): + registry.get_adapter("attacker.module") + + +@pytest.mark.parametrize( + "library,member", + [(Library.ONNX, MemberName.SKOPS), (Library.SKLEARN, MemberName.ONNX)], +) +def test_mismatched_adapter_identity(monkeypatch, library, member): + fake = SimpleNamespace(library=library, model_member=member) + monkeypatch.setattr( + registry, "import_module", lambda path: SimpleNamespace(Adapter=lambda: fake) + ) + with pytest.raises(UnsupportedModelError): + registry.get_adapter(Library.SKLEARN) diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..b694ec7 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,68 @@ +"""Consumer access to metadata, predictions and the original native object.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from mlbundle import ( + FeatureOrigin, + Library, + LoadedBundle, + Manifest, + MemberName, + MetadataView, + ModelKind, +) +from mlbundle.canonical import canonical_dumps + + +class Predictor: + def predict(self, data): + return [float(row[0]) for row in data] + + def predict_proba(self, data): + return [[0.1, 0.9] for _ in data] + + +def test_metadata_view(manifest_data): + view = MetadataView( + Manifest.from_bytes(canonical_dumps(manifest_data)), + (MemberName.MANIFEST, MemberName.SKOPS), + ) + assert not hasattr(view, "native_model") + with pytest.raises(FrozenInstanceError): + view.members = () + + +def test_loaded_classifier(manifest_data): + native = object() + bundle = LoadedBundle( + Manifest.from_bytes(canonical_dumps(manifest_data)), + (MemberName.MANIFEST, MemberName.SKOPS), + native, + Predictor(), + ) + assert bundle.native_model is native + assert bundle.kind == ModelKind.CLASSIFIER + assert bundle.library == Library.SKLEARN + assert bundle.class_name == "DecisionTreeClassifier" + assert bundle.feature_names == ("a", "b") + assert bundle.feature_origin == FeatureOrigin.MODEL + assert bundle.class_labels == [[1, "positive"]] + bundle.class_labels[0].append("mutated copy") + assert bundle.class_labels == [[1, "positive"]] + assert bundle.predict_proba([[1, 2]]) == [[0.1, 0.9]] + with pytest.raises(ValueError, match="regressor"): + bundle.predict([[1]]) + + +def test_loaded_regressor(manifest_data): + manifest_data["model"].update(kind="regressor", n_classes=None) + manifest_data["target"]["class_labels"] = None + bundle = LoadedBundle( + Manifest.from_bytes(canonical_dumps(manifest_data)), (), object(), Predictor() + ) + assert bundle.class_labels is None + assert bundle.predict([[2]]) == [2.0] + with pytest.raises(ValueError, match="classifier"): + bundle.predict_proba([[2]]) diff --git a/uv.lock b/uv.lock index cfb9c58..1a8c1d5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -11,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -107,6 +125,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl", hash = "sha256:3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089", size = 132460, upload-time = "2026-10-11T16:11:52.753Z" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + [[package]] name = "identify" version = "2.6.20" @@ -125,13 +151,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] +[[package]] +name = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/72/307d7c4bd0600601c7133fba5cb78af7db968152951c1cd473abb1cda782/ml_dtypes-0.6.0.tar.gz", hash = "sha256:5e60251d32ced5598972e4d5e06a2f044341f9291402551a3f6f0ec44f9299b0", size = 3032327, upload-time = "2026-08-13T14:14:40.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/7a/97dc35667b7c9db33c5344c673cd27f87e34771875ea7100138726132ac9/ml_dtypes-0.6.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:84fa136b8602c8c39e3b6cb24918960cd6f36cade7a70376f56770729cd56510", size = 562551, upload-time = "2026-08-13T14:14:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/db/48/77f0ede10558d0d935da2e3276ed7e9c8cc2bad3463b9a0b66b03fc60be2/ml_dtypes-0.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:317be9967fb84b0ce4e80e6b1bf71213d21971621cf6f1e501a63602a95297bf", size = 360334, upload-time = "2026-08-13T14:14:16.079Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b1/1831dd8c9b06c013085d31a2ac4f03392d43bd36bfc6ff591a08bcedc1cf/ml_dtypes-0.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f490c003369ce60e514a0c3b12374f05274c101fee1bead6740ec8a564032b0", size = 409966, upload-time = "2026-08-13T14:14:17.477Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ad/9c32c53f823dda3742df19a79c10bc198365937873ea125ba65747440c23/ml_dtypes-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:d574c2b28921dc72e869df248f1a278f6eee176a1f237c8642e1a71eb15f3977", size = 457224, upload-time = "2026-08-13T14:14:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/dd98205418a13353d41c52bf5326d8cbec515aace46174e23c6ea01c2978/ml_dtypes-0.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:f4adb4af61516510d786cf8c01851a66f6d3ddfa79e1144deaa5b40d8507231e", size = 568378, upload-time = "2026-08-13T14:14:19.843Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/32e7beef3281fed74883451477ad976364323206dbfaa95e948ba788dac7/ml_dtypes-0.6.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3e169214e0d80ff1c038e1b3017e33c23e43bdf948d42d31de8283111c7e2fa3", size = 590177, upload-time = "2026-08-13T14:14:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/99b3d9b3c984b3bd1e81d8244f1fa2f812e44060d853205b2df6271aa17c/ml_dtypes-0.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:573b11f3c327e17ef3826d266e676cf1149a1f3016f822a05f2306c55d8246bf", size = 363142, upload-time = "2026-08-13T14:14:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fb/8091c0aee7f2712de99c7fd4b1642382644dec6a4962effe4f5b9d16a973/ml_dtypes-0.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b76fa1d3f92967d58289ac47ab7458ede66e6f3527fff3e59142aee57d9307cd", size = 430645, upload-time = "2026-08-13T14:14:23.737Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/962d2c589513b5930d05b6eae5fbd22ad8bbcf26bb763449f3d8f912360f/ml_dtypes-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3be9911d953f97cddded4b9961d7b650473b7e55806d20f6176f8356dfe7b38e", size = 465667, upload-time = "2026-08-13T14:14:25.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/bcb25e246edd19af5fa1cf6267040bd9977a7afca846e6cfd4a52078b44f/ml_dtypes-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e74266ca8e97874a937b7646378c178025650a236584f7474d10d8086a6edea3", size = 572706, upload-time = "2026-08-13T14:14:26.296Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/46cb442648e3c774d8cb25f2e1e41d496cdcc91fbe9c2a6f75c0b8df7af6/ml_dtypes-0.6.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b1b503864fada3f74fabf8d9fee7b4c1cbe956301e6fdece975d5f77c2fce958", size = 562550, upload-time = "2026-08-13T14:14:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/07/56/844eff5af7a2d1a09d75df12c70225c3a6b6a771f95876b2bf5f7d10ad44/ml_dtypes-0.6.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6ad60af4102789a5c09824004beade2f7f28cd1cd581ee5c170d9dc2fbb00e", size = 360332, upload-time = "2026-08-13T14:14:28.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/29/b7165a3a76364a5baa6aa4ee82a0adf73a3c014b8cd126120b62cc087992/ml_dtypes-0.6.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4f1b9329a251e4affe3bb58f4d3e2db22a714396fd7ffb40d0b5db423c24d17", size = 409964, upload-time = "2026-08-13T14:14:30.023Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/f61c54a0544b6a170ac1bb89bcf406af53fb2deffc5476b6d2d3df5ba13e/ml_dtypes-0.6.0-cp315-cp315-win_amd64.whl", hash = "sha256:488c99ab181a2f59d9ec3b12c5fa11ec904e92be2c4ba18cded54dd7501208fe", size = 457249, upload-time = "2026-08-13T14:14:31.213Z" }, + { url = "https://files.pythonhosted.org/packages/63/00/bee1bc9faa02a46e7a851019fd23f47ca1f906609edbec8b6ba5decc3cc3/ml_dtypes-0.6.0-cp315-cp315-win_arm64.whl", hash = "sha256:de9d14748dbf3968951436ef514a29c9d1fe438aa680d110134ee2f7a9f9df18", size = 568381, upload-time = "2026-08-13T14:14:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/72/f7/9a5edede28f73185fd51d75030ef7f11d76997bab3a92427d986e54fe2eb/ml_dtypes-0.6.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:e25bb3b0ad1217b60626e4ed45b10ca170c41d99fbe44a12bebc1e07ec4aad55", size = 589877, upload-time = "2026-08-13T14:14:33.695Z" }, + { url = "https://files.pythonhosted.org/packages/fd/81/d5924a141b850b606eb027493c9c3ca3c665cca5163af3f5b6e5e3345503/ml_dtypes-0.6.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31f1ce979d31a357e95aa81812f20412c8c954fa43c44ee3ead1e1c8a78575ef", size = 362788, upload-time = "2026-08-13T14:14:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/3298e3f334832bc28dd144af6b99cdc93502a8687e71922ea68b0a319929/ml_dtypes-0.6.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2d6149f3a57f405bcad5fb41e03218b8373936253f23e1ca84c0108abbc3392", size = 430823, upload-time = "2026-08-13T14:14:36.44Z" }, + { url = "https://files.pythonhosted.org/packages/93/d2/f2dbf118f42ce4c325a139c9236737f436b7f8e00cd18701c99ef2405e6f/ml_dtypes-0.6.0-cp315-cp315t-win_amd64.whl", hash = "sha256:ce7563e0b1a4482cbc1b4a6272145e54e4489e54fe7428f94908c3d87103abfa", size = 465119, upload-time = "2026-08-13T14:14:37.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/bda40387b5c5c64254595f4d81a12351770856acc5de4e6d43606a31f161/ml_dtypes-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f6cb525101b6b903779188c1e9e9490c343b455ab822883e02cf01e5547338d2", size = 572666, upload-time = "2026-08-13T14:14:38.993Z" }, +] + [[package]] name = "mlbundle" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] + +[package.optional-dependencies] +all = [ + { name = "onnx" }, + { name = "onnxruntime" }, + { name = "scikit-learn" }, + { name = "skops" }, + { name = "xgboost" }, +] +onnx = [ + { name = "onnx" }, + { name = "onnxruntime" }, +] +sklearn = [ + { name = "scikit-learn" }, + { name = "skops" }, +] +xgboost = [ + { name = "scikit-learn" }, + { name = "xgboost" }, +] [package.dev-dependencies] dev = [ + { name = "mlbundle", extra = ["all"] }, { name = "pre-commit" }, { name = "pyright" }, { name = "pytest" }, @@ -140,9 +234,25 @@ dev = [ ] [package.metadata] +requires-dist = [ + { name = "onnx", marker = "extra == 'all'", specifier = ">=1.23.0" }, + { name = "onnx", marker = "extra == 'onnx'", specifier = ">=1.23.0" }, + { name = "onnxruntime", marker = "extra == 'all'", specifier = ">=1.30.0" }, + { name = "onnxruntime", marker = "extra == 'onnx'", specifier = ">=1.30.0" }, + { name = "pydantic", specifier = ">=2.13.4,<3" }, + { name = "scikit-learn", marker = "extra == 'all'", specifier = ">=1.2" }, + { name = "scikit-learn", marker = "extra == 'sklearn'", specifier = ">=1.2" }, + { name = "scikit-learn", marker = "extra == 'xgboost'", specifier = ">=1.2" }, + { name = "skops", marker = "extra == 'all'", specifier = ">=0.16.0" }, + { name = "skops", marker = "extra == 'sklearn'", specifier = ">=0.16.0" }, + { name = "xgboost", marker = "extra == 'all'", specifier = ">=3.4.1" }, + { name = "xgboost", marker = "extra == 'xgboost'", specifier = ">=3.4.1" }, +] +provides-extras = ["sklearn", "xgboost", "onnx", "all"] [package.metadata.requires-dev] dev = [ + { name = "mlbundle", extras = ["all"] }, { name = "pre-commit", specifier = ">=4.6.0" }, { name = "pyright", specifier = ">=1.1.409" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -150,6 +260,15 @@ dev = [ { name = "ruff", specifier = ">=0.15.15" }, ] +[[package]] +name = "narwhals" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" }, +] + [[package]] name = "nodeenv" version = "1.11.0" @@ -159,6 +278,112 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, ] +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/0a/c29c302036a06d27dd732588f3fd8ee89b1f7b0087e38c668ae7be8ff7b2/nvidia_nccl_cu13-2.32.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a5bee92b2f4af218c109f8d221c3c9adcc752b94ae0ffcb0ed5abf9341a7724c", size = 305107282, upload-time = "2026-09-22T08:30:28.048Z" }, + { url = "https://files.pythonhosted.org/packages/5b/29/6b277e63c92d91f9cb4d1a3a554e148983de39d54baa652bb52c798af78e/nvidia_nccl_cu13-2.32.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1459723080ac889d73a26edfa3e04383a7928ab31ac8f0ec43b3ea9548b04ff3", size = 305100071, upload-time = "2026-09-22T08:30:53.705Z" }, +] + +[[package]] +name = "onnx" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/3a/b73bfb8f506a7b5be43eebf71dbdd351c4116f8100e1103a91bfcbcd3502/onnx-1.23.0.tar.gz", hash = "sha256:3f4b7323ea3397c63aa6ff5d43abe16013ffefc0a96cb8a0be6527ff1e1add40", size = 6022619, upload-time = "2026-09-18T19:30:37.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/f9/54d8047c2f409cfc314a0df520c6e5703afe78b1395e8c44287724c4b37e/onnx-1.23.0-cp312-abi3-macosx_13_0_universal2.whl", hash = "sha256:6cc93aa89502acbf453a16d79b465d47d9c454f3a3cb3fc5507a8cb59ab53208", size = 9725329, upload-time = "2026-09-18T19:30:07.486Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ff/a7d3fe41debd5b8947182a632b4e001bd3a7a103212e58ef6ca0c3daab91/onnx-1.23.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95b77466b4f6bc7c2c63d6004b8db7d963fb17bf24aff13007752e9d2ebf9390", size = 8640232, upload-time = "2026-09-18T19:30:10.058Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/63cdd5740f566fd9de2cadc106529adee0566f2fcafe69a65767eedc1681/onnx-1.23.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f336004196a22fbdc16c62e7f26f20635af1826db6147c80ff3b4b8d428fc7ef", size = 8881347, upload-time = "2026-09-18T19:30:12.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/7844929ef08aee3f9cb857d5ce34bb60d44496c9cc5c66cdfd8255917283/onnx-1.23.0-cp312-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:05ad734997f09fcc9633fc2e3715033239792fc9f12aec6ec0c48e32b214e47e", size = 7314556, upload-time = "2026-09-18T19:30:15.456Z" }, + { url = "https://files.pythonhosted.org/packages/52/68/ee1ad44118d0031e28a1cabb1cc02a4c9e30273bdc25571c5847aec5e4e8/onnx-1.23.0-cp312-abi3-win32.whl", hash = "sha256:221037cb9ffe67b701fb180ca871f1ee9089f431d944f3d0313ffae68c356a80", size = 7736116, upload-time = "2026-09-18T19:30:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/81/5a/766abb1411b9f9b06dc4e8fe9ff195d3b715535677c362ab3d950fa0e93f/onnx-1.23.0-cp312-abi3-win_amd64.whl", hash = "sha256:70a2f930b221f9dbdff62704838ce8bf81442787b25048f8b9cdc9118168799e", size = 7872197, upload-time = "2026-09-18T19:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/94/34/483fa56b4d47a5210d64dccb522a60b46e0953ca3f72d75b3c4759266f5f/onnx-1.23.0-cp312-abi3-win_arm64.whl", hash = "sha256:dcd4d269818a13c48444c96b7f17c7539d8cda7810ef802431393b6cf5df794b", size = 8037224, upload-time = "2026-09-18T19:30:22.682Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7a/3384704bbc90dba09bb6924399db717b8031704ad9bc80191c3cfe5cf88b/onnx-1.23.0-cp314-cp314t-macosx_13_0_universal2.whl", hash = "sha256:8e00eb5aaccbeef2203439e24b0e8f2bf01537fae3575ffbaacf7d8bc9d96e3b", size = 9730894, upload-time = "2026-09-18T19:30:24.982Z" }, + { url = "https://files.pythonhosted.org/packages/64/23/40a365a39f7f3148196e98f496b31b5588414b7898fe65f8343b402a6d1a/onnx-1.23.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5741b60e189b5c8977beb8ec870c47bf8d2d938add5d5f11467facf6904de4cd", size = 8647161, upload-time = "2026-09-18T19:30:27.619Z" }, + { url = "https://files.pythonhosted.org/packages/86/dd/03c1b211b935cf6bf3f96f0d18a0ea62a3ac522212af4277ac47fba02c47/onnx-1.23.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6951c2ed343eb9dbc2b644507f9ed8d34c23558bae839b3726b5b8109d3ff8b", size = 8886389, upload-time = "2026-09-18T19:30:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/b3/18/f4fe44070ffe4bb3a08898dac6c7a2eb78691025bce40bdaadfeba8795b3/onnx-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3e9c9be1baebbc2b257c45937e4fb18f9e69a021d4207a909dc1f4407193fa21", size = 7910378, upload-time = "2026-09-18T19:30:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/b7e6f3a85f5ef39f012839cb77123544ada6e9cd69dadae4a66df5403b30/onnx-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ae166bf343b95ee9d03bf023faac05216fe2c9eff44d1c9a4c01920657135c45", size = 8078741, upload-time = "2026-09-18T19:30:35.245Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" }, + { url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" }, + { url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -202,6 +427,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] +[[package]] +name = "prettytable" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/74/ba08d81e668ccfe8658d7520a307e63c19862c08eb4ccb26f356c5239a7a/prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680", size = 76373, upload-time = "2026-06-22T16:07:50.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/be/2e6798ace5cc036f5d05d36b7b2fd85346f1a708c87060890b070d0ec607/prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a", size = 37357, upload-time = "2026-06-22T16:07:48.595Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -317,6 +656,121 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/e3/b58e45082dcf3dcf0eb1192ee03545ec43d8c98441dfe20e88afca8442ce/scikit_learn-1.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5d117952769b563067656784e03c75a2d8235a7a05cf7fffa78a311e75aac08", size = 8747414, upload-time = "2026-09-10T18:33:10.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/d68115577c8b42b0442ebd8180945d4008880a33176094640e00b585128e/scikit_learn-1.9.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:8893bc6331f60f18d4ac75e12ed356e2dcf6a564bf767918b5b7ca54c8c8be49", size = 8279175, upload-time = "2026-09-10T18:33:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/06c7eb61a438e389cbf3f7210897069bec5a883dbe03c189ae792781e11a/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5492cf2df5226691c32611de8734bcf42148c6547ae53c7f4e6b847793addc0", size = 8881706, upload-time = "2026-09-10T18:33:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/4e/0bab75490ca4b85fad8388739c7ebc71d9db553f8c69e39943ee8db0aaae/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993d332ff80e62efae9e39603b7e872297c418d780f01a01855269a3489c950f", size = 9152030, upload-time = "2026-09-10T18:33:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/9a/13/31c6f8ba1b7eecef9dd9558576c752d2ec5785fd0e456bb9fc59305be23f/scikit_learn-1.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:ca9051447455dae341d4d591eece7deb2d8e3d1020298fc87a81fc51e4da8f53", size = 8377024, upload-time = "2026-09-10T18:33:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6d3cb8a45f5228f915acd66b819dd6b8232ccbe24532f51d278e3991df31/scikit_learn-1.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:90de6573f733a9fb79476ff1371af52a397d41c8b35f9146e20923db010d67b6", size = 8014196, upload-time = "2026-09-10T18:33:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/6befb2d5961490d18d9dbc16a5df37aa121d08bc9893316a6a363977a903/scikit_learn-1.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b5cad1624de8b75e5b9ccb7b0ce1ff1d01306340a3efc56d5529c5ba92392eb", size = 9066066, upload-time = "2026-09-10T18:33:24.396Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/11271f2f7db337db01f598e358721b1e83989407131272e5dd64214c28a8/scikit_learn-1.9.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:d137ce8a6142029fb5c35bd82f470c40cd9e760e5e2f7694b362c497c4ab3fa2", size = 8647654, upload-time = "2026-09-10T18:33:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/e7/04/9c15d201e1b6a2e81b8215865df7646c5a360f560831769c8dc92ac1ab9a/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66f852f7325b5070bc28329005aca76055a2def78faac039548ae889aeaa45a6", size = 8889070, upload-time = "2026-09-10T18:33:28.95Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/4cb6c85160af4a639e87a3b7bf8b1c25cfc3b504c5af710ca416a6dcfc5f/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748bcb0a4cc04aec470652c9e5ec68450948e867387e7dfade647107ade68d25", size = 9131227, upload-time = "2026-09-10T18:33:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/0e/361440972ae3d19b90ea88a84791138a51de0e8432a770ba741b2c8d9ced/scikit_learn-1.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd925e893e5539be704d5edc64dbe081aacdab6b89d8c2977c1f6a7a453ce5", size = 8683001, upload-time = "2026-09-10T18:33:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8f/a9f405c5c0e2df6f343a871b40c97fb32969e3ccc38e3033dd118f3c261e/scikit_learn-1.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b01e5b01735d38474127ca3f49319b592506225a87793b27559816b5c75cea39", size = 8257996, upload-time = "2026-09-10T18:33:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c5/74a83ea39cef7cd07f53e06cc1cf51e79f35df74f81db956835d59ec34b1/scikit_learn-1.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dec64f31a6e0ec826aca6c1b39a51e16d946e400d4c0904316f3ca72ccfb825", size = 8747991, upload-time = "2026-09-10T18:33:37.4Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/cc93e8a7fe204e43d70e96e5eb89871643be20025eea05eb4fdaf19afe39/scikit_learn-1.9.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e1b468241f4a7a9a7a0d6479ad3cc47681cc151a4046c530f2777c3d68f08942", size = 8279340, upload-time = "2026-09-10T18:33:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/0c6080f0d356fb966053009e7f25e9bff6cf74b0b16195ccf0c3757d1ae8/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ca869d0080a5723cde2d5a8b54a2da1ff7e68735a9e9adb3da1243183a0fa01", size = 8881917, upload-time = "2026-09-10T18:33:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/bb/98a31f10fffbd39edcc2f8bf4119b29652248bb110b7d45c84e68aa293ab/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6754b7cabfc3df0b1f7b38f7a344f559bbae9d82f0ac5e3d48cccbd19fdcefdf", size = 9152805, upload-time = "2026-09-10T18:33:44.23Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/858ceff52213cfd97c0a069362071756bcb70b9fac9771b38d87c4cf7f17/scikit_learn-1.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:52cfdb1fed3a34362dbc0bd96f2e761a66fd5724d6901629f5a558f1f3bd9849", size = 8376719, upload-time = "2026-09-10T18:33:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1e/5337a871bdea53effbd154b61429df048f2665653251de74a3bd8a6dea9e/scikit_learn-1.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:ae6571a4828c6f5019bcd2b4125e5b18c0af3dbc9c99726c891f45f41335ec8e", size = 8014341, upload-time = "2026-09-10T18:33:48.762Z" }, + { url = "https://files.pythonhosted.org/packages/0e/35/150383a42d83ec4c7b39f9c50bd68408ecf04c19fc30ea5198fa42e67d9c/scikit_learn-1.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:48fefd8eb42bd4eec3e2d348149368ccd6d71987e20c30706a56a24eb86a6e73", size = 9059953, upload-time = "2026-09-10T18:33:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/aa0d738808540f7eaacfab93e01982db8ef1c1c7473ef0ad38193e6aebd1/scikit_learn-1.9.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:09f4d73049cd63575157f6b1060e06a8c83a4bd3488dbfaeedf35ccba7aad712", size = 8646602, upload-time = "2026-09-10T18:33:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/687ae4214c2f598906c3b9fa5f86fbaf834b35e20360625528ff1b713f06/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3da53831534214322d9cb240fa6f390b36cf69eba727a6d4bd3238677630d70", size = 8880366, upload-time = "2026-09-10T18:33:55.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/03/82215cb78ad1c513a4498777571fb28444621ad26ef636287551767b7732/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caae15634feceafa2612566b109a3082d3293167fac388eedaf77bff66b51983", size = 9129640, upload-time = "2026-09-10T18:33:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/4b7af4efd7909a4a0524a9f18457e4eb2eb60616eff2c7627eda8e3cdceb/scikit_learn-1.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ffbcbbbb44202fbe9bc64bced25a145759adb9ef010b3d37a8064958ac13df2a", size = 8678083, upload-time = "2026-09-10T18:34:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/31/27/068e484d4b83004302e0d9cfc1faca69bcc010d76fbb66a642446095af1b/scikit_learn-1.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:800dd22dd87fe97dcea484c24e85dd93cf1734d86bd74e668ad18f7967f4d1b5", size = 8259434, upload-time = "2026-09-10T18:34:02.678Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "skops" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "prettytable" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/4b/7fef0af1efcb06c0986762645fb414ad850314a891434a2bbaea12a962ea/skops-0.16.0.tar.gz", hash = "sha256:58ddb63fed9f2a8545baa1a91c45c26d44cf65c83e2fd217541c41e411cf1d74", size = 673544, upload-time = "2026-09-24T06:29:46.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/a3/08ae9ea7f7c16e20a447d9289dce40f06f52607ed9a39f554a8426d96ba3/skops-0.16.0-py3-none-any.whl", hash = "sha256:5b2eb1bc49392c74a38db04481e2d110f97d9a72d6ae38fbd698aa7a865a9070", size = 148650, upload-time = "2026-09-24T06:29:45.491Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/dc/6c58154c1c65f758ea979e7139cb76993a9cfc662d14e9be3c4a667cfb77/threadpoolctl-3.7.0.tar.gz", hash = "sha256:61348cfb77d53b9242e0017029244b559b810c142ced65b4e21eeca1843959a7", size = 31961, upload-time = "2026-09-15T15:46:20.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3f/f88a53f60a472b46f4023f56d204dd7de33d34c5d2acbfa0d70a674e639e/threadpoolctl-3.7.0-py3-none-any.whl", hash = "sha256:cd8b60b5641b45c67bbf73c64c843235fc2d8a480c87389f52f5dbee893b86be", size = 26362, upload-time = "2026-09-15T15:46:19.168Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -326,6 +780,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "virtualenv" version = "21.14.6" @@ -341,3 +807,58 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/f3/589727d02bc832750 wheels = [ { url = "https://files.pythonhosted.org/packages/c9/1f/e5182163bcee149dda4eec823d88b0f51f2c0c531b12da896f666f0b80c1/virtualenv-21.14.6-py3-none-any.whl", hash = "sha256:eb1a20c5878569b69667f6a1af03458a8512b3afa7be771a2dafe92fdca05bb9", size = 5487469, upload-time = "2026-10-08T14:49:54.988Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" }, + { url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" }, + { url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" }, + { url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" }, + { url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" }, +] + +[[package]] +name = "xgboost" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/a9/295320f741c5be4be996c73ee65a2a11852028c50daa7229adb0d61c330b/xgboost-3.4.1.tar.gz", hash = "sha256:6968a4c71efdfa859df0dfcad0d99211c95c28c4ffd6aecff46efff77d18026a", size = 1231819, upload-time = "2026-08-15T08:39:21.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ea/0bdcd374241a86f1986e87e272516f0a70d841c3aa86aa9ca167fb651573/xgboost-3.4.1-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:1ea15f15f661825b6a67d87674fb9604a1abb38dd0d4c5cf0486fc85f5203e83", size = 2541584, upload-time = "2026-08-15T08:38:48.484Z" }, + { url = "https://files.pythonhosted.org/packages/f7/94/e5c37a8972ad780edc1d8459d1931356344ca133f7f99ba9cfda516b5bba/xgboost-3.4.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:a7afd7dbace0951c93aa85ffe046e54bc40893f5b51cd3e7991eb157bf9c7c7c", size = 2365501, upload-time = "2026-08-15T08:38:52.366Z" }, + { url = "https://files.pythonhosted.org/packages/a7/11/4ff1f36ca5c32c642c71c88bec1508ee98b2c3b1e9eb169e8c82de303522/xgboost-3.4.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7faaf99de26719c22bfae883a02bd56b5a3c2203122616e563cc72b7191b5c96", size = 57196172, upload-time = "2026-08-15T08:39:03.288Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/bd05c5c430feb347aa040fcc8870135d70b256718deee9bc7d2ca74a77ff/xgboost-3.4.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6adf2afa396da2ae8ed30295b50b99d4712eed9a6e0ce6cfe069290e4335e51f", size = 57615456, upload-time = "2026-08-15T08:39:09.983Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3c/925394671f6a1668e2a71886de66e80be694eaf37f615cec74eefaf43107/xgboost-3.4.1-py3-none-win_amd64.whl", hash = "sha256:2d30fa513673101f542fdcbd18f30c8f96c064046f798635ac08663e9969f81b", size = 48942686, upload-time = "2026-08-15T08:39:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/90/2f/f2fbe984ca095709fd246546125e78834740f347e3aa7561a22a1e928510/xgboost-3.4.1-py3-none-win_arm64.whl", hash = "sha256:e9312b30e5679d27c1d8b9ee97e092b964d960a672d5d406d9fb3cd0845c9797", size = 2094178, upload-time = "2026-08-15T08:39:19.308Z" }, +] From 12ce3c25a0dab2eb9b0bb758a464423c19b05dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Sun, 11 Oct 2026 23:38:58 +0200 Subject: [PATCH 02/15] feat: add secure container and metadata inspection (#3) --- AGENTS.md | 37 ++-- README.md | 36 ++-- SECURITY.md | 17 +- docs/format.md | 109 +++++++++-- src/mlbundle/__init__.py | 2 + src/mlbundle/_container_rules.py | 68 +++++++ src/mlbundle/_zip_directory.py | 173 ++++++++++++++++++ src/mlbundle/_zip_members.py | 116 ++++++++++++ src/mlbundle/container.py | 74 ++++++++ src/mlbundle/inspection.py | 20 +++ src/mlbundle/manifest.py | 6 + src/mlbundle/writing.py | 207 +++++++++++++++++++++ tests/conftest.py | 182 +++++++++++++++++++ tests/test_container.py | 299 +++++++++++++++++++++++++++++++ tests/test_inspection.py | 67 +++++++ tests/test_writing.py | 228 +++++++++++++++++++++++ 16 files changed, 1600 insertions(+), 41 deletions(-) create mode 100644 src/mlbundle/_container_rules.py create mode 100644 src/mlbundle/_zip_directory.py create mode 100644 src/mlbundle/_zip_members.py create mode 100644 src/mlbundle/container.py create mode 100644 src/mlbundle/inspection.py create mode 100644 src/mlbundle/writing.py create mode 100644 tests/test_container.py create mode 100644 tests/test_inspection.py create mode 100644 tests/test_writing.py diff --git a/AGENTS.md b/AGENTS.md index 1a66af2..84e66a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,12 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phase 1 defines the version 1 format contract, strict manifest models, canonical -JSON, integrity helpers, environment capture and adapter/result contracts. -No bundle API, archive reader, writer, model adapter or CLI exists yet. The rules -below constrain later phases; they must not be described as implemented until -code and tests prove them. `docs/format.md` owns the wire contract. +Phases 1 and 2 implement the version 1 format contract, strict manifest models, +canonical JSON, integrity helpers, environment capture, adapter/result contracts, +secure container reading/writing and public metadata-only `inspect`. +No public export/load API, model adapter, schema inference or CLI exists yet. +Rules for later phases must not be described as implemented until code and tests +prove them. `docs/format.md` owns the wire contract. ## What makes mlbundle special? @@ -197,9 +198,12 @@ The conversation, issues, code, tests and final diff are the work record. ## How it works -After training, the planned writer will infer the schema locally, extract the -model's claims and structure, serialize the model in its native supported format -and produce a zip with a closed member list: required `manifest.json`, exactly +The container writer accepts serialized member bytes and a manifest or its +JSON-ready parts, computes integrity records and produces a deterministic ZIP. +It enforces the same limits and validation as the container reader and publishes +paths atomically, requiring explicit overwrite. Future model export will infer +schema locally, extract claims/structure and serialize through native adapters. +The closed member list is: required `manifest.json`, exactly one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional `samples.json` requires explicit user intent. No other members are allowed. @@ -216,10 +220,11 @@ output. Model-exposed dtypes are verified separately from declared training dtyp The writer must test serialization/reloading and reject claims or structure that cannot survive the native round trip. Default safety ceilings may only be lowered. -MLSuite will receive the file. The reader will validate the archive and -manifest, check integrity, load through its closed adapter and recompute claims -before model use. `inspect` will expose validated metadata without loading a -model; it cannot attest that model-derived claims match the model. +The container reader validates the archive, manifest, JSON bounds and integrity +without loading a model. `inspect` exposes validated metadata and a member list +without importing model libraries; it cannot attest that model-derived claims +match the model. Future full loading for MLSuite must use a closed adapter and +recompute claims before model use. ## Where code lives @@ -235,7 +240,13 @@ model; it cannot attest that model-derived claims match the model. - `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. - `src/mlbundle/registry.py` — closed lazy imports of future `_sklearn`, `_xgboost`, `_onnx` modules; each provides an `Adapter` class. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. -- `docs/format.md` — version 1 format specification; container/adapters remain unimplemented. +- `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. +- `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. +- `src/mlbundle/_zip_members.py` — local headers/descriptors and bounded decompression with CRC. +- `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. +- `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. +- `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. +- `docs/format.md` — version 1 wire specification; model adapters remain unimplemented. - `tests/` — one file per feature, with success and error cases together. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. diff --git a/README.md b/README.md index bf491f3..d76824f 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ manifest, its input schema and its internal structure in one compressed `.mlbundle` file. The same package will write bundles on the training machine and read them with validation in a runtime. -**Status: pre-alpha, Phase 1.** The [version 1 format contract](docs/format.md), -strict manifest models, canonical JSON, integrity helpers, environment capture -and adapter/result contracts are implemented and tested. No archive reader, -writer, model adapter, public export/load/inspect API or CLI exists yet, and -nothing has been published to PyPI. +**Status: pre-alpha, Phase 2.** The [version 1 format contract](docs/format.md), +strict manifest models, canonical JSON, integrity helpers, environment capture, +adapter/result contracts, secure container reading/writing and public `inspect` +are implemented and tested. No model adapter, public export/load API, schema +inference or CLI exists yet, and nothing has been published to PyPI. ## Why it exists @@ -33,20 +33,32 @@ native model formats; it copies no MLflow code. mlbundle is MIT licensed. ## Security model -Bundles are untrusted input. The planned reader must reject unsafe zip members, -enforce resource limits, validate the manifest and format version, and verify -member hashes. It must use a closed set of model types and a trusted-types list -owned by mlbundle, with no pickle, joblib or cloudpickle path. Model-derived +Bundles are untrusted input. The container reader rejects unsafe ZIP members, +enforces resource limits, validates the manifest and format version, and verifies +member hashes. Future model loading must use a closed set of model types and a +trusted-types list owned by mlbundle, with no pickle, joblib or cloudpickle path. Model-derived metadata and visualisation structures are claims: the reader must recompute them from the loaded model and reject mismatches. A metadata-only `inspect` -operation must work without deserializing the model. +operation works without deserializing the model or importing model libraries. Hashes detect corruption; they do not authenticate the author or prevent deliberate tampering. Signing is outside the project's scope. These guarantees -are requirements for the future reader and adapters; today's contract helpers -do not load or validate a ZIP archive or model. +cover today's container validation; model loading and claim recomputation remain +requirements for future adapters. See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. +```python +from mlbundle import inspect + +metadata = inspect("trained.mlbundle") +print(metadata.manifest.model.library) +print(metadata.members) +``` + +Inspection validates format and integrity. It cannot attest that model-derived +claims or structure match the model. The internal container functions accept +already serialized bytes; public model export and loading arrive in later phases. + ## Development Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows diff --git a/SECURITY.md b/SECURITY.md index 2e9ef15..645da54 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,10 +1,13 @@ # Security policy -mlbundle is pre-alpha. Phase 1 implements strict manifest validation and contract -helpers; it has no archive reader, writer or model adapters. The version 1 -contract is defined in [docs/format.md](docs/format.md). The requirements below -define the security boundary later implementation must enforce; they are not -reader guarantees provided by today's code. +mlbundle is pre-alpha. Phases 1 and 2 implement strict manifest validation, +contract helpers, secure container reading/writing and metadata-only `inspect`. +The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, +checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. +The writer publishes complete validated files atomically. The version 1 contract +is defined in [docs/format.md](docs/format.md). Model adapters and public +export/load are not implemented: requirements involving model loading, trusted +types and claim recomputation below remain obligations for later phases. ## Threat model @@ -33,6 +36,10 @@ make a received file trustworthy. Reject unknown and duplicate members, names with path separators or `..`, symlinks and encrypted entries. Limit member count, compressed size, uncompressed size and compression ratio before allocating or loading content. +- Validate local and central records and any data descriptors; require exact + decompressed sizes and CRC. Reject comments, non-ZIP64 extra fields and bytes + outside the contiguous ZIP layout. ZIP64 and validated streaming descriptors + are accepted within the same resource ceilings. - Verify each member's SHA-256 against the manifest and validate member sizes. - Validate the manifest strictly and reject unknown format versions. - Provide a metadata-only `inspect` operation that does not deserialize a model. diff --git a/docs/format.md b/docs/format.md index 0f2bb4b..877a1ee 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,9 +2,10 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -“Must” denotes a compatibility or security requirement. Phase 1 implements -the manifest, encoding, limits and adapter contracts. Archive reading, writing, -inspection, model adapters and schema inference are implemented in later phases. +“Must” denotes a compatibility or security requirement. Phases 1 and 2 implement +the manifest, encoding, limits, adapter contracts, secure container reading and +writing, and public metadata-only inspection. Model adapters, public export/load +and schema inference are implemented in later phases. ## Container and resource limits @@ -14,9 +15,35 @@ slashes, backslashes, `..`, NULs or alternative spellings. Unknown or duplicate names, symlinks, other non-regular entries, encrypted entries, unsupported compression methods, multi-disk archives and malformed ZIP metadata must be rejected. Only ZIP_STORED (method 0) and ZIP_DEFLATED (method 8) are allowed. -ZIP64 is allowed within the limits. Extra fields and archive/member comments -have no semantics and cannot authorize additional members or loading behavior. - +ZIP64 is allowed within the limits, including forced ZIP64 local records for +small files. Only a single ZIP64 extra field (ID 0x0001) is permitted in a header, +and it must contain exactly the replacement values required by that header's +sentinel fields, in ZIP order. Reject other, redundant, duplicate or malformed +extra fields. ZIP64 end records have the standard fixed 44-byte body, with no +extensible data sector; their locator and ordinary end record must agree. + +Data descriptors are allowed for streaming ZIP writers. Accept the signed and +unsigned 32-bit and ZIP64 forms only when CRC and both sizes match the central +directory. With a descriptor, local CRC/sizes may be zero or match the directory; +ZIP64 sentinel sizes must be resolved through the local ZIP64 extra field. +Without a descriptor, local CRC and sizes must match the directory exactly. +The version-needed field is 10 (stored only), 20 or 45; ZIP64 headers use 45. +Allowed general-purpose flags are the UTF-8 name flag, the descriptor flag and +the two deflate-option bits (deflated entries only). Reject all other flags. + +Reject archive and member comments. The first local header starts at byte zero; +local records, payloads and any descriptors cover the archive contiguously up +to the central directory. The directory ends exactly at the end record or +ZIP64 end record, followed only by its locator and the ordinary end record. +Reject prepended/appended bytes, gaps, overlapping records and unlisted local +records. Central entries may list local records in another order. These rules +exclude polyglot wrappers and metadata the mlbundle writer never produces. +File attributes may omit a Unix file type (as ordinary ZIP writers do); an +explicit type must be regular, and the DOS directory bit must be clear. + +Readers must bound the source before buffering it: stat filesystem paths and +read streams in bounded chunks with at most one excess byte for detection. +Streams are consumed from their current position and are not closed by mlbundle. Readers must read named members into memory, never extract them to disk. They must inspect directory metadata and enforce limits before reading, allocating member buffers or loading models; they must also limit actual bytes produced @@ -25,13 +52,17 @@ between headers, directory entries, actual sizes and ZIP CRC checks. No path, module name, trusted-types list or external file reference can select loading code. ONNX external tensor data is unsupported: the original file must be self-contained. Native model formats do not sandbox their parsers or execution. +Decompression uses chunks of at most 64 KiB and a one-byte lookahead beyond the +declared output size; the lookahead is rejected before it is retained. Require +the exact output size, CRC and complete deflate stream, with no trailing packed +data. Declared sizes are checked against member, aggregate and ratio limits first. One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: | Limit | Maximum | | --- | ---: | | Member count, including manifest | 5 (minimum configured value 2) | -| Entire archive, including headers and comments | 512 MiB | +| Entire archive, including headers | 512 MiB | | Sum of compressed member sizes | 512 MiB | | Any uncompressed member | 512 MiB | | Sum of all uncompressed member sizes, including manifest | 1 GiB | @@ -45,13 +76,69 @@ One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: The effective member cap is the smaller of its specific cap and the general member cap. Check the ratio as `uncompressed_size <= 200 * compressed_size`; an empty member with both sizes zero is exempt, a nonempty member with compressed -size zero is invalid. This applies to stored members too. Writers can choose -stored entries if deflation would exceed the ratio ceiling. These limits admit +size zero is invalid. This applies to stored members too. The mlbundle writer +always uses deflate level 6 with the RLE strategy and rejects inputs exceeding the ratio ceiling; +the reader also accepts stored entries from other tools. These limits admit forests whose native model is tens of MiB and whose JSON tree arrays are larger. Callers may lower archive limits, never raise the ceilings in version 1. Consumers still need process memory, CPU and prediction limits; a 1 GiB byte budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. +### Container functions and inspection + +The internal functions are `mlbundle.container.read_container(source, *, +limits=None)` and `mlbundle.writing.write_container(manifest, members, *, +destination=None, overwrite=False, limits=None)`. Reading returns a validated +`Manifest` and a dictionary of original uncompressed bytes keyed by `MemberName`, +including `manifest.json`. Source is a path, bytes or a binary stream. It never +deserializes a native model or resolves an adapter. + +Writing accepts a `Manifest` or JSON-ready manifest parts, plus non-manifest +member bytes. The writer computes and replaces all integrity records; callers +must already set the model pairing and optional references. JSON-ready parts +may omit integrity. Optional JSON members pass the bounded canonical decoder; +their original bytes are preserved. The container validates JSON syntax and +bounds, while later model/schema APIs own semantic structure and schema checks. +Metadata inspection does not infer whether a model requires structure. + +The writer emits manifest, model, schema, structure, samples in that fixed order, +omitting absent members. ZIP timestamps are 1980-01-01 00:00:00, the creating +platform is Unix, permissions are regular-file 0600, compression is deflate +level 6 with the RLE strategy, memory level 8 and 64 KiB input chunks, and +extra fields/comments are empty. RLE avoids the different general match-search +algorithms used by zlib and zlib-ng across platforms; it compresses repeated-byte +runs and uses Huffman coding for other bytes. It creates no descriptors or ZIP64 +records within the version 1 ceilings. Identical inputs produce identical bytes +on Windows and Linux; the compression implementation is the standard zlib +provided by Python (byte identity across different future zlib implementations +is not a format requirement). +Compression output is bounded by the remaining archive and aggregate compressed +budgets, reserving the fixed header/directory overhead before compression. + +Writing returns bytes and optionally publishes them to a path after validating +the complete archive through the reader. Publication uses a flushed temporary +file in the destination directory: atomic replacement when overwrite is true, +or atomic hard-link creation otherwise, followed by temporary-file cleanup. +Hard-link publication requires a filesystem supporting hard links; otherwise +the filesystem error is raised and no partial destination is left. Existing +destinations raise `FileExistsError` unless overwrite is explicitly requested; +the no-overwrite decision also holds if another writer wins a race. + +Public `mlbundle.inspect(source, *, limits=None)` returns `MetadataView` with +the manifest and fixed-order member listing. It validates ZIP records, limits, +manifest, member hashes and JSON syntax without importing model libraries. +It cannot attest that model-derived claims or structure match the model; only +a future full validating load can do that. Samples must still be explicitly +requested by the caller of the future export API. + +Container/layout/CRC inconsistencies raise `InvalidContainerError`; resource +ceilings raise `LimitExceededError`. Invalid manifest JSON or typed fields raise +`InvalidManifestError`; unknown version strings under the mlbundle identity +raise `UnsupportedFormatVersionError` before validating version-specific fields. +Invalid optional JSON raises `InvalidJSONError`. Member hash or integrity-size +disagreement raises `IntegrityMismatchError` naming the allowed member. +Messages do not include untrusted names, JSON blobs or ZIP exception text. + ## Members | Member | Rule and contents | @@ -303,8 +390,8 @@ serialization, loading, claim extraction, structure extraction and a `Predictor` The registry imports only the three constant mlbundle module paths `_sklearn`, `_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library imports must be lazy, with dependency failures naming the extra to install. -Missing adapter modules currently raise `AdapterNotImplementedError`; there -are no public export/load/inspect placeholders. +Missing adapter modules currently raise `AdapterNotImplementedError`. Public +`inspect` is implemented; there are no public export/load placeholders. Predictors accept adapter-supported tabular objects without a core pandas/numpy dependency. Regressors return `list[float]` for a single output or row-major diff --git a/src/mlbundle/__init__.py b/src/mlbundle/__init__.py index ddc5db4..0c67b1f 100644 --- a/src/mlbundle/__init__.py +++ b/src/mlbundle/__init__.py @@ -26,6 +26,7 @@ UnsupportedFormatVersionError, UnsupportedModelError, ) +from .inspection import inspect from .integrity import IntegrityRecord from .limits import ArchiveLimits from .manifest import FeatureInfo, Manifest, MemberReferences, ModelInfo, TargetInfo @@ -66,4 +67,5 @@ "UnsupportedFormatVersionError", "UnsupportedModelError", "__version__", + "inspect", ] diff --git a/src/mlbundle/_container_rules.py b/src/mlbundle/_container_rules.py new file mode 100644 index 0000000..ec49ca7 --- /dev/null +++ b/src/mlbundle/_container_rules.py @@ -0,0 +1,68 @@ +"""Shared container names, member sets and resource checks.""" + +from collections.abc import Iterable +from os import PathLike +from typing import BinaryIO + +from .contract import MODEL_MEMBERS, MemberName +from .errors import InvalidContainerError, LimitExceededError +from .limits import ArchiveLimits +from .manifest import Manifest + +BundleSource = str | PathLike[str] | bytes | BinaryIO +CHUNK_BYTES = 64 * 1024 +MEMBER_ORDER = ( + MemberName.MANIFEST, + MemberName.SKOPS, + MemberName.UBJSON, + MemberName.ONNX, + MemberName.SCHEMA, + MemberName.STRUCTURE, + MemberName.SAMPLES, +) + + +def effective_limits(limits: ArchiveLimits | None) -> ArchiveLimits: + # Revalidate even model_copy/model_construct values; ceilings are mandatory. + return ArchiveLimits.model_validate((limits or ArchiveLimits()).model_dump()) + + +def member_name(raw: bytes | str) -> MemberName: + try: + return MemberName(raw if isinstance(raw, str) else raw.decode("ascii")) + except (UnicodeError, ValueError): + raise InvalidContainerError("Unknown or unsafe member name.") from None + + +def check_members( + names: Iterable[MemberName], manifest: Manifest | None = None +) -> None: + present = set(names) + if MemberName.MANIFEST not in present: + raise InvalidContainerError("Missing manifest.json.") + models = {pair[1] for pair in MODEL_MEMBERS.values()} + if len(present & models) != 1: + raise InvalidContainerError("Exactly one model member is required.") + if manifest is not None and present != {MemberName.MANIFEST, *manifest.integrity}: + raise InvalidContainerError("Members disagree with manifest references.") + + +def check_sizes( + sizes: Iterable[tuple[MemberName, int, int]], limits: ArchiveLimits +) -> None: + compressed = uncompressed = count = 0 + for name, packed, unpacked in sizes: + count += 1 + compressed += packed + uncompressed += unpacked + if packed > limits.max_compressed_bytes or unpacked > limits.member_bytes(name): + raise LimitExceededError(f"Member size limit exceeded: {name.value}.") + if unpacked > limits.max_compression_ratio * packed: + raise LimitExceededError(f"Compression ratio limit exceeded: {name.value}.") + if count > limits.max_members: + raise LimitExceededError("Member count limit exceeded.") + if ( + compressed > limits.max_compressed_bytes + or uncompressed > limits.max_total_bytes + ): + raise LimitExceededError("Total member size limit exceeded.") diff --git a/src/mlbundle/_zip_directory.py b/src/mlbundle/_zip_directory.py new file mode 100644 index 0000000..55ff9f7 --- /dev/null +++ b/src/mlbundle/_zip_directory.py @@ -0,0 +1,173 @@ +"""Bounded parsing of ZIP directory records, before any decompression.""" + +import stat +import struct +from dataclasses import dataclass + +from ._container_rules import check_sizes, member_name +from .contract import MemberName +from .errors import InvalidContainerError, LimitExceededError +from .limits import ArchiveLimits + +END = struct.Struct("<4s4H2IH") +END64 = struct.Struct("<4sQ2H2I4Q") +LOCATOR = struct.Struct("<4sIQI") +CENTRAL = struct.Struct("<4s6H3I5H2I") +LOCAL = struct.Struct("<4s5H3I2H") +U32_MAX = 0xFFFFFFFF +U16_MAX = 0xFFFF + + +def invalid() -> InvalidContainerError: + return InvalidContainerError("Invalid or unsupported ZIP metadata.") + + +def unpack(record: struct.Struct, data: bytes, offset: int) -> tuple: + if offset < 0 or offset + record.size > len(data): + raise invalid() + return record.unpack_from(data, offset) + + +def zip64_values(extra: bytes, fields: list[tuple[int, int]]) -> list[int]: + """Accept only one ZIP64 field, consuming every supplied value exactly.""" + required = any(value == sentinel for value, sentinel in fields) + if not extra: + if required: + raise invalid() + return [value for value, _ in fields] + if not required or len(extra) < 4: + raise invalid() + tag, length = struct.unpack_from(" len(extra): + raise invalid() + resolved = int.from_bytes(extra[offset : offset + width], "little") + offset += width + result.append(resolved) + if offset != len(extra): + raise invalid() + return result + + +def _directory_bounds(data: bytes) -> tuple[int, int, int]: + end_offset = len(data) - END.size + signature, disk, central_disk, disk_count, count, size, offset, comment = unpack( + END, data, end_offset + ) + if signature != b"PK\x05\x06" or disk or central_disk or comment: + raise invalid() + boundary = end_offset + locator_offset = end_offset - LOCATOR.size + if data[locator_offset : locator_offset + 4] == b"PK\x06\x07": + _, disk64, position, disks = unpack(LOCATOR, data, locator_offset) + if disk64 or disks != 1 or position + END64.size != locator_offset: + raise invalid() + record = unpack(END64, data, position) + if ( + record[0] != b"PK\x06\x06" + or record[1] != 44 + or record[3] != 45 + or record[4] + or record[5] + ): + raise invalid() + actual = record[6:10] + original = (disk_count, count, size, offset) + sentinels = (U16_MAX, U16_MAX, U32_MAX, U32_MAX) + if any( + old not in (new, sentinel) + for old, new, sentinel in zip(original, actual, sentinels, strict=True) + ): + raise invalid() + disk_count, count, size, offset = actual + boundary = position + elif U16_MAX in (disk_count, count) or U32_MAX in (size, offset): + raise invalid() + if disk_count != count or offset + size != boundary: + raise invalid() + return offset, size, count + + +@dataclass(frozen=True) +class Entry: + name: MemberName + flags: int + method: int + crc: int + compressed: int + size: int + offset: int + version: int + time: int + date: int + + +def _entry(data: bytes, offset: int, boundary: int) -> tuple[Entry, int]: + fields = unpack(CENTRAL, data, offset) + ( + signature, + _made, + version, + flags, + method, + time, + date, + crc, + packed, + size, + name_length, + extra_length, + comment_length, + disk, + _internal, + external, + local, + ) = fields + end = offset + CENTRAL.size + name_length + extra_length + if signature != b"PK\x01\x02" or comment_length or end > boundary: + raise invalid() + name_start = offset + CENTRAL.size + name = member_name(data[name_start : name_start + name_length]) + extra = data[name_start + name_length : end] + if extra and version != 45: + raise invalid() + size, packed, local, disk = zip64_values( + extra, [(size, U32_MAX), (packed, U32_MAX), (local, U32_MAX), (disk, U16_MAX)] + ) + file_type = stat.S_IFMT(external >> 16) + if disk or external & 0x10 or file_type not in (0, stat.S_IFREG): + raise invalid() + if method not in (0, 8) or flags & ~0x080E or version not in (10, 20, 45): + raise invalid() + if (method == 0 and flags & 6) or (method == 8 and version < 20): + raise invalid() + return Entry( + name, flags, method, crc, packed, size, local, version, time, date + ), end + + +def directory(data: bytes, limits: ArchiveLimits) -> tuple[list[Entry], int]: + offset, size, count = _directory_bounds(data) + if count > limits.max_members: + raise LimitExceededError("Member count limit exceeded.") + boundary = offset + size + position = offset + entries: list[Entry] = [] + names: set[MemberName] = set() + for _ in range(count): + entry, position = _entry(data, position, boundary) + if entry.name in names: + raise InvalidContainerError("Duplicate member name.") + names.add(entry.name) + entries.append(entry) + if position != boundary: + raise invalid() + check_sizes(((e.name, e.compressed, e.size) for e in entries), limits) + return entries, offset diff --git a/src/mlbundle/_zip_members.py b/src/mlbundle/_zip_members.py new file mode 100644 index 0000000..116726c --- /dev/null +++ b/src/mlbundle/_zip_members.py @@ -0,0 +1,116 @@ +"""Validate local records and decompress with explicit output and CRC bounds.""" + +import struct +import zlib + +from ._container_rules import CHUNK_BYTES +from ._zip_directory import LOCAL, U32_MAX, Entry, invalid, unpack, zip64_values +from .contract import MemberName +from .errors import InvalidContainerError + + +def _descriptor(data: bytes, start: int, end: int, entry: Entry) -> None: + if end - start not in (12, 16, 20, 24): + raise invalid() + raw = data[start:end] + candidates = (raw, raw[4:]) if raw.startswith(b"PK\x07\x08") else (raw,) + for candidate in candidates: + if len(candidate) not in (12, 20): + continue + record = struct.unpack(" memoryview: + ( + signature, + version, + flags, + method, + time, + date, + crc, + packed, + size, + name_length, + extra_length, + ) = unpack(LOCAL, data, entry.offset) + if (signature, version, flags, method, time, date) != ( + b"PK\x03\x04", + entry.version, + entry.flags, + entry.method, + entry.time, + entry.date, + ): + raise invalid() + start = entry.offset + LOCAL.size + end = start + name_length + extra_length + payload_end = end + entry.compressed + if payload_end > boundary or data[ + start : start + name_length + ] != entry.name.value.encode("ascii"): + raise invalid() + if extra_length and version != 45: + raise invalid() + size, packed = zip64_values( + data[start + name_length : end], [(size, U32_MAX), (packed, U32_MAX)] + ) + if flags & 8: + if ( + crc not in (0, entry.crc) + or packed not in (0, entry.compressed) + or size not in (0, entry.size) + ): + raise invalid() + _descriptor(data, payload_end, boundary, entry) + elif (crc, packed, size, payload_end) != ( + entry.crc, + entry.compressed, + entry.size, + boundary, + ): + raise invalid() + return memoryview(data)[end:payload_end] + + +def payloads( + data: bytes, entries: list[Entry], directory_offset: int +) -> dict[MemberName, memoryview]: + ordered = sorted(entries, key=lambda entry: entry.offset) + if not ordered or ordered[0].offset != 0: + raise invalid() + boundaries = [entry.offset for entry in ordered[1:]] + [directory_offset] + return { + entry.name: local_payload(data, entry, boundary) + for entry, boundary in zip(ordered, boundaries, strict=True) + } + + +def decompress(payload: memoryview, entry: Entry) -> bytes: + output = bytearray() + crc = 0 + decoder = zlib.decompressobj(-15) if entry.method == 8 else None + for start in range(0, len(payload), CHUNK_BYTES): + pending = payload[start : start + CHUNK_BYTES] + while pending: + allowance = min(CHUNK_BYTES, entry.size - len(output) + 1) + chunk = ( + decoder.decompress(pending, allowance) if decoder else bytes(pending) + ) + if decoder and decoder.unused_data: + raise invalid() + if len(output) + len(chunk) > entry.size: + raise InvalidContainerError( + "Actual member size exceeds ZIP declaration." + ) + output.extend(chunk) + crc = zlib.crc32(chunk, crc) + pending = decoder.unconsumed_tail if decoder else b"" + if decoder and (not decoder.eof or decoder.unused_data): + raise invalid() + if len(output) != entry.size or crc != entry.crc: + raise InvalidContainerError("Member size or CRC disagrees with ZIP metadata.") + return bytes(output) diff --git a/src/mlbundle/container.py b/src/mlbundle/container.py new file mode 100644 index 0000000..590a903 --- /dev/null +++ b/src/mlbundle/container.py @@ -0,0 +1,74 @@ +"""The validated byte boundary used by inspection and future model APIs.""" + +import struct +import zlib +from os import PathLike +from pathlib import Path + +from ._container_rules import CHUNK_BYTES, BundleSource, check_members, effective_limits +from ._zip_directory import directory +from ._zip_members import decompress, payloads +from .canonical import canonical_loads +from .contract import MemberName +from .errors import InvalidContainerError, LimitExceededError +from .integrity import verify_integrity +from .limits import ArchiveLimits +from .manifest import Manifest + + +def _read_source(source: BundleSource, cap: int) -> bytes: + if isinstance(source, bytes): + if len(source) > cap: + raise LimitExceededError("Archive byte limit exceeded.") + return source + if isinstance(source, (str, PathLike)): + path = Path(source) + if path.stat().st_size > cap: + raise LimitExceededError("Archive byte limit exceeded.") + with path.open("rb") as stream: + return _read_source(stream, cap) + content = bytearray() + while chunk := source.read(min(CHUNK_BYTES, cap - len(content) + 1)): + if not isinstance(chunk, bytes): + raise TypeError("Bundle source must be a binary stream.") + if len(content) + len(chunk) > cap: + raise LimitExceededError("Archive byte limit exceeded.") + content.extend(chunk) + return bytes(content) + + +def read_container( + source: BundleSource, *, limits: ArchiveLimits | None = None +) -> tuple[Manifest, dict[MemberName, bytes]]: + """Validate ZIP records, bounded decompression, manifest and member integrity. + + Streams are consumed from their current position and are never closed here. + Model bytes remain opaque: this function does not verify model claims. + """ + selected = effective_limits(limits) + data = _read_source(source, selected.max_archive_bytes) + try: + entries, offset = directory(data, selected) + check_members(entry.name for entry in entries) + packed = payloads(data, entries, offset) + by_name = {entry.name: entry for entry in entries} + manifest_bytes = decompress( + packed[MemberName.MANIFEST], by_name[MemberName.MANIFEST] + ) + manifest = Manifest.from_bytes(manifest_bytes) + check_members(by_name, manifest) + members = {MemberName.MANIFEST: manifest_bytes} + for entry in entries: + if entry.name != MemberName.MANIFEST: + value = decompress(packed[entry.name], entry) + verify_integrity(entry.name, value, manifest.integrity[entry.name]) + if entry.name in ( + MemberName.SCHEMA, + MemberName.STRUCTURE, + MemberName.SAMPLES, + ): + canonical_loads(value, max_bytes=selected.member_bytes(entry.name)) + members[entry.name] = value + except (struct.error, zlib.error, OverflowError): + raise InvalidContainerError("Corrupt ZIP container.") from None + return manifest, members diff --git a/src/mlbundle/inspection.py b/src/mlbundle/inspection.py new file mode 100644 index 0000000..6ea98b0 --- /dev/null +++ b/src/mlbundle/inspection.py @@ -0,0 +1,20 @@ +"""Public metadata-only inspection, without resolving any model adapter.""" + +from ._container_rules import MEMBER_ORDER, BundleSource +from .container import read_container +from .limits import ArchiveLimits +from .results import MetadataView + + +def inspect( + source: BundleSource, *, limits: ArchiveLimits | None = None +) -> MetadataView: + """Validate the container, manifest, JSON bounds and hashes without loading. + + Inspection cannot attest that model-derived claims or structure match the + model. Only a full validating model load can provide that guarantee. + """ + manifest, members = read_container(source, limits=limits) + return MetadataView( + manifest, tuple(name for name in MEMBER_ORDER if name in members) + ) diff --git a/src/mlbundle/manifest.py b/src/mlbundle/manifest.py index 14341e3..f899d58 100644 --- a/src/mlbundle/manifest.py +++ b/src/mlbundle/manifest.py @@ -20,6 +20,7 @@ validate_labels, ) from .contract import ( + FORMAT_NAME, FORMAT_VERSION, MODEL_MEMBERS, FeatureOrigin, @@ -175,6 +176,11 @@ def to_bytes(self) -> bytes: def from_bytes(cls, data: bytes) -> Manifest: try: value = canonical_loads(data) + if not isinstance(value, dict) or value.get("format_name") != FORMAT_NAME: + raise InvalidManifestError + version = value.get("format_version") + if isinstance(version, str) and version != FORMAT_VERSION: + raise UnsupportedFormatVersionError return cls.model_validate_json(canonical_dumps(value)) except (ValidationError, InvalidJSONError): raise InvalidManifestError from None diff --git a/src/mlbundle/writing.py b/src/mlbundle/writing.py new file mode 100644 index 0000000..1d3bf86 --- /dev/null +++ b/src/mlbundle/writing.py @@ -0,0 +1,207 @@ +"""Deterministic container writing; the writer owns integrity records.""" + +import os +import stat +import tempfile +import zlib +from collections.abc import Mapping +from io import BytesIO +from os import PathLike +from pathlib import Path + +from ._container_rules import ( + CHUNK_BYTES, + MEMBER_ORDER, + check_members, + check_sizes, + effective_limits, + member_name, +) +from ._zip_directory import CENTRAL, END, LOCAL +from .canonical import canonical_dumps +from .container import read_container +from .contract import MemberName +from .errors import InvalidContainerError, LimitExceededError +from .integrity import hash_member +from .limits import ArchiveLimits +from .manifest import Manifest + + +class _BoundedBuffer(BytesIO): + def __init__(self, cap: int) -> None: + super().__init__() + self.cap = cap + + def write(self, data: bytes) -> int: + if self.tell() + len(data) > self.cap: + raise LimitExceededError("Archive byte limit exceeded.") + return super().write(data) + + +def _finish_manifest( + manifest: Manifest | Mapping[str, object], members: dict[MemberName, bytes] +) -> Manifest: + value = ( + manifest.model_dump(mode="json", by_alias=True) + if isinstance(manifest, Manifest) + else dict(manifest) + ) + value["integrity"] = { + name.value: hash_member(data).model_dump() for name, data in members.items() + } + return Manifest.from_bytes(canonical_dumps(value)) + + +def _atomic_write(destination: Path, data: bytes, overwrite: bool) -> None: + if not overwrite and destination.exists(): + raise FileExistsError(destination) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + dir=destination.parent, prefix=".mlbundle-", delete=False + ) as stream: + temporary = Path(stream.name) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + if overwrite: + temporary.replace(destination) + else: + # Publish the complete file without a check/replace race. + os.link(temporary, destination) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def _compress(data: bytes, cap: int) -> bytes: + # RLE avoids the platform-specific match searches of zlib versus zlib-ng. + encoder = zlib.compressobj(6, zlib.DEFLATED, -15, 8, zlib.Z_RLE) + with _BoundedBuffer(cap) as packed: + for offset in range(0, len(data), CHUNK_BYTES): + packed.write( + encoder.compress(memoryview(data)[offset : offset + CHUNK_BYTES]) + ) + packed.write(encoder.flush()) + return packed.getvalue() + + +def _archive_bytes(content: dict[MemberName, bytes], limits: ArchiveLimits) -> bytes: + central = bytearray() + sizes = [] + overhead = END.size + sum( + LOCAL.size + CENTRAL.size + 2 * len(name.value) for name in content + ) + remaining = min(limits.max_archive_bytes - overhead, limits.max_compressed_bytes) + with _BoundedBuffer(limits.max_archive_bytes) as buffer: + for name in MEMBER_ORDER: + if name not in content: + continue + data = content[name] + if remaining <= 0: + raise LimitExceededError("Container byte limit exceeded.") + packed = _compress(data, remaining) + remaining -= len(packed) + sizes.append((name, len(packed), len(data))) + check_sizes(sizes, limits) + raw_name = name.value.encode("ascii") + crc = zlib.crc32(data) + offset = buffer.tell() + buffer.write( + LOCAL.pack( + b"PK\x03\x04", + 20, + 0, + 8, + 0, + 33, + crc, + len(packed), + len(data), + len(raw_name), + 0, + ) + + raw_name + ) + buffer.write(packed) + central.extend( + CENTRAL.pack( + b"PK\x01\x02", + (3 << 8) | 20, + 20, + 0, + 8, + 0, + 33, + crc, + len(packed), + len(data), + len(raw_name), + 0, + 0, + 0, + 0, + (stat.S_IFREG | 0o600) << 16, + offset, + ) + + raw_name + ) + directory_offset = buffer.tell() + buffer.write(bytes(central)) + buffer.write( + END.pack( + b"PK\x05\x06", + 0, + 0, + len(sizes), + len(sizes), + len(central), + directory_offset, + 0, + ) + ) + return buffer.getvalue() + + +def write_container( + manifest: Manifest | Mapping[str, object], + members: Mapping[MemberName, bytes], + *, + destination: str | PathLike[str] | None = None, + overwrite: bool = False, + limits: ArchiveLimits | None = None, +) -> bytes: + """Finish integrity, encode a deterministic ZIP and optionally publish it. + + Supply only non-manifest member bytes and a manifest or its JSON-ready parts. + Existing integrity records are replaced from those exact bytes. References + must already identify the supplied members. Existing paths require overwrite. + """ + selected = effective_limits(limits) + content = {member_name(name): data for name, data in members.items()} + if MemberName.MANIFEST in content: + raise InvalidContainerError("The writer creates manifest.json.") + check_members([MemberName.MANIFEST, *content]) + if len(content) + 1 > selected.max_members: + raise LimitExceededError("Member count limit exceeded.") + for name, data in content.items(): + if not isinstance(data, bytes): + raise TypeError("Member content must be bytes.") + if len(data) > selected.member_bytes(name): + raise LimitExceededError(f"Member size limit exceeded: {name.value}.") + if sum(map(len, content.values())) > selected.max_total_bytes: + raise LimitExceededError("Total member size limit exceeded.") + finished = _finish_manifest(manifest, content) + content[MemberName.MANIFEST] = canonical_dumps( + finished.model_dump(mode="json", by_alias=True), + max_bytes=selected.member_bytes(MemberName.MANIFEST), + ) + if sum(map(len, content.values())) > selected.max_total_bytes: + raise LimitExceededError("Total member size limit exceeded.") + check_members(content, finished) + result = _archive_bytes(content, selected) + # One owner for all ZIP/integrity/JSON checks, including actual compressed sizes. + read_container(result, limits=selected) + if destination is not None: + _atomic_write(Path(destination), result, overwrite) + return result diff --git a/tests/conftest.py b/tests/conftest.py index 4c79112..ffc7411 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,15 @@ """Small wire-format fixture shared by contract feature tests.""" +import io +import struct +import warnings +import zipfile +import zlib + import pytest from mlbundle import __version__ +from mlbundle.canonical import canonical_dumps from mlbundle.integrity import hash_member @@ -44,3 +51,178 @@ def manifest_data() -> dict: "references": {"schema": None, "structure": None, "samples": None}, "user_metadata": {"name": "example", "metrics": {"accuracy": 0.95}}, } + + +def build_zip(parts, compression=0, *, archive_comment=b"", **attributes): + buffer = io.BytesIO() + with warnings.catch_warnings(record=True), zipfile.ZipFile(buffer, "w") as archive: + warnings.simplefilter("ignore", UserWarning) + archive.comment = archive_comment + for name, data in parts: + info = zipfile.ZipInfo(str(name)) + info.compress_type = compression + for key, value in attributes.items(): + setattr(info, key, value) + archive.writestr(info, data) + return buffer.getvalue() + + +@pytest.fixture +def parts(manifest_data): + return [ + ("manifest.json", canonical_dumps(manifest_data)), + ("model.skops", b"native model bytes"), + ] + + +def patch(data, central_field, local_field, value, fmt="I", *, local=True): + raw = bytearray(data) + central = raw.find(b"PK\x01\x02", raw.find(b"PK\x01\x02") + 4) + offset = struct.unpack_from(" Date: Mon, 12 Oct 2026 00:07:33 +0200 Subject: [PATCH 03/15] feat: add verified sklearn export and load (#4) --- AGENTS.md | 32 ++- README.md | 68 +++++-- SECURITY.md | 50 ++++- docs/format.md | 98 +++++++-- pyproject.toml | 7 +- src/mlbundle/__init__.py | 5 + src/mlbundle/_sklearn/__init__.py | 198 +++++++++++++++++++ src/mlbundle/_sklearn/parameters.py | 52 +++++ src/mlbundle/_sklearn/payload.py | 247 +++++++++++++++++++++++ src/mlbundle/_sklearn/prediction.py | 84 ++++++++ src/mlbundle/_sklearn/structure.py | 212 ++++++++++++++++++++ src/mlbundle/canonical.py | 11 ++ src/mlbundle/container.py | 2 +- src/mlbundle/errors.py | 15 +- src/mlbundle/model_io.py | 173 ++++++++++++++++ src/mlbundle/registry.py | 25 +++ src/mlbundle/results.py | 2 +- src/mlbundle/writing.py | 37 +++- tests/test_errors.py | 1 + tests/test_loading.py | 257 ++++++++++++++++++++++++ tests/test_model_io.py | 143 ++++++++++++++ tests/test_sklearn.py | 296 ++++++++++++++++++++++++++++ tests/test_skops_payload.py | 230 +++++++++++++++++++++ tests/test_writing.py | 46 +++++ uv.lock | 86 +++++++- 25 files changed, 2313 insertions(+), 64 deletions(-) create mode 100644 src/mlbundle/_sklearn/__init__.py create mode 100644 src/mlbundle/_sklearn/parameters.py create mode 100644 src/mlbundle/_sklearn/payload.py create mode 100644 src/mlbundle/_sklearn/prediction.py create mode 100644 src/mlbundle/_sklearn/structure.py create mode 100644 src/mlbundle/model_io.py create mode 100644 tests/test_loading.py create mode 100644 tests/test_model_io.py create mode 100644 tests/test_sklearn.py create mode 100644 tests/test_skops_payload.py diff --git a/AGENTS.md b/AGENTS.md index 84e66a4..c732bec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,11 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phases 1 and 2 implement the version 1 format contract, strict manifest models, +Phases 1–3 implement the version 1 format contract, strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, -secure container reading/writing and public metadata-only `inspect`. -No public export/load API, model adapter, schema inference or CLI exists yet. +secure container reading/writing, public metadata-only `inspect`, export/load, +and the scikit-learn adapter. XGBoost and ONNX adapters, schema inference and +the CLI remain unimplemented. Rules for later phases must not be described as implemented until code and tests prove them. `docs/format.md` owns the wire contract. @@ -201,8 +202,11 @@ The conversation, issues, code, tests and final diff are the work record. The container writer accepts serialized member bytes and a manifest or its JSON-ready parts, computes integrity records and produces a deterministic ZIP. It enforces the same limits and validation as the container reader and publishes -paths atomically, requiring explicit overwrite. Future model export will infer -schema locally, extract claims/structure and serialize through native adapters. +paths atomically on hard-link filesystems, requiring explicit overwrite. The +exclusive-creation fallback removes partial destinations on failure but exposes +the file during copying. Public sklearn export extracts claims/structure, +serializes with skops and verifies a native round trip before publication. +Schema inference is future work; export currently includes no training rows. The closed member list is: required `manifest.json`, exactly one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional @@ -223,8 +227,16 @@ cannot survive the native round trip. Default safety ceilings may only be lowere The container reader validates the archive, manifest, JSON bounds and integrity without loading a model. `inspect` exposes validated metadata and a member list without importing model libraries; it cannot attest that model-derived claims -match the model. Future full loading for MLSuite must use a closed adapter and -recompute claims before model use. +match the model. Public `load` uses a closed adapter and recomputes claims and +structure before returning the native model and validated predictor. + +An adapter module provides `Adapter` with the library and model-member identity, +recognition, serialization/deserialization, `ModelClaims`, structure extraction +and a `Predictor`. Extraction uses only the native model, shared by export/load; +ONNX graph fields belong in `ModelClaims.onnx`. Missing names are null claims; +the shared pipeline owns supplied/generated names and environment declarations. +Native errors must become bounded `MLBundleError` subclasses. Follow the unchanged +protocols in `adapters.py`; adding `_xgboost` or `_onnx` needs no shared pipeline edit. ## Where code lives @@ -233,12 +245,14 @@ recompute claims before model use. - `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. - `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. - `src/mlbundle/canonical.py` — deterministic JSON encoding and bounded strict decoding. +- `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. +- `src/mlbundle/_sklearn/` — skops trust/loading, parameter and claim extraction, native structure, nested payload bounds and validated prediction. - `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. - `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. - `src/mlbundle/integrity.py` — member SHA-256/size records and verification. - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. -- `src/mlbundle/registry.py` — closed lazy imports of future `_sklearn`, `_xgboost`, `_onnx` modules; each provides an `Adapter` class. +- `src/mlbundle/registry.py` — closed lazy adapter resolution and recognition; `_sklearn` is implemented, `_xgboost` and `_onnx` remain future modules. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. @@ -246,7 +260,7 @@ recompute claims before model use. - `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. - `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. - `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. -- `docs/format.md` — version 1 wire specification; model adapters remain unimplemented. +- `docs/format.md` — version 1 wire specification and implemented sklearn mapping/trust rules. - `tests/` — one file per feature, with success and error cases together. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. diff --git a/README.md b/README.md index d76824f..05ae73c 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # mlbundle -mlbundle is a Python library being built to package a trained model, a rich -manifest, its input schema and its internal structure in one compressed -`.mlbundle` file. The same package will write bundles on the training machine -and read them with validation in a runtime. +mlbundle is a Python library for packaging a trained model, a rich manifest and +its internal structure in one compressed `.mlbundle` file. The same package +writes bundles on the training machine and reads them with validation in a runtime. +Local schema inference will be added in a later phase. -**Status: pre-alpha, Phase 2.** The [version 1 format contract](docs/format.md), +**Status: pre-alpha, Phase 3.** The [version 1 format contract](docs/format.md), strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing and public `inspect` -are implemented and tested. No model adapter, public export/load API, schema -inference or CLI exists yet, and nothing has been published to PyPI. +and public sklearn `export`/`load` are implemented and tested. XGBoost and ONNX +adapters, schema inference and the CLI are still missing. Nothing has been +published to PyPI. ## Why it exists @@ -35,29 +36,52 @@ native model formats; it copies no MLflow code. mlbundle is MIT licensed. Bundles are untrusted input. The container reader rejects unsafe ZIP members, enforces resource limits, validates the manifest and format version, and verifies -member hashes. Future model loading must use a closed set of model types and a -trusted-types list owned by mlbundle, with no pickle, joblib or cloudpickle path. Model-derived -metadata and visualisation structures are claims: the reader must recompute -them from the loaded model and reject mismatches. A metadata-only `inspect` +member hashes. Sklearn loading uses skops with a fixed trusted-types list owned +by mlbundle, with no pickle, joblib or cloudpickle path. Model-derived metadata +and visualisation structures are claims: `load` recomputes them from the native +model and rejects mismatches. `export` checks that they survive a native round +trip before writing. A metadata-only `inspect` operation works without deserializing the model or importing model libraries. Hashes detect corruption; they do not authenticate the author or prevent deliberate tampering. Signing is outside the project's scope. These guarantees -cover today's container validation; model loading and claim recomputation remain -requirements for future adapters. +cover container validation and sklearn claim recomputation; native parsers and +prediction still require a consuming runtime's isolation and resource limits. See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. ```python -from mlbundle import inspect - -metadata = inspect("trained.mlbundle") -print(metadata.manifest.model.library) -print(metadata.members) +from sklearn.tree import DecisionTreeClassifier +from mlbundle import export, inspect, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +labels = ["cold", "warm", "cold", "warm"] +model = DecisionTreeClassifier(random_state=7).fit(rows, labels) +export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) + +bundle = load("trained.mlbundle") +assert isinstance(bundle.native_model, DecisionTreeClassifier) +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +print(bundle.class_labels) # [["cold", "warm"]] +print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] +print(inspect("trained.mlbundle").manifest.model.library) # sklearn ``` Inspection validates format and integrity. It cannot attest that model-derived -claims or structure match the model. The internal container functions accept -already serialized bytes; public model export and loading arrive in later phases. +claims or structure match the model. `load` supplies that guarantee and returns +the native model, predictor and metadata. Model-exposed feature names take +precedence over `feature_names=`; otherwise those names are declarations with +origin `dataframe`, or omitted names become `feature_1` through `feature_N`. +`target_name=` and `metadata=` record author declarations. Existing destinations +require `overwrite=True`. Paths, bytes and binary streams are accepted by `load`. + +Supported sklearn models must be fitted classifiers with `predict_proba` or +regressors, with all stored types trusted and all claims surviving skops. +DecisionTree, RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, +LinearRegression, Ridge and scaler/classifier pipelines are covered by round-trip +tests. Multi-output labels and probabilities retain output-major order. Pipeline +structure is null because v1 does not map transformed features. Custom types, +user functions, HistGradientBoosting and XGBoost steps inside sklearn pipelines +are rejected. Export currently includes no training DataFrame, schema or samples. ## Development @@ -82,7 +106,9 @@ The default dev group installs all model extras. Base consumers can use `uv sync --no-dev` without installing model libraries. The committed `uv.lock` fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX Runtime to the target MLSuite runtime baseline; this is not yet evidence of -adapter compatibility. `uv run pytest` +compatibility for the future adapters; sklearn compatibility is exercised by +the round-trip tests. Pandas is a development dependency for DataFrame tests, +not a core runtime dependency. `uv run pytest` produces terminal and XML coverage reports and enforces an 80% minimum. CI runs lint, type checks, and Python 3.14 tests on Ubuntu and Windows. `uv build` creates local wheel and source archives; it does not publish them. diff --git a/SECURITY.md b/SECURITY.md index 645da54..dd6fcfe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,15 @@ # Security policy -mlbundle is pre-alpha. Phases 1 and 2 implement strict manifest validation, -contract helpers, secure container reading/writing and metadata-only `inspect`. +mlbundle is pre-alpha. Phases 1–3 implement strict manifest validation, +contract helpers, secure container reading/writing, metadata-only `inspect`, +public export/load and the sklearn adapter. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. -The writer publishes complete validated files atomically. The version 1 contract -is defined in [docs/format.md](docs/format.md). Model adapters and public -export/load are not implemented: requirements involving model loading, trusted -types and claim recomputation below remain obligations for later phases. +The writer publishes complete validated files atomically where hard links work; +the exclusive-creation fallback cleans up on failure but exposes the destination +while copying and cannot guarantee crash-atomic publication. The version 1 contract +is defined in [docs/format.md](docs/format.md). XGBoost and ONNX loading remain +unimplemented; their security requirements remain obligations for later phases. ## Threat model @@ -52,6 +54,42 @@ The supported storage formats are skops for scikit-learn, native UBJSON for XGBoost and the user's original ONNX file. There is no conversion or fallback serialization path. Adapters must use lazy imports for optional dependencies. +## Implemented sklearn trust boundary + +Export and load call skops' `get_untrusted_types` and reject all types outside +the fixed supplemental list `["sklearn.tree._tree.Tree"]`. Load passes exactly +that list to `skops.io.loads`, alongside skops' built-in trusted defaults. This +entry is native numeric tree storage required by DecisionTree, RandomForest, +ExtraTrees and GradientBoosting. It does not add arbitrary functions or I/O +constructors. The bundle cannot provide additional trusted types. + +Skops 0.16.0 deliberately distrusts tree storage because unchecked child and +feature indices can crash native inference. mlbundle checks loaded tree dimensions, +indices, reachability, depth and owning-estimator dimensions before computing +importances or predicting. Ensemble feature mappings must stay within parent +input widths. It bounds tree constructor dimensions and class counts before +native construction, and checks state-array shapes against those dimensions. +It also bounds the inner skops ZIP and validates every schema-referenced numeric +array header, including renamed files, before skops reads it. Zero-itemsize +arrays are rejected; object arrays never use pickle loading. Malformed native +payloads are wrapped in bounded `InvalidModelError` messages. These checks do not +make the native parser a sandbox. + +The installed skops load path builds nodes, audits them and only then constructs +objects; NumPy buffers use `allow_pickle=False`. No pickle/joblib loading fallback +is used. Skops imports `pickle` only to recognize an unrelated save-side exception; +this does not deserialize pickle bytes. Custom estimators/functions and XGBoost +estimators inside sklearn pipelines are rejected. HistGradientBoosting's separate +`TreePredictor` storage is also excluded. Broadening the supplemental list requires +source review and hostile-payload tests, never author approval or bundle metadata. + +Both export and load recompute the same model claims and structures and compare +canonical bytes, preserving class-label types and order. Export additionally +compares the original and reloaded native models before publication. Loaded results +contain the native model for runtime explanations; mutating it invalidates the +relationship to the verified metadata. Pipeline structure is null because v1 +does not describe its input transformations. + ## Limits of these guarantees SHA-256 hashes detect corruption, not deliberate tampering: an attacker can diff --git a/docs/format.md b/docs/format.md index 877a1ee..0804997 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,10 +2,10 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -“Must” denotes a compatibility or security requirement. Phases 1 and 2 implement -the manifest, encoding, limits, adapter contracts, secure container reading and -writing, and public metadata-only inspection. Model adapters, public export/load -and schema inference are implemented in later phases. +“Must” denotes a compatibility or security requirement. Phases 1–3 implement +the manifest, encoding, limits, secure container, metadata-only inspection, +public export/load and the scikit-learn adapter. XGBoost and ONNX adapters, +schema inference and the CLI remain unimplemented. ## Container and resource limits @@ -119,8 +119,11 @@ Writing returns bytes and optionally publishes them to a path after validating the complete archive through the reader. Publication uses a flushed temporary file in the destination directory: atomic replacement when overwrite is true, or atomic hard-link creation otherwise, followed by temporary-file cleanup. -Hard-link publication requires a filesystem supporting hard links; otherwise -the filesystem error is raised and no partial destination is left. Existing +When hard links are unsupported, publication uses exclusive destination creation, +flush and fsync, removing its own destination on failure. This fallback prevents +overwrites and leaves no partial file after a reported failure, but the destination +is visible while copying; it is not an atomic visibility guarantee or crash-safe +publication. Other filesystem errors propagate. Existing destinations raise `FileExistsError` unless overwrite is explicitly requested; the no-overwrite decision also holds if another writer wins a race. @@ -128,8 +131,8 @@ Public `mlbundle.inspect(source, *, limits=None)` returns `MetadataView` with the manifest and fixed-order member listing. It validates ZIP records, limits, manifest, member hashes and JSON syntax without importing model libraries. It cannot attest that model-derived claims or structure match the model; only -a future full validating load can do that. Samples must still be explicitly -requested by the caller of the future export API. +the full validating `load` can do that. The current export API includes neither +schema nor samples; future sample export must require explicit user intent. Container/layout/CRC inconsistencies raise `InvalidContainerError`; resource ceilings raise `LimitExceededError`. Invalid manifest JSON or typed fields raise @@ -197,7 +200,7 @@ Identity fields are declarations, not proof of provenance. | `serialization` | `"skops"`, `"ubjson"`, `"onnx"` | Must pair respectively with the three libraries and model members above. | | `estimator_class` | nonempty string, at most 256 characters | Verified class name (not a Python import path); ONNX uses the adapter's fixed `"ONNX"` identity. Never instantiate from this field. | | `kind` | `"classifier"`, `"regressor"` | Verified prediction kind. | -| `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's `get_params(deep=False)`; native models without `get_params` use their recoverable native parameter representation. ONNX uses `{}`. At most 1 MiB canonical bytes and depth 16. | +| `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's parameters. Sklearn uses `get_params(deep=True)`; other adapters use their recoverable native parameter representation. ONNX uses `{}`. At most 1 MiB canonical bytes and depth 16. | | `n_features` | integer 1–100,000 | Verified ordered input width. | | `n_classes` | null or array of integers 1–100,000 | Verified per-output class counts; classifier array length equals `n_outputs`, regressor value is null. | | `n_outputs` | integer 1–1,024 | Verified target output count. Probability columns are classes, not separate target outputs. | @@ -206,7 +209,7 @@ Hyperparameter values preserve JSON scalars, nested containers and numeric tags. Do not stringify unsupported objects or call `repr` as a fallback. If an adapter supports nested estimators in parameters, their representation is an object `{"$estimator":{"class":CLASS_NAME,"params":PARAMETER_OBJECT}}`, recursively, -with classes drawn from its own closed allowlist. This is a comparison record, +with types admitted by the adapter's trust boundary. This is a comparison record, never an instruction to import or construct a class. Unsupported callable, custom-object or otherwise unrecoverable parameters must fail export. The writer must serialize, reload and compare extracted claims and structure @@ -214,6 +217,20 @@ before emitting a bundle. Native formats may omit training-time parameters: export must fail when the chosen claims cannot survive that round trip; the manifest must never restore missing parameters into a reader's model. +Sklearn parameters preserve `None`, JSON booleans, strings, integers and floats; +non-finite floats use the canonical tags. NumPy scalars/arrays use the canonical +conversion below; tuples become arrays, and dictionaries require string keys. +Nested sklearn estimators use the `$estimator` record with their class name and +recursively projected deep parameters. Deep keys such as `classifier__C` are +retained alongside the estimator record. A NumPy `RandomState` becomes +`{"$random_state":[ALGORITHM,STATE_ARRAY,POSITION,HAS_GAUSSIAN,CACHED_GAUSSIAN]}` +from its current `get_state()`, after training. No initial seed is guessed. +Callables (including built-in functions, NumPy functions, lambdas and partials), +types, cyclic containers, byte strings, complex/datetime values and other custom +objects are rejected. There is no `repr`, identity/address or stringification +fallback. A skops-trusted object can still be rejected if its parameters have no +faithful projection. The round-trip comparison checks all these records. + ### `features` | Field | Type | Meaning / verification | @@ -221,12 +238,14 @@ manifest must never restore missing parameters into a reader's model. | `names` | array of 1–100,000 unique nonempty strings, each at most 1,024 characters | Effective input names, in prediction order; length equals `n_features`. | | `dtypes` | equally sized array of null or nonempty strings of at most 256 characters | Training DataFrame dtype declarations in feature order; null means unknown. They do not prove model input types. | | `model_dtypes` | optional equally sized array of nonempty strings of at most 256 characters | Verified dtypes exposed by the native model, or null if it exposes none; separate from training dtypes. ONNX uses normalized NumPy dtype names such as `float32`, `float64`, `int32`, `int64`. | -| `origin` | `"model"`, `"dataframe"`, `"generated"` | Source of the effective names. | +| `origin` | `"model"`, `"dataframe"`, `"generated"` | Model names, declared training-input names, or generated names. | If a model exposes feature names, origin must be `model` and names must equal its extracted names in order. Otherwise `dataframe` names are author declarations -from the locally supplied frame; the reader verifies their count, not their -historical truth. With no model names or frame names, origin is `generated` and +of training-input column names: currently supplied through `feature_names=`, +and later also through a locally supplied training frame. This identifier does +not prove that a DataFrame was used. The reader verifies their count, not their +historical truth. With no model or declared names, origin is `generated` and names must be `feature_1` through `feature_N`. Claim extraction represents missing model feature names as null, so falsely declaring a named model's origin as `dataframe` or `generated` also fails comparison. Model dtype extraction is @@ -383,6 +402,15 @@ ensemble. Unsupported or oversized mandatory structure fails export rather than being silently omitted. ONNX graph visualizations and additional structure families require a deliberate future contract change. +Sklearn `Pipeline` returns null structure in version 1. Its final estimator +operates on transformed features; version 1 has no input transformation mapping, +so no final-step tree or coefficient member is emitted. Claims, names and +predictions still describe the complete pipeline. Standalone DecisionTree, +RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, LinearRegression +and Ridge retain their native structures as specified above. HistGradientBoosting +is currently rejected: its `TreePredictor` storage is not on mlbundle's trust +allowlist. No approximate trees are emitted for it. + ## Adapter and result contracts The internal `Adapter` protocol in `mlbundle.adapters` owns recognition, @@ -390,8 +418,44 @@ serialization, loading, claim extraction, structure extraction and a `Predictor` The registry imports only the three constant mlbundle module paths `_sklearn`, `_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library imports must be lazy, with dependency failures naming the extra to install. -Missing adapter modules currently raise `AdapterNotImplementedError`. Public -`inspect` is implemented; there are no public export/load placeholders. +Missing adapter modules raise `AdapterNotImplementedError` on load; export skips +unimplemented adapters while looking for one that recognizes its input. A missing +optional dependency reports `pip install "mlbundle[EXTRA]"`. An unrecognized +object raises `UnsupportedModelError`. + +`export(model, destination, *, feature_names=None, target_name=None, metadata=None, +overwrite=False, limits=None)` returns a small `MetadataView` of the written +bundle. It serializes, extracts, reloads and compares both claims and structure +before writing. Model-exposed names take precedence over supplied names. It +captures distribution metadata, including `claims.onnx` in the environment for +the future ONNX adapter. No unimplemented schema/data parameters are accepted. +`load(source, *, limits=None)` validates the container, resolves its closed enum, +deserializes, recomputes claims/structure and compares canonical bytes before +constructing a `LoadedBundle`. `ClaimMismatchError` identifies the claim without +echoing values; absent/present structure disagreements are checked separately, +including a present JSON null. Malformed native payloads raise `InvalidModelError`, +an `MLBundleError` subclass, without native exception text. + +The sklearn adapter uses only `skops.io.dumps/loads`. Its fixed supplemental +trusted list is exactly `["sklearn.tree._tree.Tree"]`, for numeric node storage +in the supported tree families; it adds no callable, arbitrary class or I/O type. +Skops' own defaults remain part of the dependency trust boundary. Dumped bytes +are audited with `get_untrusted_types`; every type outside the list is rejected +at export and independently at load. XGBoost estimators inside sklearn pipelines +are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). +The native XGBoost adapter must own their loading in the next phase. +The nested skops archive is bounded before parsing; every schema-referenced +numeric array header is checked regardless of filename suffix. Headers must +match the stored byte sizes and cannot use object-array loading or zero-itemsize +dtypes. Tree constructor dimensions/class counts and state-array shapes are +bounded before native construction. Loaded tree dimensions, child/feature indices, +reachability and depth are checked before native feature-importance traversal or +prediction, and input/output/class counts must agree with owning estimators. +Ensemble feature mappings must address the parent input width. Every loaded root object is +checked again for classifier/regressor identity, fitted state and probability +support. Multilabel classifiers with independent positive-class columns and +classifier chains are rejected because their probabilities cannot satisfy the +output-major contract; ordinary multi-output probability-matrix lists are supported. Predictors accept adapter-supported tabular objects without a core pandas/numpy dependency. Regressors return `list[float]` for a single output or row-major @@ -409,8 +473,8 @@ properties for kind, library, class name, ordered feature names and origin, and output-major original class labels. Calling the prediction method for the wrong kind raises a bounded `ValueError`. Result records and typed models prevent field reassignment, but nested JSON values and the native model are not deeply frozen. -Construction itself performs no model validation; only a future validating reader -can supply the loaded-result guarantee. Consumers must not mutate a validated +Construction itself performs no model validation; `load` supplies the loaded-result +guarantee. Consumers must not mutate a validated model and continue treating its original metadata as verified. ## Versioning and integrity diff --git a/pyproject.toml b/pyproject.toml index 0670d97..1fa8e7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ requires-python = ">=3.14" dependencies = ["pydantic>=2.13.4,<3"] [project.optional-dependencies] -# sklearn's floor matches skops; other floors match the target runtime baseline. -sklearn = ["scikit-learn>=1.2", "skops>=0.16.0"] +# sklearn's floor provides get_tags; other floors match the runtime baseline. +sklearn = ["scikit-learn>=1.6", "skops>=0.16.0"] xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] all = [ - "scikit-learn>=1.2", "skops>=0.16.0", "xgboost>=3.4.1", + "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", "onnx>=1.23.0", "onnxruntime>=1.30.0", ] @@ -59,6 +59,7 @@ dev = [ "pre-commit>=4.6.0", # Install every adapter in the default development environment. "mlbundle[all]", + "pandas>=3.0.6", ] [build-system] diff --git a/src/mlbundle/__init__.py b/src/mlbundle/__init__.py index 0c67b1f..78ac138 100644 --- a/src/mlbundle/__init__.py +++ b/src/mlbundle/__init__.py @@ -20,6 +20,7 @@ InvalidContainerError, InvalidJSONError, InvalidManifestError, + InvalidModelError, LimitExceededError, MissingOptionalDependencyError, MLBundleError, @@ -30,6 +31,7 @@ from .integrity import IntegrityRecord from .limits import ArchiveLimits from .manifest import FeatureInfo, Manifest, MemberReferences, ModelInfo, TargetInfo +from .model_io import export, load from .results import LoadedBundle, MetadataView __version__ = _version("mlbundle") @@ -47,6 +49,7 @@ "InvalidContainerError", "InvalidJSONError", "InvalidManifestError", + "InvalidModelError", "Library", "LimitExceededError", "LoadedBundle", @@ -67,5 +70,7 @@ "UnsupportedFormatVersionError", "UnsupportedModelError", "__version__", + "export", "inspect", + "load", ] diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py new file mode 100644 index 0000000..0b87076 --- /dev/null +++ b/src/mlbundle/_sklearn/__init__.py @@ -0,0 +1,198 @@ +"""Skops-only sklearn adapter; importing the base package never imports this module.""" + +from typing import Any, cast + +import numpy as np +import skops.io as sio +from pydantic import JsonValue, ValidationError +from sklearn.base import BaseEstimator, is_classifier, is_regressor +from sklearn.exceptions import NotFittedError +from sklearn.multioutput import ClassifierChain, MultiOutputRegressor, RegressorChain +from sklearn.pipeline import Pipeline +from sklearn.utils import get_tags +from sklearn.utils.validation import check_is_fitted + +from mlbundle.canonical import canonical_value +from mlbundle.claims import ClassLabels, FeatureNames, ModelClaims +from mlbundle.contract import Library, MemberName, ModelKind +from mlbundle.errors import ( + InvalidJSONError, + InvalidModelError, + MLBundleError, + UnsupportedModelError, +) +from mlbundle.limits import MAX_CLASSES, MAX_OUTPUTS + +from .parameters import parameters +from .payload import validate_payload +from .prediction import Predictor +from .structure import extract_structure, validate_tree_storage + +# Native numeric node storage for DecisionTree, RandomForest, ExtraTrees and +# GradientBoosting; no functions/I/O constructors. Indices are validated on load. +TRUSTED_TYPES = ("sklearn.tree._tree.Tree",) + + +def _untrusted(data: bytes) -> None: + unsupported = tuple( + sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) + ) + if unsupported: + raise UnsupportedModelError(unsupported) + + +def _outputs(model: Any, classifier: bool) -> tuple[int, ClassLabels | None]: + if classifier: + labels = _labels(model) + return len(labels), labels + estimator = model.steps[-1][1] if isinstance(model, Pipeline) else model + if hasattr(estimator, "n_outputs_"): + outputs = estimator.n_outputs_ + if ( + not isinstance(outputs, (int, np.integer)) + or isinstance(outputs, (bool, np.bool_)) + or not 1 <= outputs <= MAX_OUTPUTS + ): + raise InvalidModelError("Invalid regressor output count.") + return int(outputs), None + if hasattr(estimator, "coef_"): + coef = np.asarray(estimator.coef_) + if coef.ndim not in (1, 2) or coef.shape[-1] != estimator.n_features_in_: + raise InvalidModelError("Invalid regressor coefficient dimensions.") + return 1 if coef.ndim == 1 else coef.shape[0], None + if hasattr(estimator, "_y"): + target = np.asarray(estimator._y) + return 1 if target.ndim == 1 else target.shape[1], None + if isinstance(estimator, (MultiOutputRegressor, RegressorChain)): + return len(list(estimator.estimators_)), None + if not get_tags(estimator).target_tags.multi_output: + return 1, None + raise UnsupportedModelError( + reason="Regressor output count cannot be recovered faithfully." + ) + + +def _labels(model: Any) -> ClassLabels: + classes = getattr(model, "classes_", None) + if classes is None: + raise UnsupportedModelError(reason="Classifier must expose original classes_.") + outputs = classes if isinstance(classes, list) else [classes] + if not 1 <= len(outputs) <= MAX_OUTPUTS or any( + np.asarray(output).ndim != 1 or not 1 <= len(output) <= MAX_CLASSES + for output in outputs + ): + raise InvalidModelError("Invalid classifier label dimensions.") + return cast(ClassLabels, canonical_value(outputs)) + + +class Adapter: + library = Library.SKLEARN + model_member = MemberName.SKOPS + + def recognizes(self, model: object) -> bool: + return ( + isinstance(model, BaseEstimator) + and not type(model).__module__.startswith("xgboost.") + and (is_classifier(model) or is_regressor(model)) + ) + + def _validate(self, model: Any) -> None: + if not self.recognizes(model): + raise UnsupportedModelError( + reason="Sklearn payload must be a classifier or regressor." + ) + try: + check_is_fitted(model) + except NotFittedError: + raise UnsupportedModelError( + reason="Sklearn estimator must be fitted before export/load." + ) from None + if is_classifier(model) and not callable(getattr(model, "predict_proba", None)): + raise UnsupportedModelError(reason="Classifier must expose predict_proba.") + estimator = model.steps[-1][1] if isinstance(model, Pipeline) else model + binarizer = getattr(estimator, "_label_binarizer", None) + if ( + isinstance(estimator, ClassifierChain) + or getattr(estimator, "multilabel_", False) + or getattr(binarizer, "y_type_", None) == "multilabel-indicator" + ): + raise UnsupportedModelError( + reason="Multilabel/chain probabilities cannot satisfy the output-major contract." + ) + if not hasattr(model, "n_features_in_"): + raise UnsupportedModelError( + reason="Estimator must expose fitted n_features_in_." + ) + validate_tree_storage(model) + + def serialize(self, model: object) -> bytes: + try: + data = sio.dumps(model) + validate_payload(data) + _untrusted(data) + self._validate(model) + return data + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="Model cannot be serialized safely with skops." + ) from None + + def deserialize(self, data: bytes) -> object: + try: + validate_payload(data) + _untrusted(data) + model = sio.loads(data, trusted=list(TRUSTED_TYPES)) + self._validate(model) + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid skops model payload.") from None + + def extract_claims(self, model: Any) -> ModelClaims: + try: + self._validate(model) + classifier = is_classifier(model) + outputs, labels = _outputs(model, classifier) + names = getattr(model, "feature_names_in_", None) + if names is not None and ( + np.asarray(names).ndim != 1 or len(names) != model.n_features_in_ + ): + raise InvalidModelError("Invalid model feature-name dimensions.") + return ModelClaims( + estimator_class=type(model).__name__, + kind=ModelKind.CLASSIFIER if classifier else ModelKind.REGRESSOR, + hyperparameters=parameters(model), + n_features=int(model.n_features_in_), + n_classes=None + if labels is None + else [len(output) for output in labels], + n_outputs=outputs, + feature_names=None + if names is None + else cast(FeatureNames, canonical_value(names)), + feature_dtypes=None, + class_labels=labels, + ) + except (ValidationError, InvalidJSONError): + raise UnsupportedModelError( + reason="Model claims cannot be represented faithfully." + ) from None + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid sklearn model claims.") from None + + def extract_structure(self, model: object) -> dict[str, JsonValue] | None: + try: + self._validate(model) + return extract_structure(model) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid sklearn model structure.") from None + + def predictor(self, model: object) -> Predictor: + return Predictor(model, self.extract_claims(model)) diff --git a/src/mlbundle/_sklearn/parameters.py b/src/mlbundle/_sklearn/parameters.py new file mode 100644 index 0000000..41625d0 --- /dev/null +++ b/src/mlbundle/_sklearn/parameters.py @@ -0,0 +1,52 @@ +"""Deterministic sklearn parameters; these records never select loading code.""" + +from typing import cast + +import numpy as np +from pydantic import JsonValue +from sklearn.base import BaseEstimator + +from mlbundle.canonical import canonical_value +from mlbundle.errors import UnsupportedModelError + + +def _parameter(value: object, ancestors: frozenset[int]) -> JsonValue: + if id(value) in ancestors: + raise UnsupportedModelError(reason="Cyclic hyperparameters are unsupported.") + path = ancestors | {id(value)} + if isinstance(value, BaseEstimator): + if not type(value).__module__.startswith("sklearn."): + raise UnsupportedModelError( + (f"{type(value).__module__}.{type(value).__name__}",) + ) + return { + "$estimator": { + "class": type(value).__name__, + "params": parameters(value, path), + } + } + if isinstance(value, np.random.RandomState): + return {"$random_state": canonical_value(value.get_state())} + if isinstance(value, dict): + if any(type(key) is not str for key in value): + raise UnsupportedModelError(reason="Hyperparameter keys must be strings.") + return {key: _parameter(item, path) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_parameter(item, path) for item in value] + if callable(value): + raise UnsupportedModelError( + reason="Callable and type hyperparameters are unsupported." + ) + return canonical_value(value) + + +def parameters( + model: BaseEstimator, ancestors: frozenset[int] = frozenset() +) -> dict[str, JsonValue]: + return cast( + dict[str, JsonValue], + { + key: _parameter(value, ancestors) + for key, value in model.get_params(deep=True).items() + }, + ) diff --git a/src/mlbundle/_sklearn/payload.py b/src/mlbundle/_sklearn/payload.py new file mode 100644 index 0000000..e1ea591 --- /dev/null +++ b/src/mlbundle/_sklearn/payload.py @@ -0,0 +1,247 @@ +"""Bound the nested skops ZIP before skops reads schema or numeric buffers.""" + +from io import BytesIO +from math import prod +from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile + +import numpy as np +from pydantic import JsonValue + +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.errors import InvalidModelError +from mlbundle.limits import ( + MANIFEST_BYTES, + MAX_CLASSES, + MAX_FEATURES, + MAX_OUTPUTS, + ArchiveLimits, +) + + +def validate_payload(data: bytes) -> None: + limits = ArchiveLimits() + with ZipFile(BytesIO(data)) as archive: + entries = archive.infolist() + names = [entry.filename for entry in entries] + if ( + len(names) != len(set(names)) + or "schema.json" not in names + or any("/" in name or "\\" in name or ".." in name for name in names) + or sum(entry.file_size for entry in entries) > limits.max_member_bytes + ): + raise InvalidModelError("Invalid nested skops archive.") + schema_entry = archive.getinfo("schema.json") + if schema_entry.file_size > MANIFEST_BYTES: + raise InvalidModelError("Skops schema exceeds the byte limit.") + for entry in entries: + if ( + entry.flag_bits & 1 + or entry.compress_type not in (ZIP_STORED, ZIP_DEFLATED) + or entry.file_size > limits.max_member_bytes + or entry.file_size > limits.max_compression_ratio * entry.compress_size + ): + raise InvalidModelError("Unsafe nested skops member bounds.") + schema = canonical_loads(archive.read(schema_entry)) + arrays = {name for name in names if name.endswith(".npy")} | _array_references( + schema + ) + for name in arrays: + if name not in names: + raise InvalidModelError("Missing skops numeric array member.") + _array_header(archive, name, archive.getinfo(name).file_size) + _tree_constructors(schema, archive) + + +def _array_references(schema: JsonValue) -> set[str]: + arrays: set[str] = set() + pending = [schema] + while pending: + node = pending.pop() + if isinstance(node, dict): + if node.get("__loader__") == "NdArrayNode" and "file" in node: + name = node["file"] + if not isinstance(name, str): + raise InvalidModelError("Invalid skops numeric array reference.") + arrays.add(name) + pending.extend(node.values()) + elif isinstance(node, list): + pending.extend(node) + return arrays + + +def _array_header( + archive: ZipFile, name: str, size: int +) -> tuple[tuple[int, ...], np.dtype]: + with archive.open(name) as stream: + version = np.lib.format.read_magic(stream) + if version == (1, 0): + shape, _, dtype = np.lib.format.read_array_header_1_0(stream) + elif version == (2, 0): + shape, _, dtype = np.lib.format.read_array_header_2_0(stream) + else: + raise InvalidModelError("Unsupported skops numeric array header.") + if ( + dtype.hasobject + or dtype.itemsize == 0 + or any(dimension < 0 for dimension in shape) + or (prod(shape) * dtype.itemsize != size - stream.tell()) + ): + raise InvalidModelError("Invalid skops numeric array dimensions.") + return shape, dtype + + +def _tree_constructors(schema: JsonValue, archive: ZipFile) -> None: + nodes: list[dict[str, JsonValue]] = [] + memo: dict[int, dict[str, JsonValue]] = {} + variants: dict[int, list[dict[str, JsonValue]]] = {} + pending = [schema] + while pending: + value = pending.pop() + if isinstance(value, dict): + nodes.append(value) + if ( + value.get("__module__") == "sklearn.tree._tree" + and value.get("__class__") == "Tree" + and value.get("__loader__") not in ("TreeNode", "CachedNode") + ): + raise InvalidModelError("Invalid skops native tree loader.") + identity = value.get("__id__") + if ( + "__loader__" in value + and "__id__" in value + and (type(identity) is not int or identity <= 0) + ): + raise InvalidModelError("Invalid skops object identity.") + if type(identity) is int and value.get("__loader__") != "CachedNode": + if identity in memo and ( + value.get("__loader__") != memo[identity].get("__loader__") + or ( + value.get("__loader__") in ("JsonNode", "NdArrayNode") + and canonical_dumps(value) != canonical_dumps(memo[identity]) + ) + ): + raise InvalidModelError("Conflicting skops object references.") + memo[identity] = value + variants.setdefault(identity, []).append(value) + pending.extend(value.values()) + elif isinstance(value, list): + pending.extend(value) + for node in nodes: + if node.get("__loader__") == "TreeNode": + _tree_variants(node, memo, variants, archive) + + +def _tree_variants( + node: dict[str, JsonValue], + memo: dict[int, dict[str, JsonValue]], + variants: dict[int, list[dict[str, JsonValue]]], + archive: ZipFile, +) -> None: + reduced = node.get("__reduce__") + if not isinstance(reduced, dict): + raise InvalidModelError("Invalid skops tree constructor.") + for args in _variants(reduced.get("args"), variants): + for state in _variants(node.get("content"), variants): + _tree_constructor( + {**node, "__reduce__": {"args": args}, "content": state}, memo, archive + ) + + +def _variants( + value: JsonValue, variants: dict[int, list[dict[str, JsonValue]]] +) -> list[dict[str, JsonValue]]: + if not isinstance(value, dict): + raise InvalidModelError("Invalid skops tree constructor schema.") + identity = value.get("__id__") + if type(identity) is int and identity in variants: + return variants[identity] + if value.get("__loader__") == "CachedNode": + raise InvalidModelError("Invalid skops tree constructor reference.") + return [value] + + +def _resolve( + value: JsonValue, memo: dict[int, dict[str, JsonValue]] +) -> dict[str, JsonValue]: + if not isinstance(value, dict): + raise InvalidModelError("Invalid skops tree constructor schema.") + identity = value.get("__id__") + if type(identity) is int and identity in memo: + return memo[identity] + if value.get("__loader__") == "CachedNode": + raise InvalidModelError("Invalid skops tree constructor reference.") + return value + + +def _integer(value: JsonValue, memo: dict[int, dict[str, JsonValue]]) -> int: + node = _resolve(value, memo) + content = node.get("content") + if ( + node.get("__loader__") != "JsonNode" + or node.get("is_json") is not True + or not isinstance(content, str) + ): + raise InvalidModelError("Invalid skops tree constructor integer.") + result = canonical_loads(content.encode()) + if type(result) is not int: + raise InvalidModelError("Invalid skops tree constructor integer.") + return result + + +def _numeric_header( + value: JsonValue, memo: dict[int, dict[str, JsonValue]], archive: ZipFile +) -> tuple[str, tuple[int, ...], np.dtype]: + node = _resolve(value, memo) + name = node.get("file") + if ( + node.get("__loader__") != "NdArrayNode" + or node.get("type") != "numpy" + or not isinstance(name, str) + ): + raise InvalidModelError("Invalid skops tree numeric storage.") + shape, dtype = _array_header(archive, name, archive.getinfo(name).file_size) + return name, shape, dtype + + +def _tree_constructor( + node: dict[str, JsonValue], memo: dict[int, dict[str, JsonValue]], archive: ZipFile +) -> None: + reduced = node.get("__reduce__") + if not isinstance(reduced, dict): + raise InvalidModelError("Invalid skops tree constructor.") + arguments = reduced.get("args") + if not isinstance(arguments, dict): + raise InvalidModelError("Invalid skops tree constructor arguments.") + args = arguments.get("content") + if ( + arguments.get("__loader__") != "TupleNode" + or not isinstance(args, list) + or len(args) != 3 + ): + raise InvalidModelError("Invalid skops tree constructor arguments.") + width, outputs = _integer(args[0], memo), _integer(args[2], memo) + if not 1 <= width <= MAX_FEATURES or not 1 <= outputs <= MAX_OUTPUTS: + raise InvalidModelError("Unsafe skops tree constructor dimensions.") + name, shape, dtype = _numeric_header(args[1], memo, archive) + if shape != (outputs,) or dtype != np.dtype(np.intp): + raise InvalidModelError("Invalid skops tree class-count dimensions.") + classes = np.load(BytesIO(archive.read(name)), allow_pickle=False) + if np.any(classes < 1) or np.any(classes > MAX_CLASSES): + raise InvalidModelError("Unsafe skops tree class counts.") + state = node.get("content") + if not isinstance(state, dict): + raise InvalidModelError("Invalid skops tree state.") + attributes = state.get("content") + if not isinstance(attributes, dict): + raise InvalidModelError("Invalid skops tree state.") + count = _integer(attributes.get("node_count"), memo) + depth = _integer(attributes.get("max_depth"), memo) + _, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, archive) + _, values_shape, _ = _numeric_header(attributes.get("values"), memo, archive) + if ( + count < 1 + or not 0 <= depth < count + or nodes_shape != (count,) + or values_shape != (count, outputs, int(max(classes))) + ): + raise InvalidModelError("Invalid skops tree storage dimensions.") diff --git a/src/mlbundle/_sklearn/prediction.py b/src/mlbundle/_sklearn/prediction.py new file mode 100644 index 0000000..6098489 --- /dev/null +++ b/src/mlbundle/_sklearn/prediction.py @@ -0,0 +1,84 @@ +"""Tabular prediction with explicit output dimensions and probability validation.""" + +import warnings +from typing import Any, cast + +import numpy as np + +from mlbundle.adapters import ProbabilityOutput, RegressionOutput +from mlbundle.claims import ModelClaims +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError + + +class Predictor: + def __init__(self, model: Any, claims: ModelClaims) -> None: + self.model = model + self.claims = claims + + def _data(self, data: object) -> tuple[object, int]: + shape = getattr(data, "shape", None) + if shape is None: + data = np.asarray(data) + shape = data.shape + if len(shape) != 2 or shape[1] != self.claims.n_features: + raise ValueError("Prediction input must have the model's feature width.") + return data, int(shape[0]) + + def _call(self, data: object, *, probabilities: bool) -> Any: + # Positional arrays/lists remain supported for named models; sklearn + # still enforces DataFrame name/order when those names are supplied. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="X does not have valid feature names, but .*", + category=UserWarning, + ) + warnings.filterwarnings( + "ignore", message="X has feature names, but .*", category=UserWarning + ) + return ( + self.model.predict_proba(data) + if probabilities + else self.model.predict(data) + ) + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + data, rows = self._data(data) + result = np.asarray(self._call(data, probabilities=False), dtype=float) + expected = ( + (rows,) if self.claims.n_outputs == 1 else (rows, self.claims.n_outputs) + ) + if self.claims.n_outputs == 1 and result.shape == (rows, 1): + result = result[:, 0] + if result.shape != expected or not np.isfinite(result).all(): + raise InvalidModelError( + "Invalid regression prediction dimensions or values." + ) + return cast(RegressionOutput, result.tolist()) + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + data, rows = self._data(data) + raw = self._call(data, probabilities=True) + matrices = raw if isinstance(raw, list) else [raw] + if not isinstance(matrices, list) or len(matrices) != self.claims.n_outputs: + raise InvalidModelError("Invalid probability output count.") + result: list[list[list[float]]] = [] + for matrix, classes in zip(matrices, self.claims.n_classes or [], strict=True): + values = np.asarray(matrix, dtype=float) + if ( + values.shape != (rows, classes) + or not np.isfinite(values).all() + or np.any(values < 0) + or np.any(values > 1) + or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-7, atol=1e-8) + ): + raise InvalidModelError( + "Invalid probability dimensions, values or row sums." + ) + result.append(values.tolist()) + return result[0] if self.claims.n_outputs == 1 else result diff --git a/src/mlbundle/_sklearn/structure.py b/src/mlbundle/_sklearn/structure.py new file mode 100644 index 0000000..edd6f2c --- /dev/null +++ b/src/mlbundle/_sklearn/structure.py @@ -0,0 +1,212 @@ +"""Unmodified native structure, with tree index validation before native traversal.""" + +from typing import Any, cast + +import numpy as np +from pydantic import JsonValue +from sklearn.base import BaseEstimator, is_classifier +from sklearn.multioutput import RegressorChain +from sklearn.pipeline import Pipeline +from sklearn.tree._tree import Tree + +from mlbundle.canonical import canonical_value +from mlbundle.errors import InvalidModelError, UnsupportedModelError +from mlbundle.limits import MAX_CLASSES, MAX_FEATURES, MAX_OUTPUTS, STRUCTURE_BYTES + +_ARRAYS = ( + "children_left", + "children_right", + "feature", + "threshold", + "value", + "impurity", + "n_node_samples", + "weighted_n_node_samples", +) + + +def validate_tree(tree: Tree) -> None: + count = tree.node_count + if ( + count < 1 + or not 1 <= tree.n_features <= MAX_FEATURES + or not 1 <= tree.n_outputs <= MAX_OUTPUTS + or tree.n_classes.shape != (tree.n_outputs,) + or np.any(tree.n_classes < 1) + or np.any(tree.n_classes > MAX_CLASSES) + or tree.value.shape != (count, tree.n_outputs, int(max(tree.n_classes))) + or any(len(getattr(tree, name)) != count for name in _ARRAYS) + ): + raise InvalidModelError("Invalid sklearn tree dimensions.") + # Visit each node exactly once: reject cycles, aliases, unreachable nodes and + # out-of-range feature/child indices before feature_importances_ or inference. + seen: set[int] = set() + pending = [(0, 0)] + depth = 0 + while pending: + node, level = pending.pop() + if not 0 <= node < count or node in seen: + raise InvalidModelError("Invalid sklearn tree child indices.") + seen.add(node) + depth = max(depth, level) + left, right = int(tree.children_left[node]), int(tree.children_right[node]) + if left == right == -1: + continue + if left < 0 or right < 0 or not 0 <= tree.feature[node] < tree.n_features: + raise InvalidModelError("Invalid sklearn tree feature or child indices.") + pending.extend(((left, level + 1), (right, level + 1))) + if len(seen) != count or depth != tree.max_depth: + raise InvalidModelError("Invalid sklearn tree reachability or depth.") + + +def validate_tree_storage(model: object) -> None: + """Check all tree objects, including trees inside pipelines/meta-estimators.""" + seen: set[int] = set() + pending = [model] + while pending: + value = pending.pop() + if id(value) in seen: + continue + seen.add(id(value)) + if isinstance(value, Tree): + validate_tree(value) + elif isinstance(value, dict): + pending.extend(value.values()) + elif isinstance(value, (list, tuple)): + pending.extend(value) + elif isinstance(value, np.ndarray) and value.dtype.kind == "O": + pending.extend(value.flat) + elif type(value).__module__.startswith("sklearn.") and hasattr( + value, "__dict__" + ): + if isinstance(value, BaseEstimator): + _validate_owner(value) + pending.extend(vars(value).values()) + + +def _width(model: Any) -> int | None: + if not hasattr(model, "n_features_in_"): + return None + width = model.n_features_in_ + if ( + not isinstance(width, (int, np.integer)) + or isinstance(width, (bool, np.bool_)) + or not 1 <= width <= MAX_FEATURES + ): + raise InvalidModelError("Invalid sklearn estimator input width.") + return int(width) + + +def _validate_owner(model: Any) -> None: + width = _width(model) + if hasattr(model, "tree_") and ( + not isinstance(model.tree_, Tree) or model.tree_.n_features != width + ): + raise InvalidModelError( + "Sklearn tree and owning estimator input widths disagree." + ) + if hasattr(model, "tree_") and ( + not isinstance(model.n_outputs_, (int, np.integer)) + or isinstance(model.n_outputs_, (bool, np.bool_)) + or model.n_outputs_ != model.tree_.n_outputs + ): + raise InvalidModelError( + "Sklearn tree and owning estimator output counts disagree." + ) + if is_classifier(model) and ( + hasattr(model, "tree_") or hasattr(model, "estimators_") + ): + _validate_classes(model) + if width is None or not hasattr(model, "estimators_"): + return + estimators = list(np.asarray(model.estimators_, dtype=object).flat) + feature_sets = getattr(model, "estimators_features_", None) + if feature_sets is not None and len(feature_sets) != len(estimators): + raise InvalidModelError("Invalid ensemble feature mappings.") + for index, estimator in enumerate(estimators): + expected = width + index if isinstance(model, RegressorChain) else width + if feature_sets is not None: + features = np.asarray(feature_sets[index]) + if ( + features.ndim != 1 + or features.dtype.kind not in "iu" + or len(features) == 0 + or np.any(features < 0) + or np.any(features >= width) + ): + raise InvalidModelError("Invalid ensemble feature mappings.") + expected = len(features) + actual = _width(estimator) + if actual is not None and actual != expected: + raise InvalidModelError( + "Ensemble and constituent estimator input widths disagree." + ) + + +def _validate_classes(model: Any) -> None: + classes = model.classes_ if isinstance(model.classes_, list) else [model.classes_] + if not classes or any(np.asarray(labels).ndim != 1 for labels in classes): + raise InvalidModelError("Invalid classifier label dimensions.") + counts = np.asarray([len(labels) for labels in classes]) + if hasattr(model, "n_outputs_") and model.n_outputs_ != len(classes): + raise InvalidModelError("Classifier labels and native output counts disagree.") + if hasattr(model, "n_classes_"): + native = np.asarray(model.n_classes_) + if ( + native.dtype.kind not in "iu" + or native.ndim != (0 if len(classes) == 1 else 1) + or not np.array_equal(np.atleast_1d(native), counts) + ): + raise InvalidModelError( + "Classifier labels and native class counts disagree." + ) + if hasattr(model, "tree_") and not np.array_equal(model.tree_.n_classes, counts): + raise InvalidModelError("Classifier labels and tree class counts disagree.") + + +def _tree(tree: Tree) -> dict[str, object]: + return { + "node_count": tree.node_count, + "n_features": tree.n_features, + "n_outputs": tree.n_outputs, + "n_classes": tree.n_classes, + **{name: getattr(tree, name) for name in _ARRAYS}, + } + + +def extract_structure(model: Any) -> dict[str, JsonValue] | None: + # Pipeline internals address transformed inputs; v1 has no transformation + # mapping, so it makes no visualization claim for a pipeline. + if isinstance(model, Pipeline): + return None + validate_tree_storage(model) + result: dict[str, object] = { + "feature_importances": getattr(model, "feature_importances_", None) + } + if hasattr(model, "tree_"): + result.update(family="decision_tree", tree=_tree(model.tree_)) + elif hasattr(model, "estimators_"): + estimators = np.asarray(model.estimators_, dtype=object) + if all(hasattr(estimator, "tree_") for estimator in estimators.flat): + result.update( + family="tree_ensemble", + estimator_shape=estimators.shape, + trees=[_tree(estimator.tree_) for estimator in estimators.flat], + ) + elif any(hasattr(estimator, "tree_") for estimator in estimators.flat): + raise UnsupportedModelError( + reason="Mixed tree/non-tree ensembles have no supported structure." + ) + if ( + "family" not in result + and hasattr(model, "coef_") + and hasattr(model, "intercept_") + ): + result.update(family="linear", coef=model.coef_, intercept=model.intercept_) + if "family" not in result: + if result["feature_importances"] is None: + return None + result["family"] = "feature_importances" + return cast( + dict[str, JsonValue], canonical_value(result, max_bytes=STRUCTURE_BYTES) + ) diff --git a/src/mlbundle/canonical.py b/src/mlbundle/canonical.py index aaa6ce2..e5ad881 100644 --- a/src/mlbundle/canonical.py +++ b/src/mlbundle/canonical.py @@ -151,3 +151,14 @@ def canonical_loads( except (ValueError, TypeError, UnicodeError, RecursionError): raise InvalidJSONError from None return cast(JsonValue, value) + + +def canonical_value( + value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> JsonValue: + """Project native scalars/arrays to the same bounded JSON used for comparison.""" + return canonical_loads( + canonical_dumps(value, max_bytes=max_bytes, max_depth=max_depth), + max_bytes=max_bytes, + max_depth=max_depth, + ) diff --git a/src/mlbundle/container.py b/src/mlbundle/container.py index 590a903..9f26e1a 100644 --- a/src/mlbundle/container.py +++ b/src/mlbundle/container.py @@ -1,4 +1,4 @@ -"""The validated byte boundary used by inspection and future model APIs.""" +"""The validated byte boundary shared by inspection and model APIs.""" import struct import zlib diff --git a/src/mlbundle/errors.py b/src/mlbundle/errors.py index c1e260b..4a6e54a 100644 --- a/src/mlbundle/errors.py +++ b/src/mlbundle/errors.py @@ -51,18 +51,27 @@ def __init__(self, claim: str) -> None: class UnsupportedModelError(MLBundleError): - def __init__(self, untrusted_types: tuple[str, ...] = ()) -> None: + def __init__( + self, untrusted_types: tuple[str, ...] = (), *, reason: str | None = None + ) -> None: self.untrusted_types = untrusted_types names = ", ".join(_identifier(name) for name in untrusted_types[:20]) super().__init__( - f"Unsupported model ({len(untrusted_types)} untrusted types): {names}." + reason + or f"Unsupported model ({len(untrusted_types)} untrusted types): {names}." ) +class InvalidModelError(MLBundleError): + """Native model content is malformed or violates the adapter contract.""" + + class MissingOptionalDependencyError(MLBundleError): def __init__(self, extra: str) -> None: self.extra = _identifier(extra) - super().__init__(f"Install mlbundle[{self.extra}] for this adapter.") + super().__init__( + f'Install this adapter with pip install "mlbundle[{self.extra}]".' + ) class AdapterNotImplementedError(MLBundleError): diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py new file mode 100644 index 0000000..80c30e5 --- /dev/null +++ b/src/mlbundle/model_io.py @@ -0,0 +1,173 @@ +"""Shared export and validating load; adapters own all native-model behavior.""" + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from importlib.metadata import version +from os import PathLike +from typing import cast +from uuid import uuid4 + +from pydantic import JsonValue + +from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits +from .canonical import canonical_dumps, canonical_loads +from .claims import ModelClaims, verify_claims, verify_structure +from .container import read_container +from .contract import ( + FORMAT_NAME, + FORMAT_VERSION, + MODEL_MEMBERS, + FeatureOrigin, + Library, + MemberName, + SerializationFormat, +) +from .environment import capture_environment +from .errors import ClaimMismatchError, InvalidModelError, LimitExceededError +from .inspection import inspect +from .limits import ArchiveLimits +from .registry import find_adapter, get_adapter +from .results import LoadedBundle, MetadataView +from .writing import write_container + + +def _manifest_parts( + claims: ModelClaims, + library: Library, + serialization: SerializationFormat, + *, + structure: dict[str, JsonValue] | None, + feature_names: Sequence[str] | None, + target_name: str | None, + metadata: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + names = claims.feature_names + origin = FeatureOrigin.MODEL + if names is None: + origin = ( + FeatureOrigin.DATAFRAME + if feature_names is not None + else FeatureOrigin.GENERATED + ) + names = ( + list(feature_names) + if feature_names is not None + else [f"feature_{index}" for index in range(1, claims.n_features + 1)] + ) + model = claims.model_dump( + mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} + ) + model.update(library=library.value, serialization=serialization.value) + return { + "format_name": FORMAT_NAME, + "format_version": FORMAT_VERSION, + "bundle_id": str(uuid4()), + "created_at": datetime.now(UTC).isoformat(), + "writer_version": version("mlbundle"), + "model": model, + "features": { + "names": names, + "dtypes": [None] * claims.n_features, + "model_dtypes": claims.feature_dtypes, + "origin": origin.value, + }, + "target": {"class_labels": claims.class_labels, "name": target_name}, + "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), + "references": { + "structure": None if structure is None else MemberName.STRUCTURE.value + }, + "user_metadata": {} if metadata is None else dict(metadata), + } + + +def export( + model: object, + destination: str | PathLike[str], + *, + feature_names: Sequence[str] | None = None, + target_name: str | None = None, + metadata: Mapping[str, JsonValue] | None = None, + overwrite: bool = False, + limits: ArchiveLimits | None = None, +) -> MetadataView: + """Publish a native bundle only after its claims survive serialization/reloading. + + Model-exposed feature names take precedence over supplied names. Otherwise + supplied names are declarations; omitted names become feature_1 … feature_N. + No training rows, schema or sample data are included by this API. + """ + selected = effective_limits(limits) + adapter = find_adapter(model) + data = adapter.serialize(model) + if len(data) > selected.member_bytes(adapter.model_member): + raise LimitExceededError("Model member size limit exceeded.") + claims = adapter.extract_claims(model) + structure = adapter.extract_structure(model) + restored = adapter.deserialize(data) + verify_claims(claims, adapter.extract_claims(restored)) + verify_structure(structure, adapter.extract_structure(restored)) + members = {adapter.model_member: data} + if structure is not None: + members[MemberName.STRUCTURE] = canonical_dumps( + structure, max_bytes=selected.member_bytes(MemberName.STRUCTURE) + ) + parts = _manifest_parts( + claims, + adapter.library, + MODEL_MEMBERS[adapter.library][0], + structure=structure, + feature_names=feature_names, + target_name=target_name, + metadata=metadata, + ) + result = write_container( + parts, members, destination=destination, overwrite=overwrite, limits=selected + ) + return inspect(result, limits=selected) + + +def _optional_json( + members: dict[MemberName, bytes], name: MemberName, limits: ArchiveLimits +) -> JsonValue: + if name not in members: + return None + return canonical_loads(members[name], max_bytes=limits.member_bytes(name)) + + +def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> LoadedBundle: + """Verify format, integrity and recomputed native claims before returning a model.""" + selected = effective_limits(limits) + manifest, members = read_container(source, limits=selected) + adapter = get_adapter(manifest.model.library) + model = adapter.deserialize(members[adapter.model_member]) + verify_claims(manifest.verified_claims(), adapter.extract_claims(model)) + structure = _optional_json(members, MemberName.STRUCTURE, selected) + extracted = adapter.extract_structure(model) + if (MemberName.STRUCTURE in members) != (extracted is not None): + raise ClaimMismatchError("structure") + verify_structure(structure, extracted) + schema = _optional_json(members, MemberName.SCHEMA, selected) + samples = _optional_json(members, MemberName.SAMPLES, selected) + if MemberName.SCHEMA in members and ( + not isinstance(schema, dict) + or not isinstance(schema.get("fields"), list) + or not isinstance(schema.get("reports"), list) + ): + raise InvalidModelError("Schema member must contain fields and reports arrays.") + if MemberName.SAMPLES in members and ( + not isinstance(samples, list) + or any( + not isinstance(row, dict) or set(row) != set(manifest.features.names) + for row in samples + ) + ): + raise InvalidModelError("Samples member must contain row objects.") + return LoadedBundle( + manifest=manifest, + members=tuple(name for name in MEMBER_ORDER if name in members), + native_model=model, + predictor=adapter.predictor(model), + schema=cast(dict[str, JsonValue] | None, schema), + structure=cast(dict[str, JsonValue] | None, structure), + samples=cast(list[dict[str, JsonValue]] | None, samples), + ) diff --git a/src/mlbundle/registry.py b/src/mlbundle/registry.py index 0cc2fd0..cf159d2 100644 --- a/src/mlbundle/registry.py +++ b/src/mlbundle/registry.py @@ -1,6 +1,7 @@ """Only mlbundle-owned module paths can select an adapter.""" from importlib import import_module +from os import PathLike from types import MappingProxyType from typing import cast @@ -45,3 +46,27 @@ def get_adapter(library: Library) -> Adapter: if adapter.library != library or adapter.model_member != MODEL_MEMBERS[library][1]: raise UnsupportedModelError return adapter + + +def find_adapter(model: object) -> Adapter: + """Ask every available closed adapter; preserve dependency errors if none matches.""" + missing: dict[Library, MissingOptionalDependencyError] = {} + for library in Library: + try: + adapter = get_adapter(library) + except AdapterNotImplementedError: + continue + except MissingOptionalDependencyError as error: + missing[library] = error + continue + if adapter.recognizes(model): + return adapter + # Class ancestry only identifies which missing extra to report; it never + # selects an import path, adapter or estimator constructor. + roots = {base.__module__.split(".")[0] for base in type(model).__mro__} + for library, error in missing.items(): + if library.value in roots or ( + library == Library.ONNX and isinstance(model, (bytes, str, PathLike)) + ): + raise error + raise UnsupportedModelError diff --git a/src/mlbundle/results.py b/src/mlbundle/results.py index 522b1c5..49c55ec 100644 --- a/src/mlbundle/results.py +++ b/src/mlbundle/results.py @@ -1,4 +1,4 @@ -"""Stable metadata-only and loaded result contracts for future readers.""" +"""Metadata-only and fully validated native-model result contracts.""" from dataclasses import dataclass, field diff --git a/src/mlbundle/writing.py b/src/mlbundle/writing.py index 1d3bf86..a1d552b 100644 --- a/src/mlbundle/writing.py +++ b/src/mlbundle/writing.py @@ -1,5 +1,6 @@ """Deterministic container writing; the writer owns integrity records.""" +import errno import os import stat import tempfile @@ -68,12 +69,46 @@ def _atomic_write(destination: Path, data: bytes, overwrite: bool) -> None: temporary.replace(destination) else: # Publish the complete file without a check/replace race. - os.link(temporary, destination) + try: + os.link(temporary, destination) + except OSError as error: + unsupported = { + errno.ENOSYS, + errno.ENOTSUP, + errno.EOPNOTSUPP, + errno.EPERM, + errno.EXDEV, + } + if error.errno not in unsupported and getattr( + error, "winerror", None + ) not in (1, 50, 1314): + raise + _exclusive_write(destination, data) finally: if temporary is not None: temporary.unlink(missing_ok=True) +def _exclusive_write(destination: Path, data: bytes) -> None: + # Filesystems without hard links still get exclusive ownership; remove our + # destination on every failure, never a competing writer's existing file. + descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + stream = os.fdopen(descriptor, "wb") + except BaseException: + os.close(descriptor) + destination.unlink(missing_ok=True) + raise + try: + with stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + destination.unlink(missing_ok=True) + raise + + def _compress(data: bytes, cap: int) -> bytes: # RLE avoids the platform-specific match searches of zlib versus zlib-ng. encoder = zlib.compressobj(6, zlib.DEFLATED, -15, 8, zlib.Z_RLE) diff --git a/tests/test_errors.py b/tests/test_errors.py index 487a1e7..84167a2 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -12,6 +12,7 @@ ("LimitExceededError", ("Limit exceeded.",)), ("UnsupportedFormatVersionError", ()), ("InvalidManifestError", ()), + ("InvalidModelError", ("Invalid native model.",)), ("InvalidJSONError", ()), ("IntegrityMismatchError", ("model.ubj",)), ("ClaimMismatchError", ("n_features",)), diff --git a/tests/test_loading.py b/tests/test_loading.py new file mode 100644 index 0000000..3da17d1 --- /dev/null +++ b/tests/test_loading.py @@ -0,0 +1,257 @@ +"""Validating load: success, lying claims and hostile native payloads together.""" + +import io +import json +import sys +import zipfile + +import pandas as pd +import pytest +import skops.io as sio +from sklearn.datasets import load_iris +from sklearn.ensemble import RandomForestClassifier +from sklearn.linear_model import Ridge +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor + +import mlbundle +from mlbundle import ( + ClaimMismatchError, + InvalidModelError, + MemberName, + UnsupportedModelError, +) +from mlbundle._sklearn import Adapter +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +@pytest.fixture +def data(): + return load_iris(return_X_y=True) + + +def test_load_sources_and_native_predictions(data, tmp_path): + x, y = data + path = tmp_path / "tree.mlbundle" + mlbundle.export(DecisionTreeClassifier(random_state=7).fit(x, y), path) + for source in [path, str(path), path.read_bytes(), io.BytesIO(path.read_bytes())]: + loaded = mlbundle.load(source) + assert isinstance(loaded.native_model, DecisionTreeClassifier) + assert (loaded.native_model.predict(x) == y).all() + assert loaded.structure["family"] == "decision_tree" + + +def _rewrite(path, change): + manifest, members = read_container(path) + value = manifest.model_dump(mode="json", by_alias=True) + del members[MemberName.MANIFEST] + change(value, members) + return write_container(value, members) + + +@pytest.mark.parametrize( + "change,claim", + [ + (lambda m, _: m["model"].update(estimator_class="Ridge"), "estimator_class"), + ( + lambda m, _: ( + m["model"].update(kind="regressor", n_classes=None), + m["target"].update(class_labels=None), + ), + "kind", + ), + (lambda m, _: m["target"].update(class_labels=[[2, 1, 0]]), "class_labels"), + ( + lambda m, _: m["target"].update(class_labels=[[0, 1, "renamed"]]), + "class_labels", + ), + (lambda m, _: m["target"].update(class_labels=[[0, "1", 2]]), "class_labels"), + ( + lambda m, _: ( + m["model"].update(n_features=3), + m["features"].update(names=list("abc"), dtypes=[None] * 3), + ), + "n_features", + ), + (lambda m, _: m["features"].update(names=list("dcba")), "feature_names"), + (lambda m, _: m["features"].update(origin="dataframe"), "feature_names"), + ( + lambda m, _: m["features"].update(model_dtypes=["float64"] * 4), + "feature_dtypes", + ), + ( + lambda m, _: m["model"]["hyperparameters"].update(max_depth=1), + "hyperparameters", + ), + ( + lambda m, b: ( + m["references"].update(structure=None), + b.pop(MemberName.STRUCTURE), + ), + "structure", + ), + ], +) +def test_lying_manifest(change, claim, data, tmp_path): + x, y = data + path = tmp_path / "tree.mlbundle" + mlbundle.export( + DecisionTreeClassifier().fit(pd.DataFrame(x, columns=list("abcd")), y), path + ) + with pytest.raises(ClaimMismatchError) as error: + mlbundle.load(_rewrite(path, change)) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "model,alter", + [ + ( + DecisionTreeClassifier(), + lambda s: s["tree"]["threshold"].__setitem__(0, 99.0), + ), + ( + DecisionTreeRegressor(), + lambda s: s["tree"]["value"][-1][0].__setitem__(0, 99.0), + ), + (Ridge(), lambda s: s["coef"].__setitem__(0, 99.0)), + (RandomForestClassifier(n_estimators=3), lambda s: s["trees"].pop()), + ], +) +def test_lying_structure(model, alter, data, tmp_path): + x, y = data + path = tmp_path / "model.mlbundle" + mlbundle.export(model.fit(x, y), path) + + def change(_, members): + structure = canonical_loads(members[MemberName.STRUCTURE]) + alter(structure) + members[MemberName.STRUCTURE] = canonical_dumps(structure) + + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(_rewrite(path, change)) + + +def test_added_structure_and_swapped_model(data, tmp_path): + x, y = data + path = tmp_path / "pipeline.mlbundle" + model = Pipeline( + [("scale", StandardScaler()), ("tree", DecisionTreeClassifier())] + ).fit(x, y) + mlbundle.export(model, path) + + def added(m, b): + m["references"]["structure"] = "structure.json" + b[MemberName.STRUCTURE] = b"{}" + + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(_rewrite(path, added)) + path = tmp_path / "tree.mlbundle" + mlbundle.export(DecisionTreeClassifier(max_depth=1).fit(x, y), path) + different = Adapter().serialize(DecisionTreeClassifier(max_depth=2).fit(x, y)) + with pytest.raises(ClaimMismatchError, match="hyperparameters"): + mlbundle.load( + _rewrite(path, lambda _, b: b.update({MemberName.SKOPS: different})) + ) + + +@pytest.mark.parametrize( + "module,name", [("os", "system"), ("builtins", "eval"), ("subprocess", "Popen")] +) +def test_hostile_payload(module, name, data, tmp_path): + x, y = data + path, sentinel = tmp_path / "tree.mlbundle", tmp_path / "sentinel" + mlbundle.export(DecisionTreeClassifier().fit(x, y), path) + action = f"__import__('pathlib').Path({str(sentinel)!r}).touch()" + argument = f'echo attack > "{sentinel}"' if name == "system" else action + if name == "Popen": + argument = json.dumps([sys.executable, "-c", action]) + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as z: + z.writestr( + "schema.json", + json.dumps( + { + "__module__": module, + "__class__": name, + "__loader__": "ConstructorFromReduceNode", + "content": { + "__module__": "builtins", + "__class__": "tuple", + "__loader__": "TupleNode", + "content": [ + { + "__module__": "builtins", + "__class__": "str", + "__loader__": "JsonNode", + "content": argument, + "is_json": name == "Popen", + } + ], + }, + "protocol": 2, + } + ), + ) + with pytest.raises(UnsupportedModelError, match=name): + mlbundle.load( + _rewrite( + path, lambda _, b: b.update({MemberName.SKOPS: payload.getvalue()}) + ) + ) + assert not sentinel.exists() + + +@pytest.mark.parametrize( + "payload,error", + [ + (b"not a zip", InvalidModelError), + (sio.dumps({"not": "estimator"}), UnsupportedModelError), + ], +) +def test_invalid_or_non_estimator_payload(payload, error, data, tmp_path): + x, y = data + path = tmp_path / "tree.mlbundle" + mlbundle.export(DecisionTreeClassifier().fit(x, y), path) + with pytest.raises(error): + mlbundle.load( + _rewrite(path, lambda _, b: b.update({MemberName.SKOPS: payload})) + ) + + +@pytest.mark.parametrize( + "member,value,error", + [ + (MemberName.STRUCTURE, None, ClaimMismatchError), + (MemberName.SCHEMA, [], InvalidModelError), + (MemberName.SAMPLES, [1], InvalidModelError), + (MemberName.SAMPLES, [{}], InvalidModelError), + (MemberName.SAMPLES, [{"feature_1": 0, "target": 1}], InvalidModelError), + (MemberName.SAMPLES, [{"feature_1": 0, "other": 1}], InvalidModelError), + (MemberName.SCHEMA, {"fields": [], "reports": []}, None), + (MemberName.SAMPLES, [{"feature_1": 0}], None), + ], +) +def test_optional_members(member, value, error, tmp_path): + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import StandardScaler + + model = Pipeline( + [("scale", StandardScaler()), ("tree", DecisionTreeClassifier())] + ).fit([[0], [1]], [0, 1]) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["references"][member.value[:-5]] = member.value + del members[MemberName.MANIFEST] + members[member] = canonical_dumps(value) + bundle = write_container(parts, members) + if error is not None: + with pytest.raises(error): + mlbundle.load(bundle) + else: + assert getattr(mlbundle.load(bundle), member.value[:-5]) == value diff --git a/tests/test_model_io.py b/tests/test_model_io.py new file mode 100644 index 0000000..d91e41c --- /dev/null +++ b/tests/test_model_io.py @@ -0,0 +1,143 @@ +"""Library-independent pipelines, dependency failures and export-time verification.""" + +import pytest +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import ( + ArchiveLimits, + ClaimMismatchError, + InvalidManifestError, + Library, + MemberName, + MissingOptionalDependencyError, + UnsupportedModelError, + registry, +) +from mlbundle._sklearn import Adapter +from mlbundle.canonical import canonical_dumps +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +def test_basic_export_errors_and_options(tmp_path, monkeypatch): + target = tmp_path / "tree.mlbundle" + for model, match in [ + (object(), "Unsupported"), + (DecisionTreeClassifier(), "fitted"), + ]: + with pytest.raises(UnsupportedModelError, match=match): + mlbundle.export(model, target) + model = DecisionTreeClassifier().fit([[0, 1], [1, 0]], [0, 1]) + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export(model, target, limits=ArchiveLimits(max_member_bytes=1)) + assert not target.exists() + for names in [["only-one"], ["same", "same"]]: + with pytest.raises(InvalidManifestError): + mlbundle.export(model, target, feature_names=names) + mlbundle.export(model, target, feature_names=["a", "b"]) + with pytest.raises(FileExistsError): + mlbundle.export(model, target) + mlbundle.export(model, target, overwrite=True) + assert mlbundle.load(target).feature_origin == "generated" + + def missing(path): + raise ModuleNotFoundError(name="skops" if path == "mlbundle._sklearn" else path) + + monkeypatch.setattr(registry, "import_module", missing) + for action in [ + lambda: mlbundle.export(model, target), + lambda: mlbundle.load(target), + ]: + with pytest.raises( + MissingOptionalDependencyError, match='pip install "mlbundle\\[sklearn\\]"' + ): + action() + + +@pytest.mark.parametrize("structure", [False, True]) +def test_export_roundtrip_rejects_loss(tmp_path, monkeypatch, structure): + model = DecisionTreeClassifier().fit([[0], [1]], [0, 1]) + original = Adapter.deserialize + + def changed(self, data): + restored = original(self, data) + if structure: + restored.tree_.threshold[0] += 1 + else: + restored.max_depth = 99 + return restored + + monkeypatch.setattr(Adapter, "deserialize", changed) + with pytest.raises( + ClaimMismatchError, match="structure" if structure else "hyperparameters" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("library", list(Library)) +def test_closed_adapter_pipeline_is_library_agnostic( + library, tmp_path, monkeypatch, manifest_data +): + from types import SimpleNamespace + + from mlbundle import Manifest, OnnxInfo + from mlbundle.contract import MODEL_MEMBERS + + claims = Manifest.from_bytes(canonical_dumps(manifest_data)).verified_claims() + graph = ( + OnnxInfo( + opsets=[mlbundle.Opset(domain="", version=21)], + ir_version=10, + producer_name="test", + producer_version="1", + ) + if library == Library.ONNX + else None + ) + claims = claims.model_copy(update={"onnx": graph}) + + class FakeAdapter: + model_member = MODEL_MEMBERS[library][1] + + def recognizes(self, model): + return model == "native input" + + def serialize(self, model): + return b"native format bytes" + + def deserialize(self, data): + assert data == b"native format bytes" + return "native input" + + def extract_claims(self, model): + return claims + + def extract_structure(self, model): + return None + + def predictor(self, model): + return SimpleNamespace(predict_proba=lambda data: [[0.2, 0.8]]) + + FakeAdapter.library = library + + def resolve(path): + if path != f"mlbundle._{library.value}": + raise ModuleNotFoundError(name=path) + return SimpleNamespace(Adapter=FakeAdapter) + + monkeypatch.setattr(registry, "import_module", resolve) + path = tmp_path / "bundle.mlbundle" + mlbundle.export("native input", path) + loaded = mlbundle.load(path) + assert loaded.library == library and loaded.native_model == "native input" + assert loaded.manifest.environment.onnx == graph + assert loaded.predict_proba([[1, 2]]) == [[0.2, 0.8]] + if graph is not None: + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["environment"]["onnx"]["ir_version"] = 11 + del members[MemberName.MANIFEST] + with pytest.raises(ClaimMismatchError, match="onnx"): + mlbundle.load(write_container(parts, members)) diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py new file mode 100644 index 0000000..7a17e56 --- /dev/null +++ b/tests/test_sklearn.py @@ -0,0 +1,296 @@ +"""Sklearn native round trips, claimed metadata and hostile model payloads.""" + +import subprocess +import sys +import warnings + +import numpy as np +import pandas as pd +import pytest +import skops.io as sio +from sklearn.datasets import load_iris +from sklearn.ensemble import ( + ExtraTreesClassifier, + ExtraTreesRegressor, + GradientBoostingClassifier, + GradientBoostingRegressor, + HistGradientBoostingClassifier, + RandomForestClassifier, + RandomForestRegressor, +) +from sklearn.linear_model import LinearRegression, LogisticRegression, Ridge +from sklearn.multioutput import ( + ClassifierChain, + MultiOutputClassifier, + MultiOutputRegressor, +) +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, StandardScaler +from sklearn.svm import LinearSVC +from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor +from xgboost import XGBClassifier + +import mlbundle +from mlbundle import InvalidModelError, UnsupportedModelError +from mlbundle._sklearn import TRUSTED_TYPES, Adapter +from mlbundle.canonical import canonical_dumps + + +class CustomTree(DecisionTreeClassifier): + pass + + +def user_function(data): + return data + + +@pytest.fixture +def data(): + return load_iris(return_X_y=True) + + +@pytest.mark.parametrize( + "model", + [ + DecisionTreeClassifier(random_state=1), + DecisionTreeRegressor(random_state=1), + RandomForestClassifier(n_estimators=3, random_state=1), + RandomForestRegressor(n_estimators=3, random_state=1), + ExtraTreesClassifier(n_estimators=3, random_state=1), + ExtraTreesRegressor(n_estimators=3, random_state=1), + GradientBoostingClassifier(n_estimators=3, random_state=1), + GradientBoostingRegressor(n_estimators=3, random_state=1), + LogisticRegression(max_iter=400), + LinearRegression(), + Ridge(), + Pipeline( + [ + ("scale", StandardScaler()), + ("classifier", LogisticRegression(max_iter=400)), + ] + ), + ], +) +def test_roundtrip(model, data, tmp_path): + x, y = data + model.fit(x, y) + path = tmp_path / "model.mlbundle" + view = mlbundle.export(model, path, target_name="species", metadata={"run": 1}) + loaded = mlbundle.load(path) + assert type(loaded.native_model) is type(model) + assert np.array_equal(model.predict(x), loaded.native_model.predict(x)) + method = "predict_proba" if loaded.kind == "classifier" else "predict" + assert np.array_equal( + getattr(model, method)(x), getattr(loaded, method)(x.tolist()) + ) + adapter = Adapter() + assert view.manifest == loaded.manifest + assert loaded.manifest.verified_claims() == adapter.extract_claims(model) + assert canonical_dumps(loaded.structure) == canonical_dumps( + adapter.extract_structure(model) + ) + assert loaded.feature_names == tuple(f"feature_{i}" for i in range(1, 5)) + assert ( + loaded.manifest.target.name == "species" + and loaded.manifest.user_metadata == {"run": 1} + ) + + +@pytest.mark.parametrize( + "labels", [[False, True], [1, 2], ["no", "yes"], ["a", "b", "c"]] +) +def test_label_types_and_names(labels, data, tmp_path): + x, y = data + y = np.asarray(labels)[y % len(labels)] + frame = pd.DataFrame(x, columns=list("abcd")) + model = LogisticRegression(max_iter=400).fit(frame, y) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path, feature_names=["ignored"]) + loaded = mlbundle.load(path) + assert loaded.feature_origin == "model" and loaded.feature_names == tuple("abcd") + assert loaded.class_labels == [model.classes_.tolist()] + assert type(loaded.class_labels[0][0]) is type(labels[0]) + for value in [frame, x, x.tolist()]: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="X does not have valid feature names, but .*", + category=UserWarning, + ) + expected = model.predict_proba(value) + assert np.array_equal(loaded.predict_proba(value), expected) + + +@pytest.mark.parametrize("classifier", [True, False]) +def test_multioutput(classifier, data, tmp_path): + x, y = data + model = DecisionTreeClassifier() if classifier else LinearRegression() + model.fit(x, np.column_stack([y, y % 2])) + path = tmp_path / "multi.mlbundle" + mlbundle.export(model, path, feature_names=list("abcd")) + loaded = mlbundle.load(path) + assert loaded.manifest.model.n_outputs == 2 and loaded.feature_origin == "dataframe" + if classifier: + assert loaded.class_labels == [[0, 1, 2], [0, 1]] + assert all( + np.array_equal(a, b) + for a, b in zip( + loaded.predict_proba(x), model.predict_proba(x), strict=True + ) + ) + else: + assert np.array_equal(loaded.predict(x), model.predict(x)) + + +@pytest.mark.parametrize( + "model", + [ + MultiOutputClassifier(DecisionTreeClassifier()), + MultiOutputRegressor(Ridge()), + ], +) +def test_multioutput_wrappers(model, data, tmp_path): + x, y = data + model.fit(x, np.column_stack([y, y % 2])) + path = tmp_path / "multi.mlbundle" + mlbundle.export(model, path) + assert mlbundle.load(path).manifest.model.n_outputs == 2 + + +def test_multilabel_chain_is_rejected(data, tmp_path): + x, y = data + model = ClassifierChain(LogisticRegression()).fit( + x, np.column_stack([y % 2, y % 2]) + ) + with pytest.raises(UnsupportedModelError, match="output-major"): + mlbundle.export(model, tmp_path / "bad.mlbundle") + + +@pytest.mark.parametrize( + "model,match", + [ + (CustomTree(), "CustomTree"), + ( + Pipeline( + [ + ("function", FunctionTransformer(user_function)), + ("tree", DecisionTreeClassifier()), + ] + ), + "user_function", + ), + (LinearSVC(), "predict_proba"), + (HistGradientBoostingClassifier(), "TreePredictor"), + (Pipeline([("xgb", XGBClassifier(n_estimators=2, n_jobs=1))]), "xgboost"), + ], +) +def test_export_rejections(model, match, data, tmp_path): + x, y = data + model.fit(x, y) + with pytest.raises(UnsupportedModelError, match=match): + mlbundle.export(model, tmp_path / "bad.mlbundle") + assert not (tmp_path / "bad.mlbundle").exists() + + +def test_import_and_inspect_are_lazy(data, tmp_path): + x, y = data + path = tmp_path / "tree.mlbundle" + mlbundle.export(DecisionTreeClassifier().fit(x, y), path) + code = "import sys,mlbundle; mlbundle.inspect(sys.argv[1]); assert not {'sklearn','skops','numpy','pandas','xgboost','onnx','onnxruntime'} & sys.modules.keys(); print('lazy import and inspect: OK')" + result = subprocess.run( + [sys.executable, "-c", code, str(path)], + capture_output=True, + text=True, + check=True, + ) + assert "lazy import and inspect: OK" in result.stdout + + +def test_fixed_trust_and_unsafe_tree(data, monkeypatch): + x, y = data + model = DecisionTreeClassifier().fit(x, y) + original, seen = sio.loads, [] + + def loads(data, *, trusted): + seen.append(trusted) + return original(data, trusted=trusted) + + monkeypatch.setattr(sio, "loads", loads) + Adapter().deserialize(sio.dumps(model)) + assert seen == [list(TRUSTED_TYPES)] + model.tree_.children_left[0] = 1_000_000 + with pytest.raises(InvalidModelError, match="indices"): + Adapter().deserialize(sio.dumps(model)) + + +@pytest.mark.parametrize("invalid", [np.nan, -0.1, 1.1, 0.4]) +def test_prediction_validation(invalid, monkeypatch): + model = DecisionTreeClassifier().fit([[0], [1]], [0, 1]) + predictor = Adapter().predictor(model) + monkeypatch.setattr( + model, "predict_proba", lambda data: [[invalid, 0.0]] * len(data) + ) + with pytest.raises(InvalidModelError, match="probability"): + predictor.predict_proba([[0]]) + with pytest.raises(ValueError, match="width"): + predictor.predict_proba([[0, 1]]) + with pytest.raises(ValueError, match="regressor"): + predictor.predict([[0]]) + from sklearn.linear_model import Ridge + + regressor = Ridge().fit([[0], [1]], [0, 1]) + prediction = Adapter().predictor(regressor) + monkeypatch.setattr(regressor, "predict", lambda data: [np.inf] * len(data)) + with pytest.raises(InvalidModelError, match="regression"): + prediction.predict([[0]]) + with pytest.raises(ValueError, match="classifier"): + prediction.predict_proba([[0]]) + + +def test_parameter_projection(tmp_path): + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import FunctionTransformer + + from mlbundle._sklearn.parameters import _parameter + + state = np.random.RandomState(7) + model = DecisionTreeClassifier(random_state=state).fit([[0], [1]], [0, 1]) + path = tmp_path / "state.mlbundle" + mlbundle.export(model, path) + assert ( + "$random_state" + in mlbundle.load(path).manifest.model.hyperparameters["random_state"] + ) + assert _parameter((np.float32(1.5), np.array([1, 2]), None), frozenset()) == [ + 1.5, + [1, 2], + None, + ] + for value in [float, lambda x: x, {1: "bad"}]: + with pytest.raises(UnsupportedModelError): + _parameter(value, frozenset()) + cyclic = [] + cyclic.append(cyclic) + with pytest.raises(UnsupportedModelError, match="Cyclic"): + _parameter(cyclic, frozenset()) + model = Pipeline( + [("identity", FunctionTransformer()), ("tree", DecisionTreeClassifier())] + ).fit([[0], [1]], [0, 1]) + path = tmp_path / "pipeline.mlbundle" + mlbundle.export(model, path) + params = mlbundle.load(path).manifest.model.hyperparameters + assert params["tree"]["$estimator"]["class"] == "DecisionTreeClassifier" + assert "tree__max_depth" in params + + +@pytest.mark.parametrize("model", [DecisionTreeClassifier(), DecisionTreeRegressor()]) +def test_sparse_predictions(model): + from scipy.sparse import csr_matrix + + model.fit([[0], [1]], [0, 1]) + predictor = Adapter().predictor(model) + method = "predict_proba" if isinstance(model, DecisionTreeClassifier) else "predict" + assert np.array_equal( + getattr(predictor, method)(csr_matrix([[0], [1]])), + getattr(model, method)([[0], [1]]), + ) diff --git a/tests/test_skops_payload.py b/tests/test_skops_payload.py new file mode 100644 index 0000000..25c1161 --- /dev/null +++ b/tests/test_skops_payload.py @@ -0,0 +1,230 @@ +"""Nested skops bounds: accepted numeric buffers and malformed/rejection cases.""" + +import io +import json +import warnings +import zipfile +from copy import deepcopy + +import numpy as np +import pytest +import skops.io as sio +from sklearn.ensemble import BaggingClassifier, RandomForestClassifier +from sklearn.linear_model import Ridge +from sklearn.tree import DecisionTreeClassifier + +from mlbundle import InvalidModelError, MLBundleError +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.payload import validate_payload + + +def _archive(entries, *, compressed=False): + buffer = io.BytesIO() + with ( + warnings.catch_warnings(), + zipfile.ZipFile( + buffer, + "w", + compression=zipfile.ZIP_DEFLATED if compressed else zipfile.ZIP_STORED, + ) as archive, + ): + warnings.filterwarnings( + "ignore", message="Duplicate name:.*", category=UserWarning + ) + for name, data in entries: + archive.writestr(name, data) + return buffer.getvalue() + + +def _array(*, version=(1, 0), shape=(2,), objects=False): + buffer = io.BytesIO() + header = { + "descr": "|O" if objects else " Date: Mon, 12 Oct 2026 00:33:03 +0200 Subject: [PATCH 04/15] feat: add native XGBoost adapter (#5) --- AGENTS.md | 28 +- README.md | 43 +- SECURITY.md | 34 +- docs/format.md | 113 +++++- src/mlbundle/_sklearn/__init__.py | 8 + src/mlbundle/_xgboost/__init__.py | 234 +++++++++++ src/mlbundle/_xgboost/native.py | 187 +++++++++ src/mlbundle/_xgboost/prediction.py | 66 +++ src/mlbundle/_xgboost/structure.py | 39 ++ src/mlbundle/_xgboost/ubjson.py | 107 +++++ src/mlbundle/adapters.py | 4 + src/mlbundle/claims.py | 4 +- src/mlbundle/declarations.py | 68 ++++ src/mlbundle/model_io.py | 30 +- tests/test_model_io.py | 89 ++++- tests/test_registry.py | 3 + tests/test_xgboost.py | 600 ++++++++++++++++++++++++++++ 17 files changed, 1620 insertions(+), 37 deletions(-) create mode 100644 src/mlbundle/_xgboost/__init__.py create mode 100644 src/mlbundle/_xgboost/native.py create mode 100644 src/mlbundle/_xgboost/prediction.py create mode 100644 src/mlbundle/_xgboost/structure.py create mode 100644 src/mlbundle/_xgboost/ubjson.py create mode 100644 src/mlbundle/declarations.py create mode 100644 tests/test_xgboost.py diff --git a/AGENTS.md b/AGENTS.md index c732bec..ebcdb4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,10 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phases 1–3 implement the version 1 format contract, strict manifest models, +Phases 1–4a implement the version 1 format contract, strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing, public metadata-only `inspect`, export/load, -and the scikit-learn adapter. XGBoost and ONNX adapters, schema inference and +and the scikit-learn and XGBoost adapters. The ONNX adapter, schema inference and the CLI remain unimplemented. Rules for later phases must not be described as implemented until code and tests prove them. `docs/format.md` owns the wire contract. @@ -204,8 +204,13 @@ JSON-ready parts, computes integrity records and produces a deterministic ZIP. It enforces the same limits and validation as the container reader and publishes paths atomically on hard-link filesystems, requiring explicit overwrite. The exclusive-creation fallback removes partial destinations on failure but exposes -the file during copying. Public sklearn export extracts claims/structure, -serializes with skops and verifies a native round trip before publication. +the file during copying. Public sklearn/XGBoost export extracts claims/structure, +serializes with skops/native in-memory UBJSON and verifies a native round trip +before publication. XGBoost supports exact XGBClassifier/XGBRegressor tree +wrappers with closed probability/regression objectives and one target. Original +class labels supplied through output-major `class_labels=` are bound to a native +copy through an mlbundle-owned booster attribute and verified on load. Omitted +labels use encoded indices; sklearn rejects this option because it owns classes_. Schema inference is future work; export currently includes no training rows. The closed member list is: required `manifest.json`, exactly one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and @@ -231,12 +236,17 @@ match the model. Public `load` uses a closed adapter and recomputes claims and structure before returning the native model and validated predictor. An adapter module provides `Adapter` with the library and model-member identity, -recognition, serialization/deserialization, `ModelClaims`, structure extraction +recognition, export preparation, serialization/deserialization, `ModelClaims`, structure extraction and a `Predictor`. Extraction uses only the native model, shared by export/load; ONNX graph fields belong in `ModelClaims.onnx`. Missing names are null claims; the shared pipeline owns supplied/generated names and environment declarations. -Native errors must become bounded `MLBundleError` subclasses. Follow the unchanged -protocols in `adapters.py`; adding `_xgboost` or `_onnx` needs no shared pipeline edit. +Native errors must become bounded `MLBundleError` subclasses. `prepare_export` +receives the strict `ExportDeclarations` label record and leaves the user's +object unchanged. Classifier claims may use null labels only for a native model +without labels; `declarations.py` then resolves count-validated supplied/generated +labels. ONNX can use that declaration path without changing its original bytes; +its kind/count inference remains ONNX-owned. Follow `adapters.py`; adding `_onnx` +needs no library-specific shared pipeline edit. ## Where code lives @@ -247,12 +257,14 @@ protocols in `adapters.py`; adding `_xgboost` or `_onnx` needs no shared pipelin - `src/mlbundle/canonical.py` — deterministic JSON encoding and bounded strict decoding. - `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. - `src/mlbundle/_sklearn/` — skops trust/loading, parameter and claim extraction, native structure, nested payload bounds and validated prediction. +- `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. +- `src/mlbundle/declarations.py` — export-time class labels and resolution for native models without labels. - `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. - `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. - `src/mlbundle/integrity.py` — member SHA-256/size records and verification. - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. -- `src/mlbundle/registry.py` — closed lazy adapter resolution and recognition; `_sklearn` is implemented, `_xgboost` and `_onnx` remain future modules. +- `src/mlbundle/registry.py` — closed lazy adapter resolution and recognition; `_sklearn` and `_xgboost` are implemented, `_onnx` remains a future module. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. diff --git a/README.md b/README.md index 05ae73c..063adcd 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ its internal structure in one compressed `.mlbundle` file. The same package writes bundles on the training machine and reads them with validation in a runtime. Local schema inference will be added in a later phase. -**Status: pre-alpha, Phase 3.** The [version 1 format contract](docs/format.md), +**Status: pre-alpha, Phase 4a.** The [version 1 format contract](docs/format.md), strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing and public `inspect` -and public sklearn `export`/`load` are implemented and tested. XGBoost and ONNX -adapters, schema inference and the CLI are still missing. Nothing has been +and public sklearn/XGBoost `export`/`load` are implemented and tested. The ONNX +adapter, schema inference and the CLI are still missing. Nothing has been published to PyPI. ## Why it exists @@ -45,7 +45,7 @@ operation works without deserializing the model or importing model libraries. Hashes detect corruption; they do not authenticate the author or prevent deliberate tampering. Signing is outside the project's scope. These guarantees -cover container validation and sklearn claim recomputation; native parsers and +cover container validation and sklearn/XGBoost claim recomputation; native parsers and prediction still require a consuming runtime's isolation and resource limits. See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. @@ -83,6 +83,39 @@ structure is null because v1 does not map transformed features. Custom types, user functions, HistGradientBoosting and XGBoost steps inside sklearn pipelines are rejected. Export currently includes no training DataFrame, schema or samples. +XGBoost supports fitted `XGBClassifier` and `XGBRegressor` tree wrappers, with +native UBJSON stored and loaded entirely in memory. Train classifiers on encoded +indices and supply original labels in probability-column order: + +```python +from xgboost import XGBClassifier +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit(rows, [0, 1, 0, 1]) +export( + model, + "xgboost.mlbundle", + feature_names=["temperature", "humidity"], + class_labels=[["cold", "warm"]], +) +bundle = load("xgboost.mlbundle") +assert isinstance(bundle.native_model, XGBClassifier) +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +assert bundle.class_labels == [["cold", "warm"]] +``` + +`class_labels=` is output-major even for one target. XGBoost binds it to a native +copy and load verifies the manifest against that binding; omitted labels are the +model's `0..N-1` integers. Native `predict` retains those encoded indices. Sklearn +and regressors reject this option. XGBoost keeps the recoverable native config +projection, not lost training parameters such as max depth or learning rate. +Binary logistic and multiclass softprob classifiers and the closed regression +objectives in [docs/format.md](docs/format.md) are covered. Native gbtree/dropout +is supported; gblinear, RF wrappers, raw boosters, ranking, survival, custom +objectives and multi-target models are rejected. + ## Development Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows @@ -106,7 +139,7 @@ The default dev group installs all model extras. Base consumers can use `uv sync --no-dev` without installing model libraries. The committed `uv.lock` fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX Runtime to the target MLSuite runtime baseline; this is not yet evidence of -compatibility for the future adapters; sklearn compatibility is exercised by +compatibility for the future ONNX adapter; sklearn/XGBoost compatibility is exercised by the round-trip tests. Pandas is a development dependency for DataFrame tests, not a core runtime dependency. `uv run pytest` produces terminal and XML coverage reports and enforces an 80% minimum. CI runs diff --git a/SECURITY.md b/SECURITY.md index dd6fcfe..1580b71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,15 @@ # Security policy -mlbundle is pre-alpha. Phases 1–3 implement strict manifest validation, +mlbundle is pre-alpha. Phases 1–4a implement strict manifest validation, contract helpers, secure container reading/writing, metadata-only `inspect`, -public export/load and the sklearn adapter. +public export/load and the sklearn and XGBoost adapters. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. The writer publishes complete validated files atomically where hard links work; the exclusive-creation fallback cleans up on failure but exposes the destination while copying and cannot guarantee crash-atomic publication. The version 1 contract -is defined in [docs/format.md](docs/format.md). XGBoost and ONNX loading remain -unimplemented; their security requirements remain obligations for later phases. +is defined in [docs/format.md](docs/format.md). ONNX loading remains +unimplemented; its security requirements remain obligations for a later phase. ## Threat model @@ -90,6 +90,32 @@ contain the native model for runtime explanations; mutating it invalidates the relationship to the verified metadata. Pipeline structure is null because v1 does not describe its input transformations. +## Implemented XGBoost dependency boundary + +XGBoost uses native UBJSON only, in memory, with exact XGBClassifier/XGBRegressor +classes chosen from mlbundle's fixed map. The manifest never supplies loading +code. Export rejects custom objectives, other wrappers, raw boosters, unsupported +objectives/boosters and multi-target models. A native copy holds the canonical +mlbundle attribute binding class labels, wrapper identity and prediction settings; +the caller's model is never mutated. Load requires and recomputes that binding, +recoverable config fields, names/types and the complete tree dumps/importances. +Label agreement proves consistency with the native file, not the historical +truth of a user-supplied label mapping. + +The adapter rejects oversized bytes and non-UBJSON before XGBoost sees them. +A bounded Python preflight checks document layout, feature/class/target counts, +tree counts, node arrays, output groups, iteration pointers, indices, reachability +and dump-compatible depth, keeping +numeric arrays as byte views. Native errors are translated to bounded mlbundle +errors without third-party exception text. These checks reduce cheap malformed +inputs; they are not a complete validation of XGBoost's internal model schema. +XGBoost's native parser and native inference remain a dependency boundary: +mlbundle cannot guarantee their memory safety or bound their internal memory/CPU +use. Native tree dump generation also allocates inside XGBoost before mlbundle +can inspect its returned byte size. Server consumers must isolate loading and +prediction and enforce process resource limits. See the exact support and +resource boundaries in [docs/format.md](docs/format.md). + ## Limits of these guarantees SHA-256 hashes detect corruption, not deliberate tampering: an attacker can diff --git a/docs/format.md b/docs/format.md index 0804997..ba685eb 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,9 +2,9 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -“Must” denotes a compatibility or security requirement. Phases 1–3 implement +"Must" denotes a compatibility or security requirement. Phases 1–4a implement the manifest, encoding, limits, secure container, metadata-only inspection, -public export/load and the scikit-learn adapter. XGBoost and ONNX adapters, +public export/load and the scikit-learn and XGBoost adapters. The ONNX adapter, schema inference and the CLI remain unimplemented. ## Container and resource limits @@ -265,6 +265,29 @@ NaN/infinite class labels are unsupported. String labels are at most 1,024 characters. Do not convert labels to display strings in the bundle or silently reorder them; a consumer may format them for display separately. +`export(class_labels=...)` accepts output-major sequences, including a nested +sequence for a single output. Supplied labels must use one scalar type per output, +be unique by canonical bytes, and match native class/output counts. Regressors +reject this option. Sklearn rejects it even when identical: its native `classes_` +owns the verified labels. XGBoost wrappers expose encoded `0..N-1` classes; +omitted labels use those integers (or retain an existing mlbundle binding). +Explicit labels map those indices to original labels in probability-column order. +The adapter binds them as canonical JSON in the native booster attribute +`mlbundle`, on a copy only. Loaded XGBoost classifiers must carry that binding; +manifest labels are verified against it. This verifies agreement with the native +file, not the historical truth of the author's original-label mapping. Native +wrapper `predict` still returns encoded indices; the bundle predictor returns +probabilities aligned with the bound labels. + +For a future ONNX graph with no native labels, extraction may return null labels. +The shared pipeline then uses supplied labels, or generates `0..N-1` per output, +and verifies their lengths against native class counts. Such labels are +declarations, not verified original labels; the ONNX bytes must stay unchanged. +Graphs exposing labels must return them as claims for exact comparison. ONNX +must reject supplied labels that disagree with native labels; the shared resolver +enforces this comparison. The ONNX implementation must establish kind and class +counts independently of labels. + ### `environment` | Field | Type | Meaning / verification | @@ -361,7 +384,9 @@ from recomputing claims: plausible metadata alone never verifies a model. The manifest's claim projection is `ModelClaims`: estimator class, kind, hyperparameters, feature/class/output counts, model-origin feature names (null otherwise), model dtypes, original output-major class labels and ONNX graph -metadata. Library/serialization/member pairing is validated by the closed +metadata. Classifier extraction may leave labels null only when the native model +carries none; the shared pipeline resolves the declaration as specified above. +Library/serialization/member pairing is validated by the closed registry. Structure is compared separately using the same canonical encoding. Schema, training dtypes, frame-origin names, target name, training environment, user metadata, identity and samples are not verified against the model; they must @@ -414,7 +439,13 @@ allowlist. No approximate trees are emitted for it. ## Adapter and result contracts The internal `Adapter` protocol in `mlbundle.adapters` owns recognition, -serialization, loading, claim extraction, structure extraction and a `Predictor`. +export preparation, serialization, loading, claim extraction, structure +extraction and a `Predictor`. `prepare_export(model, ExportDeclarations)` returns +the object to serialize and extract, leaving the caller's model unchanged. +`ExportDeclarations` is a strict frozen record with `class_labels` only. Adapters +bind or reject declarations according to their native contracts; neither +`deserialize` nor extraction receives manifest fields. The shared pipeline +resolves labels only when extraction returns null labels for a classifier. The registry imports only the three constant mlbundle module paths `_sklearn`, `_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library imports must be lazy, with dependency failures naming the extra to install. @@ -423,7 +454,7 @@ unimplemented adapters while looking for one that recognizes its input. A missin optional dependency reports `pip install "mlbundle[EXTRA]"`. An unrecognized object raises `UnsupportedModelError`. -`export(model, destination, *, feature_names=None, target_name=None, metadata=None, +`export(model, destination, *, feature_names=None, class_labels=None, target_name=None, metadata=None, overwrite=False, limits=None)` returns a small `MetadataView` of the written bundle. It serializes, extracts, reloads and compares both claims and structure before writing. Model-exposed names take precedence over supplied names. It @@ -443,7 +474,7 @@ Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected at export and independently at load. XGBoost estimators inside sklearn pipelines are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). -The native XGBoost adapter must own their loading in the next phase. +The native XGBoost adapter owns standalone XGBoost loading. The nested skops archive is bounded before parsing; every schema-referenced numeric array header is checked regardless of filename suffix. Headers must match the stored byte sizes and cannot use object-array loading or zero-itemsize @@ -457,6 +488,76 @@ support. Multilabel classifiers with independent positive-class columns and classifier chains are rejected because their probabilities cannot satisfy the output-major contract; ordinary multi-output probability-matrix lists are supported. +### Implemented XGBoost mapping + +Only exact `XGBClassifier` and `XGBRegressor` classes are supported. Subclasses, +ranking, survival, custom objectives, raw `Booster`, and the deprecated +`XGBRFClassifier`/`XGBRFRegressor` wrappers are rejected. Raw boosters do not +carry enough wrapper identity or custom-objective history for this narrow +training-side boundary; RF wrappers add no required v0.1 contract. Supported +classifier objectives are `binary:logistic` and `multi:softprob`. Supported +regression objectives are `reg:squarederror`, `reg:absoluteerror`, +`reg:pseudohubererror`, `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, +`reg:gamma`, and `reg:tweedie`. Only one target is supported. Binary classifiers +return two probability columns; softmax-only objectives are rejected. + +Only native `gbtree` storage is admitted. In XGBoost 3.4.1, the deprecated `dart` +option uses `gbtree` storage with dropout; that representation is supported and +all native dump statistics are retained. Legacy native `dart` storage is rejected. +`gblinear` is rejected: its multi-class coefficient/importance dimensions and +deprecated format are outside this phase's tree contract. There is no silently +omitted structure for an admitted XGBoost model. + +Serialization is exclusively `bytes(booster.save_raw(raw_format="ubj"))` and +`load_model(bytearray(data))`, in memory. Class selection uses the closed adapter +map and the `mlbundle` booster attribute, never the manifest. That attribute is +exactly `{ "estimator_class": CLASS, "class_labels": LABELS_OR_NULL, +"prediction": SETTINGS }` encoded canonically. `SETTINGS` has exactly `missing` +(a finite binary64 value or the canonical NaN tag), `importance_type` (one of +`gain`, `weight`, `cover`, `total_gain`, `total_cover`; omitted native option +normalizes to `gain`), and `enable_categorical` (boolean). These settings survive +loading and are compared with the wrapper. Other booster attributes, including +`best_iteration`, survive unchanged; prediction respects native best iteration. + +`hyperparameters` has exactly these four keys: + +```json +{ + "learner_model_param": {"base_score":"[5E-1]","boost_from_average":"1","num_class":"0","num_feature":"4","num_target":"1"}, + "objective": {"name":"binary:logistic","reg_loss_param":{"scale_pos_weight":"1"}}, + "gradient_booster": {"name":"gbtree","gbtree_model_param":{"num_parallel_tree":"1","num_trees":"3"}}, + "prediction": {"missing":{"$float":"nan"},"importance_type":"gain","enable_categorical":true} +} +``` + +The first three records are a projection of the parsed native `save_config()`: +retain every field in `learner_model_param`, `objective`, and +`gradient_booster.gbtree_model_param`, plus the booster name, with native value +types/spellings unchanged. `prediction` is the bound wrapper settings record. +Neither `get_params()` nor the whole config survives native model I/O. Training +settings such as learning rate, max depth, seed, threads and metrics are therefore +not claimed or reconstructed. Export compares this projection and the complete +structure before and after making the native copy, and again through the shared +serialization round trip. Unsupported unrecoverable claims fail publication. + +Booster feature names and feature types become verified claims; absent values +are null. Structure is exactly the `xgboost` family above, with the wrapper's +native float32-normalized importance vector for the bound importance type. +Prediction values and structure scalars promote float32 exactly to binary64. +Claim/structure comparisons use exact canonical bytes; probability row-sum +validation allows `rtol=1e-6`, `atol=1e-7` for float32 arithmetic. + +Before native parsing, the adapter bounds bytes, recognizes the native UBJSON +prefix, validates the complete bounded document, and checks feature/class/target, +tree and node-array dimensions, output-group indices, iteration pointers and +child/feature indices. Numeric arrays remain +byte views. At most 100,000 trees are supported; reachable tree depth is at most +14 so the complete nested dump fits the existing JSON depth ceiling of 32. +Cycles, aliases and unreachable nodes are rejected. Native dump generation is +count-bounded; total dump bytes and canonical structure bytes must also fit +256 MiB and caller-selected lower limits. No truncation is permitted. These +checks do not sandbox the native parser or cap its internal allocations. + Predictors accept adapter-supported tabular objects without a core pandas/numpy dependency. Regressors return `list[float]` for a single output or row-major `list[list[float]]` for multiple outputs. Classifiers return row-major diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index 0b87076..0dd3c5c 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -15,6 +15,7 @@ from mlbundle.canonical import canonical_value from mlbundle.claims import ClassLabels, FeatureNames, ModelClaims from mlbundle.contract import Library, MemberName, ModelKind +from mlbundle.declarations import ExportDeclarations from mlbundle.errors import ( InvalidJSONError, InvalidModelError, @@ -96,6 +97,13 @@ def recognizes(self, model: object) -> bool: and (is_classifier(model) or is_regressor(model)) ) + def prepare_export(self, model: object, declarations: ExportDeclarations) -> object: + if declarations.class_labels is not None: + raise UnsupportedModelError( + reason="Sklearn already carries class labels; class_labels is unsupported." + ) + return model + def _validate(self, model: Any) -> None: if not self.recognizes(model): raise UnsupportedModelError( diff --git a/src/mlbundle/_xgboost/__init__.py b/src/mlbundle/_xgboost/__init__.py new file mode 100644 index 0000000..ea9c05e --- /dev/null +++ b/src/mlbundle/_xgboost/__init__.py @@ -0,0 +1,234 @@ +"""Native, in-memory UBJSON adapter with mlbundle-owned wrapper identities.""" + +from typing import Any, cast + +import numpy as np +import xgboost as xgb +from pydantic import JsonValue + +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.claims import ClassLabels, ModelClaims, verify_claims, verify_structure +from mlbundle.contract import Library, MemberName, ModelKind +from mlbundle.declarations import ( + ExportDeclarations, + export_declarations, + resolve_labels, +) +from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError +from mlbundle.limits import HYPERPARAMETER_BYTES + +from .native import ( + ATTRIBUTE, + configuration, + dimensions, + parameters, + tree_model, + validate_payload, +) +from .prediction import Predictor +from .structure import extract_structure + +CLASSES = {"XGBClassifier": xgb.XGBClassifier, "XGBRegressor": xgb.XGBRegressor} + + +def _settings(model: Any) -> dict[str, Any]: + missing = model.missing + if not isinstance(missing, (int, float, np.number)) or np.isinf(missing): + raise UnsupportedModelError(reason="Unsupported XGBoost missing-value marker.") + importance = model.importance_type or "gain" + if importance not in ("gain", "weight", "cover", "total_gain", "total_cover"): + raise UnsupportedModelError(reason="Unsupported XGBoost importance type.") + return { + "missing": canonical_loads(canonical_dumps(float(missing))), + "importance_type": importance, + "enable_categorical": bool(model.enable_categorical), + } + + +def _native_settings(value: dict[str, Any]) -> dict[str, Any]: + if not isinstance(value, dict) or set(value) != { + "missing", + "importance_type", + "enable_categorical", + }: + raise InvalidModelError("Invalid XGBoost prediction settings.") + missing = value["missing"] + if missing == {"$float": "nan"}: + missing = float("nan") + if type(value["enable_categorical"]) is not bool: + raise InvalidModelError("Invalid XGBoost categorical setting.") + return {**value, "missing": missing} + + +class Adapter: + library = Library.XGBOOST + model_member = MemberName.UBJSON + + def recognizes(self, model: object) -> bool: + return isinstance(model, (xgb.XGBModel, xgb.Booster)) + + def _validate(self, model: Any) -> tuple[dict[str, Any], int | None]: + if type(model) not in CLASSES.values(): + raise UnsupportedModelError( + reason="Only XGBClassifier and XGBRegressor are supported." + ) + if not model.__sklearn_is_fitted__(): + raise UnsupportedModelError(reason="XGBoost estimator must be fitted.") + if callable(model.objective): + raise UnsupportedModelError( + reason="Custom XGBoost objectives are unsupported." + ) + learner = configuration(model.get_booster()) + kind, width, classes = dimensions(learner) + tree_model(learner) + if (type(model) is xgb.XGBClassifier) != (kind == ModelKind.CLASSIFIER): + raise UnsupportedModelError( + reason="XGBoost wrapper kind and native objective disagree." + ) + if model.n_features_in_ != width or ( + classes is not None and model.n_classes_ != classes + ): + raise InvalidModelError("XGBoost wrapper and native dimensions disagree.") + _settings(model) + return learner, classes + + def prepare_export(self, model: Any, declarations: ExportDeclarations) -> object: + try: + self._validate(model) + claims = self.extract_claims(model) + if ( + claims.kind == ModelKind.REGRESSOR + and declarations.class_labels is not None + ): + raise UnsupportedModelError( + reason="Regressors cannot accept class_labels." + ) + # Explicit declarations replace encoded class indices on the copy only. + if declarations.class_labels is not None: + claims = resolve_labels( + claims.model_copy(update={"class_labels": None}), declarations + ) + settings = _settings(model) + restored = type(model)(**_native_settings(settings)) + restored.load_model(model.get_booster().save_raw(raw_format="ubj")) + restored.enable_categorical = settings["enable_categorical"] + binding = { + "estimator_class": type(model).__name__, + "class_labels": claims.class_labels, + "prediction": settings, + } + restored.get_booster().set_attr( + **{ + ATTRIBUTE: canonical_dumps( + binding, max_bytes=HYPERPARAMETER_BYTES + ).decode() + } + ) + verify_claims(claims, self.extract_claims(restored)) + verify_structure( + self.extract_structure(model), self.extract_structure(restored) + ) + return restored + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="XGBoost export preparation failed." + ) from None + + def serialize(self, model: Any) -> bytes: + try: + self._validate(model) + data = bytes(model.get_booster().save_raw(raw_format="ubj")) + validate_payload(data) + return data + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="XGBoost UBJSON serialization failed." + ) from None + + def deserialize(self, data: bytes) -> object: + try: + validate_payload(data) + booster = xgb.Booster() + booster.load_model(bytearray(data)) + binding = _binding(booster) + if not isinstance(binding, dict) or set(binding) != { + "estimator_class", + "class_labels", + "prediction", + }: + raise InvalidModelError("Invalid XGBoost mlbundle binding.") + cls = CLASSES.get(binding["estimator_class"]) + if cls is None: + raise InvalidModelError("Unsupported XGBoost wrapper identity.") + settings = _native_settings(binding["prediction"]) + model = cls(**settings) + model.load_model(bytearray(data)) + model.enable_categorical = settings["enable_categorical"] + self.extract_claims(model) + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid XGBoost UBJSON payload.") from None + + def extract_claims(self, model: Any) -> ModelClaims: + try: + learner, classes = self._validate(model) + booster = model.get_booster() + binding = booster.attr(ATTRIBUTE) + labels = None if classes is None else [list(range(classes))] + if binding is not None: + record = _binding(booster) + if record["estimator_class"] != type(model).__name__ or canonical_dumps( + record["prediction"] + ) != canonical_dumps(_settings(model)): + raise InvalidModelError( + "XGBoost binding and loaded wrapper disagree." + ) + labels = record["class_labels"] + declared = export_declarations(labels) + if classes is not None and declared.class_labels is None: + raise InvalidModelError("Missing XGBoost bound class labels.") + return ModelClaims( + estimator_class=type(model).__name__, + kind=ModelKind.CLASSIFIER + if classes is not None + else ModelKind.REGRESSOR, + hyperparameters=parameters(learner, _settings(model)), + n_features=model.n_features_in_, + n_classes=None if classes is None else [classes], + n_outputs=1, + feature_names=booster.feature_names, + feature_dtypes=booster.feature_types, + class_labels=cast(ClassLabels | None, labels), + ) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid XGBoost model claims.") from None + + def extract_structure(self, model: Any) -> dict[str, JsonValue]: + try: + self._validate(model) + return extract_structure(model) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid XGBoost model structure.") from None + + def predictor(self, model: object) -> Predictor: + return Predictor(model, self.extract_claims(model)) + + +def _binding(booster: Any) -> dict[str, Any]: + text = booster.attr(ATTRIBUTE) + if text is None: + raise InvalidModelError("Missing XGBoost mlbundle binding.") + value = canonical_loads(text.encode(), max_bytes=HYPERPARAMETER_BYTES) + if not isinstance(value, dict): + raise InvalidModelError("Invalid XGBoost mlbundle binding.") + return cast(dict[str, Any], value) diff --git a/src/mlbundle/_xgboost/native.py b/src/mlbundle/_xgboost/native.py new file mode 100644 index 0000000..1417e97 --- /dev/null +++ b/src/mlbundle/_xgboost/native.py @@ -0,0 +1,187 @@ +"""Closed native dimensions, objectives and recoverable config projection.""" + +from typing import Any + +import numpy as np + +from mlbundle.canonical import canonical_loads +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError, UnsupportedModelError +from mlbundle.limits import MAX_CLASSES, MAX_FEATURES, ArchiveLimits + +from .ubjson import Buffer, decode + +ATTRIBUTE = "mlbundle" +MAX_TREES = 100_000 +CLASSIFIERS = frozenset({"binary:logistic", "multi:softprob"}) +REGRESSORS = frozenset( + { + "reg:squarederror", + "reg:absoluteerror", + "reg:pseudohubererror", + "reg:squaredlogerror", + "reg:logistic", + "count:poisson", + "reg:gamma", + "reg:tweedie", + } +) + + +def dimensions(learner: dict[str, Any]) -> tuple[ModelKind, int, int | None]: + params = learner["learner_model_param"] + width, classes, outputs = ( + int(params[key]) for key in ("num_feature", "num_class", "num_target") + ) + if ( + not 1 <= width <= MAX_FEATURES + or not 0 <= classes <= MAX_CLASSES + or outputs != 1 + ): + raise UnsupportedModelError( + reason="XGBoost requires bounded features/classes and a single target." + ) + objective = learner["objective"]["name"] + if objective in CLASSIFIERS: + if (objective == "binary:logistic" and classes != 0) or ( + objective == "multi:softprob" and classes < 2 + ): + raise InvalidModelError("Invalid XGBoost classification dimensions.") + return ModelKind.CLASSIFIER, width, max(2, classes) + if objective in REGRESSORS and classes == 0: + return ModelKind.REGRESSOR, width, None + raise UnsupportedModelError( + reason="Unsupported XGBoost objective; only probabilities and regression are supported." + ) + + +def tree_model(learner: dict[str, Any]) -> dict[str, Any]: + booster = learner["gradient_booster"] + if booster["name"] != "gbtree": + raise UnsupportedModelError( + reason="Only XGBoost gbtree (including native dropout) is supported." + ) + return booster + + +def configuration(booster: Any) -> dict[str, Any]: + value = canonical_loads(booster.save_config().encode()) + learner = value.get("learner") if isinstance(value, dict) else None + if not isinstance(learner, dict): + raise InvalidModelError("Invalid XGBoost native configuration.") + return learner + + +def parameters(learner: dict[str, Any], settings: dict[str, Any]) -> dict[str, Any]: + gb = tree_model(learner) + return { + "learner_model_param": learner["learner_model_param"], + "objective": learner["objective"], + "gradient_booster": { + "name": gb["name"], + "gbtree_model_param": gb["gbtree_model_param"], + }, + "prediction": settings, + } + + +def validate_payload(data: bytes) -> None: + if len(data) > ArchiveLimits().max_member_bytes: + raise InvalidModelError("XGBoost payload exceeds the byte limit.") + if not data.startswith(b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{"): + raise InvalidModelError("Expected a native XGBoost UBJSON model.") + learner = decode(data)["learner"] + dimensions(learner) + model = tree_model(learner)["model"] + count = int(model["gbtree_model_param"]["num_trees"]) + trees = model["trees"] + if ( + not 1 <= count <= MAX_TREES + or not isinstance(trees, list) + or len(trees) != count + ): + raise InvalidModelError("Invalid or oversized XGBoost tree count.") + groups = _array(model["tree_info"]) + group_count = max(1, int(learner["learner_model_param"]["num_class"])) + if ( + groups.shape != (count,) + or groups.dtype.kind not in "iu" + or np.any(groups < 0) + or np.any(groups >= group_count) + ): + raise InvalidModelError("Invalid XGBoost tree output-group indices.") + pointers = _array(model["iteration_indptr"]) + if ( + pointers.ndim != 1 + or not 2 <= len(pointers) <= count + 1 + or pointers.dtype.kind not in "iu" + or pointers[0] != 0 + or pointers[-1] != count + or np.any(pointers[1:] <= pointers[:-1]) + ): + raise InvalidModelError("Invalid XGBoost iteration pointers.") + for tree in trees: + nodes = int(tree["tree_param"]["num_nodes"]) + if ( + nodes < 1 + or int(tree["tree_param"]["size_leaf_vector"]) != 1 + or int(tree["tree_param"]["num_feature"]) + != int(learner["learner_model_param"]["num_feature"]) + or int(tree["tree_param"]["num_deleted"]) != 0 + ): + raise InvalidModelError("Unsupported XGBoost tree dimensions.") + for key in ( + "left_children", + "right_children", + "split_indices", + "split_conditions", + "parents", + "default_left", + "loss_changes", + "sum_hessian", + "base_weights", + ): + values = tree[key] + length = len(values) if isinstance(values, list) else values.count + if length != nodes: + raise InvalidModelError("Invalid XGBoost node array dimensions.") + _tree_indices(tree, nodes, int(learner["learner_model_param"]["num_feature"])) + + +def _array(value: Any) -> np.ndarray: + if isinstance(value, Buffer): + formats = { + "i": "i1", + "U": "u1", + "I": ">i2", + "l": ">i4", + "L": ">i8", + "d": ">f4", + "D": ">f8", + } + return np.frombuffer(value.data, dtype=formats[value.marker]) + return np.asarray(value) + + +def _tree_indices(tree: dict[str, Any], nodes: int, width: int) -> None: + left, right, features = ( + _array(tree[key]) + for key in ("left_children", "right_children", "split_indices") + ) + if any(array.dtype.kind not in "iu" for array in (left, right, features)): + raise InvalidModelError("Invalid XGBoost tree index types.") + seen = bytearray(nodes) + pending = [(0, 0)] + while pending: + node, depth = pending.pop() + if node < 0 or node >= nodes or seen[node] or depth > 14: + raise InvalidModelError("Invalid XGBoost tree reachability or depth.") + seen[node] = 1 + a, b = int(left[node]), int(right[node]) + if a == b == -1: + continue + if a < 0 or b < 0 or not 0 <= features[node] < width: + raise InvalidModelError("Invalid XGBoost tree child or feature indices.") + pending.extend(((a, depth + 1), (b, depth + 1))) + if not all(seen): + raise InvalidModelError("Unreachable XGBoost tree nodes.") diff --git a/src/mlbundle/_xgboost/prediction.py b/src/mlbundle/_xgboost/prediction.py new file mode 100644 index 0000000..38b094e --- /dev/null +++ b/src/mlbundle/_xgboost/prediction.py @@ -0,0 +1,66 @@ +"""XGBoost tabular inference with bounded-error and probability validation.""" + +from typing import Any, cast + +import numpy as np + +from mlbundle.adapters import ProbabilityOutput, RegressionOutput +from mlbundle.claims import ModelClaims +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError + + +class Predictor: + def __init__(self, model: Any, claims: ModelClaims) -> None: + self.model = model + self.claims = claims + + def _call(self, data: object, *, probabilities: bool) -> tuple[np.ndarray, int]: + try: + shape = getattr(data, "shape", None) + if shape is None: + data = np.asarray(data) + shape = data.shape + if len(shape) != 2 or shape[1] != self.claims.n_features: + raise ValueError( + "Prediction input must have the model's feature width." + ) + rows = int(shape[0]) + raw = ( + self.model.predict_proba(data) + if probabilities + else self.model.predict(data) + ) + return np.asarray(raw, dtype=float), rows + except ValueError as error: + if str(error) == "Prediction input must have the model's feature width.": + raise + raise InvalidModelError("Invalid XGBoost prediction input.") from None + except Exception: + raise InvalidModelError("XGBoost prediction failed.") from None + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + values, rows = self._call(data, probabilities=False) + if values.shape != (rows,) or not np.isfinite(values).all(): + raise InvalidModelError( + "Invalid regression prediction dimensions or values." + ) + return cast(RegressionOutput, values.tolist()) + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + values, rows = self._call(data, probabilities=True) + if ( + values.shape != (rows, (self.claims.n_classes or [0])[0]) + or not np.isfinite(values).all() + or np.any(values < 0) + or np.any(values > 1) + or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-7) + ): + raise InvalidModelError( + "Invalid probability dimensions, values or row sums." + ) + return cast(ProbabilityOutput, values.tolist()) diff --git a/src/mlbundle/_xgboost/structure.py b/src/mlbundle/_xgboost/structure.py new file mode 100644 index 0000000..0fba759 --- /dev/null +++ b/src/mlbundle/_xgboost/structure.py @@ -0,0 +1,39 @@ +"""All native tree dump fields and wrapper feature importances, without truncation.""" + +from typing import Any, cast + +from pydantic import JsonValue + +from mlbundle.canonical import canonical_loads, canonical_value +from mlbundle.errors import LimitExceededError +from mlbundle.limits import STRUCTURE_BYTES + +from .native import MAX_TREES, configuration, tree_model + + +def extract_structure(model: Any) -> dict[str, JsonValue]: + booster = model.get_booster() + native = tree_model(configuration(booster)) + count = int(native["gbtree_model_param"]["num_trees"]) + if not 1 <= count <= MAX_TREES: + raise LimitExceededError("XGBoost tree count exceeds the limit.") + dumps = booster.get_dump(dump_format="json", with_stats=True) + if ( + len(dumps) != count + or sum(len(tree.encode()) for tree in dumps) > STRUCTURE_BYTES + ): + raise LimitExceededError("XGBoost structure exceeds the limit.") + return cast( + dict[str, JsonValue], + canonical_value( + { + "family": "xgboost", + "trees": [ + canonical_loads(tree.encode(), max_bytes=STRUCTURE_BYTES) + for tree in dumps + ], + "feature_importances": model.feature_importances_, + }, + max_bytes=STRUCTURE_BYTES, + ), + ) diff --git a/src/mlbundle/_xgboost/ubjson.py b/src/mlbundle/_xgboost/ubjson.py new file mode 100644 index 0000000..3c63a83 --- /dev/null +++ b/src/mlbundle/_xgboost/ubjson.py @@ -0,0 +1,107 @@ +"""Bounded UBJSON preflight; numeric buffers stay as views, never expanded lists.""" + +import struct +from dataclasses import dataclass +from typing import Any + +from mlbundle.errors import InvalidModelError + +_FORMATS = {"i": "b", "U": "B", "I": "h", "l": "i", "L": "q", "d": "f", "D": "d"} + + +@dataclass(frozen=True) +class Buffer: + data: memoryview + marker: str + count: int + + +class Decoder: + def __init__(self, data: bytes) -> None: + self.data = memoryview(data) + self.position = 0 + + def take(self, count: int) -> memoryview: + if count < 0 or count > len(self.data) - self.position: + raise InvalidModelError("Truncated UBJSON model.") + result = self.data[self.position : self.position + count] + self.position += count + return result + + def marker(self) -> str: + return chr(self.take(1)[0]) + + def integer(self) -> int: + marker = self.marker() + if marker not in "iUIlL": + raise InvalidModelError("Invalid UBJSON length.") + return self.number(marker) + + def number(self, marker: str) -> Any: + fmt = ">" + _FORMATS[marker] + return struct.unpack(fmt, self.take(struct.calcsize(fmt)))[0] + + def string(self) -> str: + return bytes(self.take(self.integer())).decode("utf-8") + + def value(self, depth: int = 0, marker: str | None = None) -> Any: + if depth > 32: + raise InvalidModelError("UBJSON nesting exceeds the limit.") + marker = self.marker() if marker is None else marker + if marker in _FORMATS: + return self.number(marker) + if marker == "S": + return self.string() + if marker in ("T", "F", "Z"): + return {"T": True, "F": False, "Z": None}[marker] + if marker in ("{", "["): + return self.container(marker, depth + 1) + raise InvalidModelError("Unsupported UBJSON token.") + + def header(self) -> tuple[str | None, int | None]: + typed = None + count = None + if self.position < len(self.data) and chr(self.data[self.position]) == "$": + self.take(1) + typed = self.marker() + if self.position < len(self.data) and chr(self.data[self.position]) == "#": + self.take(1) + count = self.integer() + if count < 0 or count > len(self.data): + raise InvalidModelError("Unsafe UBJSON collection length.") + if typed is not None and count is None: + raise InvalidModelError("Unbounded typed UBJSON collection.") + return typed, count + + def container(self, marker: str, depth: int) -> Any: + end = "}" if marker == "{" else "]" + typed, count = self.header() + if marker == "[" and typed in _FORMATS and count is not None: + size = struct.calcsize(">" + _FORMATS[typed]) + return Buffer(self.take(count * size), typed, count) + result: Any = {} if marker == "{" else [] + index = 0 + while count is None or index < count: + if count is None and chr(self.data[self.position]) == end: + self.take(1) + break + if index >= 100_000: + raise InvalidModelError("UBJSON object collection exceeds the limit.") + key = self.string() if marker == "{" else None + value = self.value(depth, typed) + if marker == "{": + if key in result: + raise InvalidModelError("Duplicate UBJSON object key.") + result[key] = value + else: + result.append(value) + index += 1 + return result + + +def decode(data: bytes) -> dict[str, Any]: + decoder = Decoder(data) + value = decoder.value() + if not isinstance(value, dict) or decoder.position != len(data): + raise InvalidModelError("Invalid UBJSON model document.") + return value diff --git a/src/mlbundle/adapters.py b/src/mlbundle/adapters.py index 93fa7e3..b4ed051 100644 --- a/src/mlbundle/adapters.py +++ b/src/mlbundle/adapters.py @@ -6,6 +6,7 @@ from .claims import ModelClaims from .contract import Library, MemberName +from .declarations import ExportDeclarations RegressionOutput = list[float] | list[list[float]] ProbabilityOutput = list[list[float]] | list[list[list[float]]] @@ -22,6 +23,9 @@ def library(self) -> Library: ... @property def model_member(self) -> MemberName: ... def recognizes(self, model: object) -> bool: ... + def prepare_export( + self, model: object, declarations: ExportDeclarations + ) -> object: ... def serialize(self, model: object) -> bytes: ... def deserialize(self, data: bytes) -> object: ... def extract_claims(self, model: object) -> ModelClaims: ... diff --git a/src/mlbundle/claims.py b/src/mlbundle/claims.py index be01756..a5de810 100644 --- a/src/mlbundle/claims.py +++ b/src/mlbundle/claims.py @@ -77,7 +77,9 @@ class ModelClaims(EstimatorInfo): @model_validator(mode="after") def consistent_claims(self) -> ModelClaims: - validate_labels(self, self.class_labels) + # A classifier with no native labels leaves their declaration to the pipeline. + if self.class_labels is not None or self.kind == ModelKind.REGRESSOR: + validate_labels(self, self.class_labels) if self.feature_names is not None and ( len(self.feature_names) != self.n_features or len(set(self.feature_names)) != len(self.feature_names) diff --git a/src/mlbundle/declarations.py b/src/mlbundle/declarations.py new file mode 100644 index 0000000..4729e05 --- /dev/null +++ b/src/mlbundle/declarations.py @@ -0,0 +1,68 @@ +"""Export declarations and resolution for models without native class labels.""" + +from collections.abc import Sequence +from typing import cast + +from pydantic import ValidationError + +from ._base import ContractModel +from .canonical import canonical_dumps +from .claims import ClassLabels, Label, ModelClaims, validate_labels +from .contract import ModelKind +from .errors import UnsupportedModelError + + +class ExportDeclarations(ContractModel): + class_labels: ClassLabels | None = None + + +def export_declarations( + labels: Sequence[Sequence[Label]] | None, +) -> ExportDeclarations: + try: + if labels is not None and any( + isinstance(output, (str, bytes)) for output in labels + ): + raise ValueError + value = None if labels is None else [list(output) for output in labels] + declarations = ExportDeclarations(class_labels=value) + for output in declarations.class_labels or []: + if len({type(label) for label in output}) != 1: + raise ValueError + return declarations + except (ValidationError, TypeError, ValueError): + raise UnsupportedModelError( + reason="Invalid class_labels declaration." + ) from None + + +def resolve_labels( + claims: ModelClaims, declarations: ExportDeclarations +) -> ModelClaims: + """Native labels win; otherwise verify declarations against native class counts.""" + labels = claims.class_labels + if claims.kind == ModelKind.REGRESSOR and declarations.class_labels is not None: + raise UnsupportedModelError(reason="Regressors cannot accept class_labels.") + if ( + labels is not None + and declarations.class_labels is not None + and canonical_dumps(labels) != canonical_dumps(declarations.class_labels) + ): + raise UnsupportedModelError( + reason="Declared labels disagree with native class labels." + ) + if labels is None and claims.kind == ModelKind.CLASSIFIER: + labels = declarations.class_labels + if labels is None: + labels = cast( + ClassLabels, [list(range(count)) for count in claims.n_classes or []] + ) + try: + validate_labels(claims, labels) + return ModelClaims.model_validate( + {**claims.model_dump(), "class_labels": labels} + ) + except (ValidationError, ValueError): + raise UnsupportedModelError( + reason="Class labels must match native counts and kind." + ) from None diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py index 80c30e5..c329533 100644 --- a/src/mlbundle/model_io.py +++ b/src/mlbundle/model_io.py @@ -11,7 +11,7 @@ from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits from .canonical import canonical_dumps, canonical_loads -from .claims import ModelClaims, verify_claims, verify_structure +from .claims import Label, ModelClaims, verify_claims, verify_structure from .container import read_container from .contract import ( FORMAT_NAME, @@ -22,6 +22,7 @@ MemberName, SerializationFormat, ) +from .declarations import ExportDeclarations, export_declarations, resolve_labels from .environment import capture_environment from .errors import ClaimMismatchError, InvalidModelError, LimitExceededError from .inspection import inspect @@ -85,6 +86,7 @@ def export( destination: str | PathLike[str], *, feature_names: Sequence[str] | None = None, + class_labels: Sequence[Sequence[Label]] | None = None, target_name: str | None = None, metadata: Mapping[str, JsonValue] | None = None, overwrite: bool = False, @@ -94,17 +96,23 @@ def export( Model-exposed feature names take precedence over supplied names. Otherwise supplied names are declarations; omitted names become feature_1 … feature_N. + Class labels are output-major declarations handled by the native adapter; + omitted labels use native labels or generated encoded indices. No training rows, schema or sample data are included by this API. """ selected = effective_limits(limits) + declarations = export_declarations(class_labels) adapter = find_adapter(model) + model = adapter.prepare_export(model, declarations) data = adapter.serialize(model) if len(data) > selected.member_bytes(adapter.model_member): raise LimitExceededError("Model member size limit exceeded.") - claims = adapter.extract_claims(model) + claims = resolve_labels(adapter.extract_claims(model), declarations) structure = adapter.extract_structure(model) restored = adapter.deserialize(data) - verify_claims(claims, adapter.extract_claims(restored)) + verify_claims( + claims, resolve_labels(adapter.extract_claims(restored), declarations) + ) verify_structure(structure, adapter.extract_structure(restored)) members = {adapter.model_member: data} if structure is not None: @@ -140,7 +148,21 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded manifest, members = read_container(source, limits=selected) adapter = get_adapter(manifest.model.library) model = adapter.deserialize(members[adapter.model_member]) - verify_claims(manifest.verified_claims(), adapter.extract_claims(model)) + actual = adapter.extract_claims(model) + expected = manifest.verified_claims() + # Check native dimensions before using any label declaration from the bundle. + verify_claims( + expected.model_copy(update={"class_labels": actual.class_labels}), actual + ) + actual = resolve_labels( + actual, + ExportDeclarations( + class_labels=manifest.target.class_labels + if actual.class_labels is None + else None + ), + ) + verify_claims(expected, actual) structure = _optional_json(members, MemberName.STRUCTURE, selected) extracted = adapter.extract_structure(model) if (MemberName.STRUCTURE in members) != (extracted is not None): diff --git a/tests/test_model_io.py b/tests/test_model_io.py index d91e41c..8767afd 100644 --- a/tests/test_model_io.py +++ b/tests/test_model_io.py @@ -1,7 +1,7 @@ """Library-independent pipelines, dependency failures and export-time verification.""" import pytest -from sklearn.tree import DecisionTreeClassifier +from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor import mlbundle from mlbundle import ( @@ -96,7 +96,7 @@ def test_closed_adapter_pipeline_is_library_agnostic( if library == Library.ONNX else None ) - claims = claims.model_copy(update={"onnx": graph}) + claims = claims.model_copy(update={"onnx": graph, "class_labels": None}) class FakeAdapter: model_member = MODEL_MEMBERS[library][1] @@ -104,6 +104,8 @@ class FakeAdapter: def recognizes(self, model): return model == "native input" + prepare_export = _prepare_native + def serialize(self, model): return b"native format bytes" @@ -129,15 +131,84 @@ def resolve(path): monkeypatch.setattr(registry, "import_module", resolve) path = tmp_path / "bundle.mlbundle" - mlbundle.export("native input", path) + mlbundle.export("native input", path, class_labels=[["cold", "warm"]]) loaded = mlbundle.load(path) assert loaded.library == library and loaded.native_model == "native input" + assert loaded.class_labels == [["cold", "warm"]] assert loaded.manifest.environment.onnx == graph assert loaded.predict_proba([[1, 2]]) == [[0.2, 0.8]] + _check_declared_count_mismatch(path) if graph is not None: - manifest, members = read_container(path) - parts = manifest.model_dump(mode="json", by_alias=True) - parts["environment"]["onnx"]["ir_version"] = 11 - del members[MemberName.MANIFEST] - with pytest.raises(ClaimMismatchError, match="onnx"): - mlbundle.load(write_container(parts, members)) + _check_graph_mismatch(path) + + +def _check_graph_mismatch(path): + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["environment"]["onnx"]["ir_version"] = 11 + del members[MemberName.MANIFEST] + with pytest.raises(ClaimMismatchError, match="onnx"): + mlbundle.load(write_container(parts, members)) + + +def _check_declared_count_mismatch(path): + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["model"]["n_classes"] = [1] + parts["target"]["class_labels"] = [["one"]] + del members[MemberName.MANIFEST] + with pytest.raises(ClaimMismatchError, match="n_classes"): + mlbundle.load(write_container(parts, members)) + + +def _prepare_native(self, model, declarations): + return model + + +@pytest.mark.parametrize( + "model", + [ + DecisionTreeClassifier(), + DecisionTreeRegressor(), + ], +) +def test_sklearn_rejects_class_label_declarations(model, tmp_path): + model.fit([[0], [1]], [0, 1]) + with pytest.raises(UnsupportedModelError, match="Sklearn already carries"): + mlbundle.export(model, tmp_path / "bad.mlbundle", class_labels=[[0, 1]]) + + +@pytest.mark.parametrize( + "labels", [[[1]], [[1, 1]], [[1, "two"]], [[None, 1]], [[float("inf"), 1]], ["ab"]] +) +def test_export_declarations_are_strict(labels, tmp_path): + model = DecisionTreeClassifier().fit([[0], [1]], [0, 1]) + with pytest.raises(UnsupportedModelError): + mlbundle.export(model, tmp_path / "bad.mlbundle", class_labels=labels) + + +def test_label_less_native_claims_use_declarations(): + from mlbundle.declarations import ( + ExportDeclarations, + export_declarations, + resolve_labels, + ) + + model = DecisionTreeClassifier().fit([[0], [1]], [0, 1]) + claims = Adapter().extract_claims(model).model_copy(update={"class_labels": None}) + with pytest.raises(UnsupportedModelError, match="disagree"): + resolve_labels( + Adapter().extract_claims(model), export_declarations([["cold", "warm"]]) + ) + assert resolve_labels(claims, ExportDeclarations()).class_labels == [[0, 1]] + assert resolve_labels( + claims, export_declarations([["cold", "warm"]]) + ).class_labels == [["cold", "warm"]] + for labels in [[[1]], [[1, 1]]]: + with pytest.raises(UnsupportedModelError): + resolve_labels(claims, export_declarations(labels)) + regressor = DecisionTreeRegressor().fit([[0], [1]], [0, 1]) + with pytest.raises(UnsupportedModelError, match="Regressors"): + resolve_labels( + Adapter().extract_claims(regressor), export_declarations([[0, 1]]) + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index c7c8e0f..b968a0d 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -34,6 +34,9 @@ def __init__(self, claims): def recognizes(self, model): return isinstance(model, dict) and "weights" in model + def prepare_export(self, model, declarations): + return model + def serialize(self, model): return canonical_dumps(model) diff --git a/tests/test_xgboost.py b/tests/test_xgboost.py new file mode 100644 index 0000000..02941b4 --- /dev/null +++ b/tests/test_xgboost.py @@ -0,0 +1,600 @@ +"""Native XGBoost round trips, hostile payloads and claim verification together.""" + +import json +import struct +import subprocess +import sys +import warnings + +import numpy as np +import pandas as pd +import pytest +import xgboost as xgb +from sklearn.datasets import load_iris + +import mlbundle +from mlbundle import ( + ClaimMismatchError, + InvalidModelError, + MemberName, + UnsupportedModelError, +) +from mlbundle._xgboost import Adapter +from mlbundle._xgboost.native import REGRESSORS +from mlbundle._xgboost.ubjson import Buffer, decode +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.container import read_container +from mlbundle.declarations import export_declarations +from mlbundle.writing import write_container + + +@pytest.fixture +def data(): + return load_iris(return_X_y=True) + + +def classifier(x, y, **params): + return xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1, **params).fit(x, y) + + +@pytest.mark.parametrize( + "kind,frame,names", + [ + ("binary", False, None), + ("multiclass", True, None), + ("regression", False, list("abcd")), + ("multiclass", False, list("abcd")), + ], +) +def test_roundtrip(kind, frame, names, data, tmp_path): + x, y = data + inputs = pd.DataFrame(x, columns=list("abcd")) if frame else x + labels = [["setosa", "versicolor", "virginica"]] if kind == "multiclass" else None + model = ( + xgb.XGBRegressor(n_estimators=3, max_depth=2, n_jobs=1).fit(inputs, y) + if kind == "regression" + else classifier(inputs, y % 2 if kind == "binary" else y) + ) + before = bytes(model.get_booster().save_raw(raw_format="ubj")) + path = tmp_path / "model.mlbundle" + view = mlbundle.export(model, path, feature_names=names, class_labels=labels) + loaded = mlbundle.load(path) + assert type(loaded.native_model) is type(model) + assert view.manifest == loaded.manifest + assert bytes(model.get_booster().save_raw(raw_format="ubj")) == before + prepared = Adapter().prepare_export(model, export_declarations(labels)) + assert canonical_dumps( + loaded.manifest.verified_claims().model_dump(mode="json") + ) == canonical_dumps(Adapter().extract_claims(prepared).model_dump(mode="json")) + assert canonical_dumps(loaded.structure) == canonical_dumps( + Adapter().extract_structure(prepared) + ) + method = "predict" if kind == "regression" else "predict_proba" + for values in [inputs, x, x.tolist()]: + assert np.array_equal( + getattr(loaded, method)(values), getattr(model, method)(values) + ) + assert np.array_equal(loaded.native_model.predict(inputs), model.predict(inputs)) + assert loaded.class_labels == (None if kind == "regression" else labels or [[0, 1]]) + assert loaded.feature_origin == ( + "model" if frame else "dataframe" if names else "generated" + ) + if frame: + assert loaded.manifest.features.model_dtypes == ["float"] * 4 + + +def test_wrapper_facts_and_in_memory_native_persistence(data, tmp_path): + x, y = data + for labels in [np.array(["a", "b", "c"])[y], np.array([10, 20, 30])[y]]: + with pytest.raises(ValueError, match="Invalid classes inferred"): + classifier(x, labels) + frame = pd.DataFrame(x, columns=list("abcd")) + for cls in [xgb.XGBClassifier, xgb.XGBRegressor]: + model = cls( + n_estimators=3, max_depth=2, learning_rate=0.2, random_state=7, n_jobs=1 + ).fit(frame, y) + booster = model.get_booster() + booster.set_attr(probe='["red","green","blue"]', best_iteration="1") + restored = cls() + restored.load_model(bytearray(booster.save_raw(raw_format="ubj"))) + assert booster.attributes() == restored.get_booster().attributes() + assert booster.feature_names == restored.get_booster().feature_names + assert booster.feature_types == restored.get_booster().feature_types + assert np.array_equal(model.feature_names_in_, restored.feature_names_in_) + assert ( + model.n_features_in_ == restored.n_features_in_ + and model.best_iteration == restored.best_iteration + ) + assert model.get_params() != restored.get_params() + before = json.loads(booster.save_config())["learner"] + after = json.loads(restored.get_booster().save_config())["learner"] + assert before != after + assert before["learner_model_param"] == after["learner_model_param"] + assert before["objective"] == after["objective"] + assert np.array_equal(model.predict(frame), restored.predict(frame)) + if cls is xgb.XGBClassifier: + assert model.n_classes_ == restored.n_classes_ == 3 + assert model.classes_.tolist() == restored.classes_.tolist() == [0, 1, 2] + path = tmp_path / f"{cls.__name__}.ubj" + model.save_model(path) + disk = cls() + disk.load_model(path) + assert np.array_equal(model.predict(frame), disk.predict(frame)) + raw = xgb.Booster() + raw.load_model(bytearray(booster.save_raw(raw_format="ubj"))) + assert raw.attributes() == booster.attributes() + assert np.array_equal( + raw.predict(xgb.DMatrix(frame)), booster.predict(xgb.DMatrix(frame)) + ) + + +@pytest.mark.parametrize( + "labels", + [ + [[0, 0, 1]], + [[0, 1]], + [[0, 1, 2, 3]], + [["a", 1, "c"]], + [[None, None, None]], + [[float("nan"), 1, 2]], + [[{}, {}, {}]], + ], +) +def test_label_rejections(labels, data, tmp_path): + x, y = data + with pytest.raises(UnsupportedModelError): + mlbundle.export( + classifier(x, y), tmp_path / "bad.mlbundle", class_labels=labels + ) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "labels", [[[10, 20, 30]], [[1.0, 2.0, 3.0]], [["", "b", "c"]]] +) +def test_original_label_types(labels, data, tmp_path): + x, y = data + path = tmp_path / "labels.mlbundle" + mlbundle.export(classifier(x, y), path, class_labels=labels) + assert mlbundle.load(path).class_labels == labels + + +def test_unsupported_models(data, tmp_path): + x, y = data + + def custom(labels, predictions): + return predictions - labels, np.ones_like(predictions) + + models = [ + xgb.XGBClassifier(), + xgb.XGBRegressor(), + xgb.XGBRanker(n_estimators=2, n_jobs=1).fit(x, y, qid=np.zeros(len(y))), + xgb.XGBRegressor(n_estimators=2, n_jobs=1, objective=custom).fit(x, y), + classifier(x, y).get_booster(), + ] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + models.extend( + [ + xgb.XGBRFClassifier(n_estimators=2, n_jobs=1).fit(x, y), + xgb.XGBRFRegressor(n_estimators=2, n_jobs=1).fit(x, y), + ] + ) + for model in models: + with pytest.raises(UnsupportedModelError): + mlbundle.export(model, tmp_path / "bad.mlbundle") + regressor = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y) + with pytest.raises(UnsupportedModelError, match="Regressors"): + mlbundle.export(regressor, tmp_path / "bad.mlbundle", class_labels=[[0, 1, 2]]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + linear = xgb.XGBRegressor(booster="gblinear", n_estimators=2, n_jobs=1).fit( + x, y + ) + with pytest.raises(UnsupportedModelError, match="gbtree"): + mlbundle.export(linear, tmp_path / "bad.mlbundle") + multi = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, np.column_stack([y, y])) + with pytest.raises(UnsupportedModelError, match="single target"): + mlbundle.export(multi, tmp_path / "bad.mlbundle") + + +@pytest.mark.parametrize("objective", sorted(REGRESSORS)) +def test_supported_regression_objectives(objective, data, tmp_path): + x, y = data + target = y / 3 + 0.1 if objective == "reg:logistic" else y + 1.0 + model = xgb.XGBRegressor(objective=objective, n_estimators=2, n_jobs=1).fit( + x, target + ) + path = tmp_path / "regression.mlbundle" + mlbundle.export(model, path) + assert np.array_equal(mlbundle.load(path).predict(x), model.predict(x)) + + +def test_prediction_settings_and_dropout(data, tmp_path): + x, y = data + x = x.copy() + x[0, 0] = -99 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model = classifier( + x, + y, + booster="dart", + rate_drop=0.3, + missing=-99, + importance_type="weight", + random_state=7, + ) + model.get_booster().set_attr(best_iteration="1") + path = tmp_path / "dropout.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert loaded.native_model.missing == -99 + assert loaded.native_model.importance_type == "weight" + assert loaded.native_model.best_iteration == 1 + assert np.array_equal(loaded.predict_proba(x), model.predict_proba(x)) + assert np.array_equal( + loaded.structure["feature_importances"], model.feature_importances_ + ) + + +def rewrite(path, change): + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + del members[MemberName.MANIFEST] + change(parts, members) + return write_container(parts, members) + + +@pytest.mark.parametrize( + "change,claim", + [ + ( + lambda m, b: m["target"].update(class_labels=[["c", "b", "a"]]), + "class_labels", + ), + ( + lambda m, b: m["target"].update(class_labels=[["x", "b", "c"]]), + "class_labels", + ), + ( + lambda m, b: ( + m["model"].update(n_classes=[2]), + m["target"].update(class_labels=[["a", "b"]]), + ), + "n_classes", + ), + ( + lambda m, b: ( + m["model"].update(kind="regressor", n_classes=None), + m["target"].update(class_labels=None), + ), + "kind", + ), + ( + lambda m, b: m["model"].update(estimator_class="XGBRegressor"), + "estimator_class", + ), + (lambda m, b: m["features"].update(names=list("dcba")), "feature_names"), + ( + lambda m, b: m["model"]["hyperparameters"]["learner_model_param"].update( + base_score="[99E0]" + ), + "hyperparameters", + ), + ( + lambda m, b: ( + m["references"].update(structure=None), + b.pop(MemberName.STRUCTURE), + ), + "structure", + ), + ], +) +def test_lying_manifest(change, claim, data, tmp_path): + x, y = data + path = tmp_path / "model.mlbundle" + mlbundle.export( + classifier(pd.DataFrame(x, columns=list("abcd")), y), + path, + class_labels=[["a", "b", "c"]], + ) + with pytest.raises(ClaimMismatchError) as error: + mlbundle.load(rewrite(path, change)) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "alter", + [ + lambda s: s["trees"][0].update(split_condition=99.0), + lambda s: s["trees"][0]["children"][0].update(leaf=99.0), + lambda s: s["trees"].pop(), + ], +) +def test_lying_structure(alter, data, tmp_path): + x, y = data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + + def change(m, b): + value = canonical_loads(b[MemberName.STRUCTURE]) + alter(value) + b[MemberName.STRUCTURE] = canonical_dumps(value) + + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, change)) + + +def test_swapped_model(data, tmp_path): + x, y = data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + different = Adapter().prepare_export( + classifier(x, (y + 1) % 3), export_declarations(None) + ) + with pytest.raises(ClaimMismatchError): + mlbundle.load( + rewrite( + path, + lambda m, b: b.update( + {MemberName.UBJSON: Adapter().serialize(different)} + ), + ) + ) + + +@pytest.mark.parametrize( + "payload", + [ + b"random bytes", + b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{", + b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{}}", + b"{}", + b"PK\x03\x04other library", + ], +) +def test_hostile_payloads(payload, data, tmp_path): + x, y = data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + with pytest.raises(mlbundle.MLBundleError): + mlbundle.load( + rewrite( + path, + lambda m, b, payload=payload: b.update({MemberName.UBJSON: payload}), + ) + ) + + +def test_native_payload_rejections(data, tmp_path, monkeypatch): + x, y = data + model = classifier(x, y) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + native = bytes(model.get_booster().save_raw(raw_format="ubj")) + for payload in [ + native[: len(native) // 2], + bytes(model.get_booster().save_raw(raw_format="json")), + native, + ]: + with pytest.raises(InvalidModelError): + Adapter().deserialize(payload) + with pytest.raises(InvalidModelError): + mlbundle.load( + rewrite( + path, + lambda m, b, payload=payload: b.update( + {MemberName.UBJSON: payload} + ), + ) + ) + prepared = Adapter().prepare_export(model, export_declarations(None)) + native = Adapter().serialize(prepared) + monkeypatch.setattr( + xgb.Booster, + "load_model", + lambda *args: (_ for _ in ()).throw( + xgb.core.XGBoostError("untrusted huge message") + ), + ) + with pytest.raises(InvalidModelError, match="UBJSON payload") as error: + Adapter().deserialize(native) + assert "untrusted" not in str(error.value) + + +def test_lazy_import_and_missing_extra(data, tmp_path, monkeypatch): + x, y = data + path = tmp_path / "model.mlbundle" + model = classifier(x, y) + mlbundle.export(model, path) + code = "import sys,mlbundle; mlbundle.inspect(sys.argv[1]); assert not {'sklearn','skops','numpy','pandas','xgboost','onnx','onnxruntime'} & sys.modules.keys(); print('lazy import and inspect: OK')" + result = subprocess.run( + [sys.executable, "-c", code, str(path)], + capture_output=True, + text=True, + check=True, + ) + assert "lazy import and inspect: OK" in result.stdout + original = mlbundle.registry.import_module + + def missing(name): + if name == "mlbundle._xgboost": + raise ModuleNotFoundError(name="xgboost") + return original(name) + + monkeypatch.setattr(mlbundle.registry, "import_module", missing) + for action in [lambda: mlbundle.export(model, path), lambda: mlbundle.load(path)]: + with pytest.raises( + mlbundle.MissingOptionalDependencyError, match="mlbundle\\[xgboost\\]" + ): + action() + + +def test_prediction_validation(data, monkeypatch): + x, y = data + model = classifier(x, y) + predictor = Adapter().predictor(model) + with pytest.raises(ValueError, match="width"): + predictor.predict_proba([[0]]) + with pytest.raises(ValueError, match="regressor"): + predictor.predict(x) + for output in [[[float("nan")] * 3], [[0.5] * 3], [[1.1, -0.1, 0]], [[1.0]]]: + monkeypatch.setattr( + model, "predict_proba", lambda values, output=output: output + ) + with pytest.raises(InvalidModelError): + predictor.predict_proba([x[0]]) + regressor = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y) + predictor = Adapter().predictor(regressor) + with pytest.raises(ValueError, match="classifier"): + predictor.predict_proba(x) + monkeypatch.setattr( + regressor, "predict", lambda values: [float("inf")] * len(values) + ) + with pytest.raises(InvalidModelError): + predictor.predict(x) + + +def ubjson(value): + if isinstance(value, Buffer): + return ( + b"[$" + + value.marker.encode() + + b"#L" + + struct.pack(">q", value.count) + + bytes(value.data) + ) + if isinstance(value, dict): + return ( + b"{" + + b"".join( + b"L" + + struct.pack(">q", len(key.encode())) + + key.encode() + + ubjson(item) + for key, item in value.items() + ) + + b"}" + ) + if isinstance(value, list): + return b"[" + b"".join(ubjson(item) for item in value) + b"]" + if isinstance(value, str): + return b"SL" + struct.pack(">q", len(value.encode())) + value.encode() + return ubjson_scalar(value) + + +def ubjson_scalar(value): + if type(value) is bool: + return b"T" if value else b"F" + if type(value) is int: + return b"L" + struct.pack(">q", value) + if type(value) is float: + return b"D" + struct.pack(">d", value) + assert value is None + return b"Z" + + +@pytest.mark.parametrize( + "change", + [ + lambda learner: learner["learner_model_param"].update(num_feature="100001"), + lambda learner: learner["learner_model_param"].update(num_class="100001"), + lambda learner: learner["gradient_booster"]["model"][ + "gbtree_model_param" + ].update(num_trees="100001"), + lambda learner: learner["gradient_booster"]["model"]["trees"][0][ + "tree_param" + ].update(num_nodes="999999"), + lambda learner: learner["gradient_booster"]["model"]["trees"][0].update( + left_children=[999999] * 5 + ), + lambda learner: learner["objective"].update(name="rank:pairwise"), + lambda learner: learner["gradient_booster"]["model"].update(tree_info=[-1] * 9), + lambda learner: learner["gradient_booster"]["model"].update( + tree_info=[900000000] * 9 + ), + lambda learner: learner["gradient_booster"]["model"].update(tree_info=[0]), + lambda learner: learner["gradient_booster"]["model"].update( + iteration_indptr=[0, 999999999] + ), + ], +) +def test_preflight_rejects_before_native_parser(change, data, monkeypatch): + x, y = data + value = decode(bytes(classifier(x, y).get_booster().save_raw(raw_format="ubj"))) + change(value["learner"]) + calls = [] + monkeypatch.setattr(xgb.Booster, "load_model", lambda *args: calls.append(True)) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(ubjson(value)) + assert not calls + + +def test_binding_validation_and_limits(data, tmp_path): + x, y = data + model = Adapter().prepare_export(classifier(x, y), export_declarations(None)) + binding = canonical_loads(model.get_booster().attr("mlbundle").encode()) + for change in [ + lambda v: v.update(estimator_class="os.system"), + lambda v: v.update(class_labels=None), + lambda v: v["prediction"].update(extra="bad"), + lambda v: v["prediction"].update(enable_categorical="yes"), + ]: + value = json.loads(json.dumps(binding)) + change(value) + model.get_booster().set_attr(mlbundle=canonical_dumps(value).decode()) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(bytes(model.get_booster().save_raw(raw_format="ubj"))) + for limits in [ + mlbundle.ArchiveLimits(max_member_bytes=1), + mlbundle.ArchiveLimits(max_structure_bytes=1), + ]: + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export(classifier(x, y), tmp_path / "bad.mlbundle", limits=limits) + assert not list(tmp_path.iterdir()) + + +def test_readme_example(tmp_path): + rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] + model = xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit( + rows, [0, 1, 0, 1] + ) + path = tmp_path / "xgboost.mlbundle" + mlbundle.export( + model, + path, + feature_names=["temperature", "humidity"], + class_labels=[["cold", "warm"]], + ) + bundle = mlbundle.load(path) + assert isinstance(bundle.native_model, xgb.XGBClassifier) + assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() + assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() + assert bundle.class_labels == [["cold", "warm"]] + + +def test_other_library_model_bytes(data, tmp_path): + from sklearn.tree import DecisionTreeClassifier + + from mlbundle._sklearn import Adapter as SklearnAdapter + + x, y = data + path = tmp_path / "xgboost.mlbundle" + mlbundle.export(classifier(x, y), path) + other = SklearnAdapter().serialize(DecisionTreeClassifier().fit(x, y)) + with pytest.raises(InvalidModelError, match="UBJSON"): + mlbundle.load(rewrite(path, lambda m, b: b.update({MemberName.UBJSON: other}))) + + +def test_lying_regressor_kind(data, tmp_path): + x, y = data + path = tmp_path / "regressor.mlbundle" + mlbundle.export(xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y), path) + + def change(manifest, members): + manifest["model"].update(kind="classifier", n_classes=[2]) + manifest["target"].update(class_labels=[[False, True]]) + + with pytest.raises(ClaimMismatchError, match="kind"): + mlbundle.load(rewrite(path, change)) From 4fe44e841f7844d733e110166c34c4f5fa659a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 00:53:28 +0200 Subject: [PATCH 05/15] feat(onnx): add validated native bundles (#6) --- AGENTS.md | 18 +- README.md | 51 +++- SECURITY.md | 41 ++- docs/format-adapters.md | 188 +++++++++++++ docs/format-container.md | 138 +++++++++ docs/format-onnx.md | 150 ++++++++++ docs/format.md | 340 +--------------------- src/mlbundle/_onnx/__init__.py | 93 ++++++ src/mlbundle/_onnx/description.py | 243 ++++++++++++++++ src/mlbundle/_onnx/prediction.py | 138 +++++++++ src/mlbundle/_onnx/protobuf.py | 95 +++++++ src/mlbundle/_onnx/session.py | 87 ++++++ src/mlbundle/_onnx/signatures.py | 63 +++++ src/mlbundle/_onnx/validation.py | 164 +++++++++++ tests/conftest.py | 7 + tests/onnx_helpers.py | 178 ++++++++++++ tests/test_onnx.py | 213 ++++++++++++++ tests/test_onnx_bounds.py | 131 +++++++++ tests/test_onnx_claims.py | 143 ++++++++++ tests/test_onnx_graphs.py | 124 ++++++++ tests/test_onnx_prediction.py | 245 ++++++++++++++++ tests/test_onnx_security.py | 201 +++++++++++++ tests/test_xgboost.py | 450 +----------------------------- tests/test_xgboost_claims.py | 123 ++++++++ tests/test_xgboost_payload.py | 119 ++++++++ tests/test_xgboost_validation.py | 154 ++++++++++ tests/xgboost_helpers.py | 61 ++++ 27 files changed, 3178 insertions(+), 780 deletions(-) create mode 100644 docs/format-adapters.md create mode 100644 docs/format-container.md create mode 100644 docs/format-onnx.md create mode 100644 src/mlbundle/_onnx/__init__.py create mode 100644 src/mlbundle/_onnx/description.py create mode 100644 src/mlbundle/_onnx/prediction.py create mode 100644 src/mlbundle/_onnx/protobuf.py create mode 100644 src/mlbundle/_onnx/session.py create mode 100644 src/mlbundle/_onnx/signatures.py create mode 100644 src/mlbundle/_onnx/validation.py create mode 100644 tests/onnx_helpers.py create mode 100644 tests/test_onnx.py create mode 100644 tests/test_onnx_bounds.py create mode 100644 tests/test_onnx_claims.py create mode 100644 tests/test_onnx_graphs.py create mode 100644 tests/test_onnx_prediction.py create mode 100644 tests/test_onnx_security.py create mode 100644 tests/test_xgboost_claims.py create mode 100644 tests/test_xgboost_payload.py create mode 100644 tests/test_xgboost_validation.py create mode 100644 tests/xgboost_helpers.py diff --git a/AGENTS.md b/AGENTS.md index ebcdb4b..1bb1563 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,11 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phases 1–4a implement the version 1 format contract, strict manifest models, +Phases 1–4b implement the version 1 format contract, strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing, public metadata-only `inspect`, export/load, -and the scikit-learn and XGBoost adapters. The ONNX adapter, schema inference and -the CLI remain unimplemented. +and the scikit-learn, XGBoost and ONNX adapters. Schema inference and the CLI +remain unimplemented. Rules for later phases must not be described as implemented until code and tests prove them. `docs/format.md` owns the wire contract. @@ -212,6 +212,13 @@ class labels supplied through output-major `class_labels=` are bound to a native copy through an mlbundle-owned booster attribute and verified on load. Omitted labels use encoded indices; sklearn rejects this option because it owns classes_. Schema inference is future work; export currently includes no training rows. +ONNX preserves path/bytes unchanged (ModelProto is serialized once), rejects +external tensors, custom domains, functions and training/runtime configuration +before a CPU session, and verifies graph signatures, metadata and environment +claims. `native_model` is an OnnxModel with original bytes and a CPU session. +Kind is inferred from supported outputs; native labels are verified, absent +labels use count-checked declarations. Structure is null. See the normative +ONNX section linked from `docs/format.md` for exact layouts and resource bounds. The closed member list is: required `manifest.json`, exactly one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional @@ -258,13 +265,14 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. - `src/mlbundle/_sklearn/` — skops trust/loading, parameter and claim extraction, native structure, nested payload bounds and validated prediction. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. +- `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. - `src/mlbundle/declarations.py` — export-time class labels and resolution for native models without labels. - `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. - `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. - `src/mlbundle/integrity.py` — member SHA-256/size records and verification. - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. -- `src/mlbundle/registry.py` — closed lazy adapter resolution and recognition; `_sklearn` and `_xgboost` are implemented, `_onnx` remains a future module. +- `src/mlbundle/registry.py` — closed lazy resolution of implemented `_sklearn`, `_xgboost` and `_onnx` adapters. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. @@ -272,7 +280,7 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. - `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. - `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. -- `docs/format.md` — version 1 wire specification and implemented sklearn mapping/trust rules. +- `docs/format.md` — version 1 wire specification, with normative container, adapter/structure and ONNX sections in linked files. - `tests/` — one file per feature, with success and error cases together. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. diff --git a/README.md b/README.md index 063adcd..680ef9e 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ its internal structure in one compressed `.mlbundle` file. The same package writes bundles on the training machine and reads them with validation in a runtime. Local schema inference will be added in a later phase. -**Status: pre-alpha, Phase 4a.** The [version 1 format contract](docs/format.md), +**Status: pre-alpha, Phase 4b.** The [version 1 format contract](docs/format.md), strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing and public `inspect` -and public sklearn/XGBoost `export`/`load` are implemented and tested. The ONNX -adapter, schema inference and the CLI are still missing. Nothing has been +and public sklearn/XGBoost/ONNX `export`/`load` are implemented and tested. +Schema inference and the CLI are still missing. Nothing has been published to PyPI. ## Why it exists @@ -45,7 +45,7 @@ operation works without deserializing the model or importing model libraries. Hashes detect corruption; they do not authenticate the author or prevent deliberate tampering. Signing is outside the project's scope. These guarantees -cover container validation and sklearn/XGBoost claim recomputation; native parsers and +cover container validation and all three adapters' claim recomputation; native parsers and prediction still require a consuming runtime's isolation and resource limits. See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. @@ -116,6 +116,44 @@ objectives in [docs/format.md](docs/format.md) are covered. Native gbtree/dropou is supported; gblinear, RF wrappers, raw boosters, ranking, survival, custom objectives and multi-target models are rejected. +Existing self-contained ONNX files travel unchanged, without a converter. Export +also accepts bytes or a ModelProto (serialized once). This executable example +creates a small probability graph; production callers can pass their existing file: + +```python +from pathlib import Path +from onnx import TensorProto, helper +from mlbundle import export, load + +graph = helper.make_graph( + [helper.make_node("Softmax", ["features"], ["probabilities"], axis=1)], + "classifier", + [helper.make_tensor_value_info("features", TensorProto.FLOAT, [None, 2])], + [helper.make_tensor_value_info("probabilities", TensorProto.FLOAT, [None, 2])], +) +model = helper.make_model( + graph, opset_imports=[helper.make_opsetid("", 21)], ir_version=10 +) +original = model.SerializeToString() +Path("trained.onnx").write_bytes(original) +export("trained.onnx", "onnx.mlbundle", class_labels=[["cold", "warm"]]) +bundle = load("onnx.mlbundle") +assert bundle.native_model.data == original +assert bundle.class_labels == [["cold", "warm"]] +assert bundle.predict_proba([[0.0, 0.0]]) == [[0.5, 0.5]] +``` + +ONNX native results contain the original `data` and a CPU `session` for native +inference. Numeric matrix inputs and one-input-per-column mixed numeric graphs +are supported. Classifiers return tensor or ZipMap probabilities; regressors +return one value per row. Kind is inferred from supported output layouts. Native +labels are verified; `class_labels=` on label-free graphs is a count-checked +declaration, and omitted labels become encoded indices. No labels are bound into +the unchanged file. Ambiguous layouts, external data, custom domains/functions +and training/runtime configuration are rejected before session creation. +ONNX has no visualization structure. See [ONNX support](docs/format-onnx.md) for +exact shapes, claim fields and native dependency limits. + ## Development Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows @@ -138,9 +176,8 @@ uv build The default dev group installs all model extras. Base consumers can use `uv sync --no-dev` without installing model libraries. The committed `uv.lock` fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX -Runtime to the target MLSuite runtime baseline; this is not yet evidence of -compatibility for the future ONNX adapter; sklearn/XGBoost compatibility is exercised by -the round-trip tests. Pandas is a development dependency for DataFrame tests, +Runtime to the target MLSuite runtime baseline; all three adapters are exercised +by the round-trip tests. Pandas is a development dependency for DataFrame tests, not a core runtime dependency. `uv run pytest` produces terminal and XML coverage reports and enforces an 80% minimum. CI runs lint, type checks, and Python 3.14 tests on Ubuntu and Windows. `uv build` creates diff --git a/SECURITY.md b/SECURITY.md index 1580b71..38f0edf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,15 +1,14 @@ # Security policy -mlbundle is pre-alpha. Phases 1–4a implement strict manifest validation, +mlbundle is pre-alpha. Phases 1–4b implement strict manifest validation, contract helpers, secure container reading/writing, metadata-only `inspect`, -public export/load and the sklearn and XGBoost adapters. +public export/load and the sklearn, XGBoost and ONNX adapters. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. The writer publishes complete validated files atomically where hard links work; the exclusive-creation fallback cleans up on failure but exposes the destination while copying and cannot guarantee crash-atomic publication. The version 1 contract -is defined in [docs/format.md](docs/format.md). ONNX loading remains -unimplemented; its security requirements remain obligations for a later phase. +is defined in [docs/format.md](docs/format.md). ## Threat model @@ -116,6 +115,40 @@ can inspect its returned byte size. Server consumers must isolate loading and prediction and enforce process resource limits. See the exact support and resource boundaries in [docs/format.md](docs/format.md). +## Implemented ONNX dependency boundary + +Original self-contained ONNX bytes are parsed and loaded in memory. Before any +ONNX Runtime session, a descriptor-driven protobuf scan bounds nesting and field +counts, validates lengths and rejects unknown/duplicate singular fields. A full +message traversal rejects every external tensor reference, including initializers, +attributes, sparse tensors, nested graphs and functions; checker and runtime never +receive external-data models. Tensor dimensions/storage and graph ranks/nodes are +bounded, and stored data must agree with declared element counts. Local functions, +training/device configuration, node overloads, overridable initializers and +`ort_config` metadata are rejected. Operators are confined to standard ONNX and +ONNX-ML domains and must pass the checker and CPU runtime. No custom op library +is registered. See [the normative ONNX rules](docs/format-onnx.md) for exact limits. + +Sessions use CPU only, sequential execution, one thread per pool and disabled +optimization/prepacking, profiling, file output, arena and memory patterns. +Model-provided session configuration is disabled. The host must not enable +`ORT_LOAD_CONFIG_FROM_MODEL=1`; the pinned runtime rejects that combination. +All native exceptions exposed through the API become bounded mlbundle errors. +No model file is converted, rewritten, extracted or used to select a disk path. +Graph signatures, metadata, kind/counts, model dtypes, producer/opsets/IR and native +labels are recomputed and canonically compared. A label-free graph's original +class mapping remains an author declaration, checked only against native counts. +ONNX structure is null; an added visualization member is a claim mismatch. + +The protobuf parser, ONNX checker and ONNX Runtime execute native code. Resource +checks reduce malformed inputs; they do not establish memory safety or cap native +allocations, constant processing, runtime-computed shapes or control-flow execution. +Consumers must isolate loading/prediction and enforce memory, CPU and row limits, +and maintain their native dependencies. Optional extras retain this dependency +boundary: importing mlbundle or inspecting any bundle imports no model library. +The native result contains the exact bytes and CPU session needed for inference; +mutating a session invalidates its verified relationship with metadata. + ## Limits of these guarantees SHA-256 hashes detect corruption, not deliberate tampering: an attacker can diff --git a/docs/format-adapters.md b/docs/format-adapters.md new file mode 100644 index 0000000..6aa493a --- /dev/null +++ b/docs/format-adapters.md @@ -0,0 +1,188 @@ +# Version 1 structure, adapters and results + +Normative sections of [the format contract](format.md). + +## `structure.json` + +Structure is never embedded in the manifest. It is a JSON object with required +`family` and `feature_importances` fields, plus exactly the family fields below. +`feature_importances` is null or an ordered numeric array of length `n_features`. +Numbers use the canonical encoding, including non-finite tags. Arrays and tree +lists preserve native order; no pruning, rounding, flattening of output/class +dimensions or display-oriented simplification is permitted. + +| `family` | Additional required fields | +| --- | --- | +| `decision_tree` | `tree`: one tree object. | +| `tree_ensemble` | `estimator_shape`: original shape of the ensemble's estimator collection; `trees`: row-major flattened list of tree objects. Product of shape equals list length. A one-dimensional collection uses `[N]`. | +| `linear` | `coef`, `intercept`: native numeric arrays/scalars converted without reshaping. | +| `xgboost` | `trees`: ordered list of parsed JSON tree dumps, each from `get_dump(dump_format="json", with_stats=True)`. Retain all native fields, including node IDs, split/leaf values, missing-child IDs, gain/cover and children. Do not embed dump strings. | +| `feature_importances` | No additional fields; the importance array must be non-null. | + +A scikit-learn tree object has exactly `node_count` (positive integer), +`n_features` (positive integer), `n_outputs` (positive integer), `n_classes` +(native per-output class-count array), and these native `tree_` arrays: +`children_left`, `children_right`, `feature`, `threshold`, `value`, `impurity`, +`n_node_samples`, `weighted_n_node_samples`. Except `value`, each array's length +is `node_count`; `value` retains its native `[node, output, class/value]` shape. +Preserve leaf and undefined-feature sentinel values, and sklearn's native value +normalization for the installed version. Do not reinterpret `value` as counts. +Trees use each constituent tree's own dimensions, not an ensemble-wide guess. + +Select family in this precedence: a single `tree_`, a tree ensemble, linear +coefficients/intercept, then feature importances only. An ensemble exposing +unsupported non-tree constituents must not masquerade as a supported tree +ensemble. Unsupported or oversized mandatory structure fails export rather than +being silently omitted. ONNX graph visualizations and additional structure +families require a deliberate future contract change. + +Sklearn `Pipeline` returns null structure in version 1. Its final estimator +operates on transformed features; version 1 has no input transformation mapping, +so no final-step tree or coefficient member is emitted. Claims, names and +predictions still describe the complete pipeline. Standalone DecisionTree, +RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, LinearRegression +and Ridge retain their native structures as specified above. HistGradientBoosting +is currently rejected: its `TreePredictor` storage is not on mlbundle's trust +allowlist. No approximate trees are emitted for it. + +## Adapter and result contracts + +The internal `Adapter` protocol in `mlbundle.adapters` owns recognition, +export preparation, serialization, loading, claim extraction, structure +extraction and a `Predictor`. `prepare_export(model, ExportDeclarations)` returns +the object to serialize and extract, leaving the caller's model unchanged. +`ExportDeclarations` is a strict frozen record with `class_labels` only. Adapters +bind or reject declarations according to their native contracts; neither +`deserialize` nor extraction receives manifest fields. The shared pipeline +resolves labels only when extraction returns null labels for a classifier. +The registry imports only the three constant mlbundle module paths `_sklearn`, +`_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library +imports must be lazy, with dependency failures naming the extra to install. +Missing adapter modules raise `AdapterNotImplementedError` on load; export skips +unimplemented adapters while looking for one that recognizes its input. A missing +optional dependency reports `pip install "mlbundle[EXTRA]"`. An unrecognized +object raises `UnsupportedModelError`. + +`export(model, destination, *, feature_names=None, class_labels=None, target_name=None, metadata=None, +overwrite=False, limits=None)` returns a small `MetadataView` of the written +bundle. It serializes, extracts, reloads and compares both claims and structure +before writing. Model-exposed names take precedence over supplied names. It +captures distribution metadata, including `claims.onnx` in the environment for +the ONNX adapter. No unimplemented schema/data parameters are accepted. +`load(source, *, limits=None)` validates the container, resolves its closed enum, +deserializes, recomputes claims/structure and compares canonical bytes before +constructing a `LoadedBundle`. `ClaimMismatchError` identifies the claim without +echoing values; absent/present structure disagreements are checked separately, +including a present JSON null. Malformed native payloads raise `InvalidModelError`, +an `MLBundleError` subclass, without native exception text. + +The sklearn adapter uses only `skops.io.dumps/loads`. Its fixed supplemental +trusted list is exactly `["sklearn.tree._tree.Tree"]`, for numeric node storage +in the supported tree families; it adds no callable, arbitrary class or I/O type. +Skops' own defaults remain part of the dependency trust boundary. Dumped bytes +are audited with `get_untrusted_types`; every type outside the list is rejected +at export and independently at load. XGBoost estimators inside sklearn pipelines +are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). +The native XGBoost adapter owns standalone XGBoost loading. +The nested skops archive is bounded before parsing; every schema-referenced +numeric array header is checked regardless of filename suffix. Headers must +match the stored byte sizes and cannot use object-array loading or zero-itemsize +dtypes. Tree constructor dimensions/class counts and state-array shapes are +bounded before native construction. Loaded tree dimensions, child/feature indices, +reachability and depth are checked before native feature-importance traversal or +prediction, and input/output/class counts must agree with owning estimators. +Ensemble feature mappings must address the parent input width. Every loaded root object is +checked again for classifier/regressor identity, fitted state and probability +support. Multilabel classifiers with independent positive-class columns and +classifier chains are rejected because their probabilities cannot satisfy the +output-major contract; ordinary multi-output probability-matrix lists are supported. + +### Implemented XGBoost mapping + +Only exact `XGBClassifier` and `XGBRegressor` classes are supported. Subclasses, +ranking, survival, custom objectives, raw `Booster`, and the deprecated +`XGBRFClassifier`/`XGBRFRegressor` wrappers are rejected. Raw boosters do not +carry enough wrapper identity or custom-objective history for this narrow +training-side boundary; RF wrappers add no required v0.1 contract. Supported +classifier objectives are `binary:logistic` and `multi:softprob`. Supported +regression objectives are `reg:squarederror`, `reg:absoluteerror`, +`reg:pseudohubererror`, `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, +`reg:gamma`, and `reg:tweedie`. Only one target is supported. Binary classifiers +return two probability columns; softmax-only objectives are rejected. + +Only native `gbtree` storage is admitted. In XGBoost 3.4.1, the deprecated `dart` +option uses `gbtree` storage with dropout; that representation is supported and +all native dump statistics are retained. Legacy native `dart` storage is rejected. +`gblinear` is rejected: its multi-class coefficient/importance dimensions and +deprecated format are outside this phase's tree contract. There is no silently +omitted structure for an admitted XGBoost model. + +Serialization is exclusively `bytes(booster.save_raw(raw_format="ubj"))` and +`load_model(bytearray(data))`, in memory. Class selection uses the closed adapter +map and the `mlbundle` booster attribute, never the manifest. That attribute is +exactly `{ "estimator_class": CLASS, "class_labels": LABELS_OR_NULL, +"prediction": SETTINGS }` encoded canonically. `SETTINGS` has exactly `missing` +(a finite binary64 value or the canonical NaN tag), `importance_type` (one of +`gain`, `weight`, `cover`, `total_gain`, `total_cover`; omitted native option +normalizes to `gain`), and `enable_categorical` (boolean). These settings survive +loading and are compared with the wrapper. Other booster attributes, including +`best_iteration`, survive unchanged; prediction respects native best iteration. + +`hyperparameters` has exactly these four keys: + +```json +{ + "learner_model_param": {"base_score":"[5E-1]","boost_from_average":"1","num_class":"0","num_feature":"4","num_target":"1"}, + "objective": {"name":"binary:logistic","reg_loss_param":{"scale_pos_weight":"1"}}, + "gradient_booster": {"name":"gbtree","gbtree_model_param":{"num_parallel_tree":"1","num_trees":"3"}}, + "prediction": {"missing":{"$float":"nan"},"importance_type":"gain","enable_categorical":true} +} +``` + +The first three records are a projection of the parsed native `save_config()`: +retain every field in `learner_model_param`, `objective`, and +`gradient_booster.gbtree_model_param`, plus the booster name, with native value +types/spellings unchanged. `prediction` is the bound wrapper settings record. +Neither `get_params()` nor the whole config survives native model I/O. Training +settings such as learning rate, max depth, seed, threads and metrics are therefore +not claimed or reconstructed. Export compares this projection and the complete +structure before and after making the native copy, and again through the shared +serialization round trip. Unsupported unrecoverable claims fail publication. + +Booster feature names and feature types become verified claims; absent values +are null. Structure is exactly the `xgboost` family above, with the wrapper's +native float32-normalized importance vector for the bound importance type. +Prediction values and structure scalars promote float32 exactly to binary64. +Claim/structure comparisons use exact canonical bytes; probability row-sum +validation allows `rtol=1e-6`, `atol=1e-7` for float32 arithmetic. + +Before native parsing, the adapter bounds bytes, recognizes the native UBJSON +prefix, validates the complete bounded document, and checks feature/class/target, +tree and node-array dimensions, output-group indices, iteration pointers and +child/feature indices. Numeric arrays remain +byte views. At most 100,000 trees are supported; reachable tree depth is at most +14 so the complete nested dump fits the existing JSON depth ceiling of 32. +Cycles, aliases and unreachable nodes are rejected. Native dump generation is +count-bounded; total dump bytes and canonical structure bytes must also fit +256 MiB and caller-selected lower limits. No truncation is permitted. These +checks do not sandbox the native parser or cap its internal allocations. + +Predictors accept adapter-supported tabular objects without a core pandas/numpy +dependency. Regressors return `list[float]` for a single output or row-major +`list[list[float]]` for multiple outputs. Classifiers return row-major +`list[list[float]]` for one output or an output-major list of those matrices for +multiple outputs, aligned with class labels. Validate probability dimensions, +finite probabilities, range and row sums in the adapter. Classifier prediction +uses `predict_proba`; regressor prediction uses `predict`. Unsupported probability +or regression contracts fail rather than synthesizing outputs. + +`MetadataView` carries `manifest` and a tuple of `MemberName` values including +the manifest; it has no model. `LoadedBundle` additionally carries the exact +native object, a uniform predictor, optional schema/structure/sample values, and +properties for kind, library, class name, ordered feature names and origin, and +output-major original class labels. Calling the prediction method for the wrong +kind raises a bounded `ValueError`. Result records and typed models prevent field +reassignment, but nested JSON values and the native model are not deeply frozen. +Construction itself performs no model validation; `load` supplies the loaded-result +guarantee. Consumers must not mutate a validated +model and continue treating its original metadata as verified. diff --git a/docs/format-container.md b/docs/format-container.md new file mode 100644 index 0000000..5f65b95 --- /dev/null +++ b/docs/format-container.md @@ -0,0 +1,138 @@ +# Version 1 container rules + +Normative section of [the format contract](format.md). + +## Container and resource limits + +A bundle is one ZIP file with the `.mlbundle` extension. Its root contains only +the case-sensitive names below. Names are ASCII, with no directory entries, +slashes, backslashes, `..`, NULs or alternative spellings. Unknown or duplicate +names, symlinks, other non-regular entries, encrypted entries, unsupported +compression methods, multi-disk archives and malformed ZIP metadata must be +rejected. Only ZIP_STORED (method 0) and ZIP_DEFLATED (method 8) are allowed. +ZIP64 is allowed within the limits, including forced ZIP64 local records for +small files. Only a single ZIP64 extra field (ID 0x0001) is permitted in a header, +and it must contain exactly the replacement values required by that header's +sentinel fields, in ZIP order. Reject other, redundant, duplicate or malformed +extra fields. ZIP64 end records have the standard fixed 44-byte body, with no +extensible data sector; their locator and ordinary end record must agree. + +Data descriptors are allowed for streaming ZIP writers. Accept the signed and +unsigned 32-bit and ZIP64 forms only when CRC and both sizes match the central +directory. With a descriptor, local CRC/sizes may be zero or match the directory; +ZIP64 sentinel sizes must be resolved through the local ZIP64 extra field. +Without a descriptor, local CRC and sizes must match the directory exactly. +The version-needed field is 10 (stored only), 20 or 45; ZIP64 headers use 45. +Allowed general-purpose flags are the UTF-8 name flag, the descriptor flag and +the two deflate-option bits (deflated entries only). Reject all other flags. + +Reject archive and member comments. The first local header starts at byte zero; +local records, payloads and any descriptors cover the archive contiguously up +to the central directory. The directory ends exactly at the end record or +ZIP64 end record, followed only by its locator and the ordinary end record. +Reject prepended/appended bytes, gaps, overlapping records and unlisted local +records. Central entries may list local records in another order. These rules +exclude polyglot wrappers and metadata the mlbundle writer never produces. +File attributes may omit a Unix file type (as ordinary ZIP writers do); an +explicit type must be regular, and the DOS directory bit must be clear. + +Readers must bound the source before buffering it: stat filesystem paths and +read streams in bounded chunks with at most one excess byte for detection. +Streams are consumed from their current position and are not closed by mlbundle. +Readers must read named members into memory, never extract them to disk. They +must inspect directory metadata and enforce limits before reading, allocating +member buffers or loading models; they must also limit actual bytes produced +during decompression, rather than trusting directory sizes. Reject inconsistencies +between headers, directory entries, actual sizes and ZIP CRC checks. No path, +module name, trusted-types list or external file reference can select loading +code. ONNX external tensor data is unsupported: the original file must be +self-contained. Native model formats do not sandbox their parsers or execution. +Decompression uses chunks of at most 64 KiB and a one-byte lookahead beyond the +declared output size; the lookahead is rejected before it is retained. Require +the exact output size, CRC and complete deflate stream, with no trailing packed +data. Declared sizes are checked against member, aggregate and ratio limits first. + +One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: + +| Limit | Maximum | +| --- | ---: | +| Member count, including manifest | 5 (minimum configured value 2) | +| Entire archive, including headers | 512 MiB | +| Sum of compressed member sizes | 512 MiB | +| Any uncompressed member | 512 MiB | +| Sum of all uncompressed member sizes, including manifest | 1 GiB | +| Each member's uncompressed/compressed ratio | 200 | +| `manifest.json` | 8 MiB | +| `structure.json` | 256 MiB | +| `schema.json` | 16 MiB | +| `samples.json` | 16 MiB | +| JSON container nesting depth | 32 | + +The effective member cap is the smaller of its specific cap and the general +member cap. Check the ratio as `uncompressed_size <= 200 * compressed_size`; +an empty member with both sizes zero is exempt, a nonempty member with compressed +size zero is invalid. This applies to stored members too. The mlbundle writer +always uses deflate level 6 with the RLE strategy and rejects inputs exceeding the ratio ceiling; +the reader also accepts stored entries from other tools. These limits admit +forests whose native model is tens of MiB and whose JSON tree arrays are larger. +Callers may lower archive limits, never raise the ceilings in version 1. +Consumers still need process memory, CPU and prediction limits; a 1 GiB byte +budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. + +### Container functions and inspection + +The internal functions are `mlbundle.container.read_container(source, *, +limits=None)` and `mlbundle.writing.write_container(manifest, members, *, +destination=None, overwrite=False, limits=None)`. Reading returns a validated +`Manifest` and a dictionary of original uncompressed bytes keyed by `MemberName`, +including `manifest.json`. Source is a path, bytes or a binary stream. It never +deserializes a native model or resolves an adapter. + +Writing accepts a `Manifest` or JSON-ready manifest parts, plus non-manifest +member bytes. The writer computes and replaces all integrity records; callers +must already set the model pairing and optional references. JSON-ready parts +may omit integrity. Optional JSON members pass the bounded canonical decoder; +their original bytes are preserved. The container validates JSON syntax and +bounds, while later model/schema APIs own semantic structure and schema checks. +Metadata inspection does not infer whether a model requires structure. + +The writer emits manifest, model, schema, structure, samples in that fixed order, +omitting absent members. ZIP timestamps are 1980-01-01 00:00:00, the creating +platform is Unix, permissions are regular-file 0600, compression is deflate +level 6 with the RLE strategy, memory level 8 and 64 KiB input chunks, and +extra fields/comments are empty. RLE avoids the different general match-search +algorithms used by zlib and zlib-ng across platforms; it compresses repeated-byte +runs and uses Huffman coding for other bytes. It creates no descriptors or ZIP64 +records within the version 1 ceilings. Identical inputs produce identical bytes +on Windows and Linux; the compression implementation is the standard zlib +provided by Python (byte identity across different future zlib implementations +is not a format requirement). +Compression output is bounded by the remaining archive and aggregate compressed +budgets, reserving the fixed header/directory overhead before compression. + +Writing returns bytes and optionally publishes them to a path after validating +the complete archive through the reader. Publication uses a flushed temporary +file in the destination directory: atomic replacement when overwrite is true, +or atomic hard-link creation otherwise, followed by temporary-file cleanup. +When hard links are unsupported, publication uses exclusive destination creation, +flush and fsync, removing its own destination on failure. This fallback prevents +overwrites and leaves no partial file after a reported failure, but the destination +is visible while copying; it is not an atomic visibility guarantee or crash-safe +publication. Other filesystem errors propagate. Existing +destinations raise `FileExistsError` unless overwrite is explicitly requested; +the no-overwrite decision also holds if another writer wins a race. + +Public `mlbundle.inspect(source, *, limits=None)` returns `MetadataView` with +the manifest and fixed-order member listing. It validates ZIP records, limits, +manifest, member hashes and JSON syntax without importing model libraries. +It cannot attest that model-derived claims or structure match the model; only +the full validating `load` can do that. The current export API includes neither +schema nor samples; future sample export must require explicit user intent. + +Container/layout/CRC inconsistencies raise `InvalidContainerError`; resource +ceilings raise `LimitExceededError`. Invalid manifest JSON or typed fields raise +`InvalidManifestError`; unknown version strings under the mlbundle identity +raise `UnsupportedFormatVersionError` before validating version-specific fields. +Invalid optional JSON raises `InvalidJSONError`. Member hash or integrity-size +disagreement raises `IntegrityMismatchError` naming the allowed member. +Messages do not include untrusted names, JSON blobs or ZIP exception text. diff --git a/docs/format-onnx.md b/docs/format-onnx.md new file mode 100644 index 0000000..4c40e6c --- /dev/null +++ b/docs/format-onnx.md @@ -0,0 +1,150 @@ +# Version 1 ONNX mapping + +Normative section of [the format contract](format.md). Phase 4b implements this +mapping. ONNX is opaque for visualization: extraction returns null, and load +rejects any `structure.json` member with `ClaimMismatchError`. + +## Original model and native result + +Export accepts `.onnx` paths (`str` or `PathLike`, case-insensitive suffix), raw +`bytes`, `onnx.ModelProto`, and a previously validated `mlbundle._onnx.OnnxModel`. +Paths are stat-bounded and read with one excess byte for detection. Paths and +bytes are stored unchanged; the model member hash is the original bytes' SHA-256. +A ModelProto has no original file representation: serialize it once using +`SerializeToString()` without changing the object, then preserve those bytes. +There is no conversion, graph editing, external-data loading or file reserialization. +`prepare_export` validates declarations but cannot bind them into immutable bytes. + +`native_model` is the frozen `mlbundle._onnx.OnnxModel` record, with `data` (exact +original bytes) and `session` (an `onnxruntime.InferenceSession`). A runtime uses +`session.get_inputs()`, `get_outputs()` and `run()` for native inference; the +bundle predictor provides validated plain-list probability/regression output. +The session and nested values are not deeply immutable. Mutating them invalidates +the loaded-result guarantee, as with other adapters. + +## Inputs and outputs + +Inputs are rank-two numeric tensors, dtype `float32`, `float64`, `int32` or `int64`, +with a fixed width from 1 through 100,000. Batch dimension is unknown, symbolic +or exactly one. Multiple inputs require width one each, and their graph input +names are verified feature names in graph order. A single matrix input supplies +no column names; supplied/generated names remain declarations. Verified model +dtypes repeat the matrix dtype per column or preserve the column input dtypes. + +Only one target is supported. Regression has one float32/float64 output of shape +`[batch]` or `[batch, 1]`. Classifier probabilities are a float32/float64 matrix +`[batch, classes]` or a sequence of maps with int64/string keys and floating values. +An optional second output is an int64/string label tensor `[batch]` or `[batch, 1]`. +Batch declarations in outputs must also be unknown/symbolic or exactly one. +Classifier output selection uses these types/shapes, never output name heuristics. +Multi-target regression, image/string/sequence inputs, multi-column multiple inputs, +fixed batches above one and unsupported/indeterminate outputs fail before sessions. + +Predictors accept row lists, NumPy arrays and DataFrames, importing pandas only +when a frame is passed. Arrays/lists are positional. Multiple-input DataFrames +must have exactly the feature width and unique model input columns; they are +selected by graph input name, permitting another column order. Single matrix +frames are positional. Integer conversion preserves Python/NumPy integer values, +requires integral finite numbers within the dtype range, and never silently +truncates fractions. Boolean/string/object values and non-finite or overflowing +numeric inputs fail. Static one-row batches require exactly one prediction row. +Output dimensions and finiteness are checked. Probabilities must lie in `[0,1]` +and sum to one (`rtol=1e-6`, `atol=1e-5`); ZipMap must contain exactly the native +labels and is read in their recorded order. No missing probabilities are invented. + +## Kind and labels + +Kind is a verified claim, with no `kind=` declaration. Two compatible outputs +(probabilities plus labels) determine classification. A single probability output +requires native classifier/ZipMap labels in its upstream graph, or a terminal +standard Softmax over the class dimension (`axis=1` or `-1`, optionally followed +by Identity/Cast). Otherwise only a single scalar-per-row floating output determines +regression. Other graphs are rejected rather than guessing from user metadata. +Single-output Softmax graphs without labels require a fixed class width. + +Native labels come from the output's `ai.onnx.ml` ZipMap, LinearClassifier, +TreeEnsembleClassifier or SVMClassifier `classlabels_strings`, `classlabels_ints` +or `classlabels_int64s` attributes. Decode strings as UTF-8; preserve integers, +types and native column order. Identity/Cast can preserve a classifier's probability +columns; unrepresented transformations of labeled probabilities are rejected. +Final ZipMap labels own the map keys, permitting a different mapping from an +upstream classifier's encoded labels. Unrelated graph nodes cannot supply labels. +Fixed probability width determines +class count; otherwise native labels determine it. Width and native labels must +agree when both exist. Labels must satisfy the shared uniqueness/count/bounds rules. +Native labels are verified against the unchanged model; supplied labels must agree. +Absent native labels remain null in extraction. The shared pipeline resolves +supplied labels or generates `0..N-1`, checking native counts. Such labels are +declarations, and load cannot authenticate the original-label mapping. + +## Claims + +Estimator identity is exactly `ONNX`; output count is one and structure is null. +`hyperparameters` is the verified graph projection with exactly `inputs`, `outputs` +and `metadata_props`. It describes available graph facts, not training parameters. +Inputs/outputs are graph-ordered arrays of objects with `name`, `dtype`, `shape`. +Tensor shapes preserve integer static dimensions, symbolic dimension strings and +null unknown dimensions. Dtypes use normalized names above (plus `string` for +label tensors). Map output dtype is exactly `sequence>`, +`sequence>` or the corresponding float64 spelling; shape is null. +`metadata_props` maps each unique model-level metadata key to its original string +value, without interpreting it as configuration. The existing 1 MiB/depth-16 +hyperparameter ceiling applies. This completes the previously unimplemented ONNX +projection; sklearn/XGBoost claim semantics and the manifest schema are unchanged. + +`environment.onnx` retains exactly the existing required opsets (unique, sorted +by original domain), IR version and producer name/version. These fields, graph +signatures, counts, kind, names/dtypes and native labels are all recomputed with +the writer's extraction code and compared by exact canonical bytes. Historical +environment, training dtypes, supplied matrix feature names, target name and +label-free mappings remain declarations. + +## Before any native session + +Both export and load perform the same checks, in this order: + +1. Bound bytes to the 512 MiB native-member ceiling (archive/caller limits also + apply). Scan protobuf wire fields through the installed ONNX descriptors + before construction: depth at most 32, at most 1,000,000 fields/scalar values, + valid lengths/wire types/varints/packed fields, no unknown fields or duplicate + singular fields. This rejects hidden content and parser amplification tricks. +2. Parse in-memory with ModelProto. Visit every message, including sparse tensors, + tensor attributes, nested graphs and functions. Reject any tensor with an + external-data entry or non-default data location, segments or unsupported + storage type. Supported storage types are the original ONNX scalar types + FLOAT through BFLOAT16; newer packed/float8 formats are outside this boundary. +3. Check tensor rank (at most 32), nonnegative dimensions, dimension/element/byte + budgets, exact raw-data length or typed scalar count and exclusive storage. + Fixed-width decoded storage and its aggregate are capped at 512 MiB; sparse declared + dense storage is also bounded. Bound static value dimensions and symbolic names + (1,024 characters). Count at most 100,000 nodes across all nested graphs. +4. Permit only standard ONNX domains `""` (the wire spelling of `ai.onnx`), + `ai.onnx` and `ai.onnx.ml` for nodes/opsets/functions; checker/runtime must also + recognize the actual operators and versions. Reject local functions entirely, + including standard-domain shadowing, training info, device configuration, + node overloads and overridable root initializers. Reject model `ort_config`. + No custom-op library is registered. +5. Run `onnx.checker.check_model(..., full_check=False)` in memory; shape inference + and constant propagation are not requested. Validate the supported tabular + layout and build bounded strict claims before creating any runtime session. + Terminal LinearRegressor/TreeEnsembleRegressor native target counts must be one. + +Create ONNX Runtime from those bytes with CPUExecutionProvider only, sequential +execution, one intra/inter-op thread, disabled graph optimization/prepacking, +CPU arena and memory patterns. This limits load-time work and retained buffers; +it is not a bound on execution memory/CPU. Profiling and optimized-model output +are disabled, model format is explicitly ONNX and `read_config_from_model=False`. +No files are written. The runtime host must not set `ORT_LOAD_CONFIG_FROM_MODEL=1`: +the pinned runtime rejects this combination; failures are bounded mlbundle errors. +Both export and load must successfully create a session, then compare its input/ +output types, ranks and known dimensions with graph declarations. A declared +static dimension must remain exact in runtime metadata; unknown inferred width +cannot conceal disagreement. Runtime probability width must match native class +count when exposed. ONNX/checker/runtime +failures become bounded `InvalidModelError`; resource ceilings become +`LimitExceededError`. Missing dependencies name `mlbundle[onnx]`. + +ONNX/protobuf/checker and ONNX Runtime are native dependency boundaries, not +sandboxes. Runtime-computed shapes and control flow can allocate or consume CPU +beyond graph declarations. Consumers still need process isolation, memory/CPU +limits, prediction row budgets and maintained native libraries. diff --git a/docs/format.md b/docs/format.md index ba685eb..60cad0c 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,145 +2,15 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -"Must" denotes a compatibility or security requirement. Phases 1–4a implement +"Must" denotes a compatibility or security requirement. Phases 1–4b implement the manifest, encoding, limits, secure container, metadata-only inspection, -public export/load and the scikit-learn and XGBoost adapters. The ONNX adapter, -schema inference and the CLI remain unimplemented. - -## Container and resource limits - -A bundle is one ZIP file with the `.mlbundle` extension. Its root contains only -the case-sensitive names below. Names are ASCII, with no directory entries, -slashes, backslashes, `..`, NULs or alternative spellings. Unknown or duplicate -names, symlinks, other non-regular entries, encrypted entries, unsupported -compression methods, multi-disk archives and malformed ZIP metadata must be -rejected. Only ZIP_STORED (method 0) and ZIP_DEFLATED (method 8) are allowed. -ZIP64 is allowed within the limits, including forced ZIP64 local records for -small files. Only a single ZIP64 extra field (ID 0x0001) is permitted in a header, -and it must contain exactly the replacement values required by that header's -sentinel fields, in ZIP order. Reject other, redundant, duplicate or malformed -extra fields. ZIP64 end records have the standard fixed 44-byte body, with no -extensible data sector; their locator and ordinary end record must agree. - -Data descriptors are allowed for streaming ZIP writers. Accept the signed and -unsigned 32-bit and ZIP64 forms only when CRC and both sizes match the central -directory. With a descriptor, local CRC/sizes may be zero or match the directory; -ZIP64 sentinel sizes must be resolved through the local ZIP64 extra field. -Without a descriptor, local CRC and sizes must match the directory exactly. -The version-needed field is 10 (stored only), 20 or 45; ZIP64 headers use 45. -Allowed general-purpose flags are the UTF-8 name flag, the descriptor flag and -the two deflate-option bits (deflated entries only). Reject all other flags. - -Reject archive and member comments. The first local header starts at byte zero; -local records, payloads and any descriptors cover the archive contiguously up -to the central directory. The directory ends exactly at the end record or -ZIP64 end record, followed only by its locator and the ordinary end record. -Reject prepended/appended bytes, gaps, overlapping records and unlisted local -records. Central entries may list local records in another order. These rules -exclude polyglot wrappers and metadata the mlbundle writer never produces. -File attributes may omit a Unix file type (as ordinary ZIP writers do); an -explicit type must be regular, and the DOS directory bit must be clear. - -Readers must bound the source before buffering it: stat filesystem paths and -read streams in bounded chunks with at most one excess byte for detection. -Streams are consumed from their current position and are not closed by mlbundle. -Readers must read named members into memory, never extract them to disk. They -must inspect directory metadata and enforce limits before reading, allocating -member buffers or loading models; they must also limit actual bytes produced -during decompression, rather than trusting directory sizes. Reject inconsistencies -between headers, directory entries, actual sizes and ZIP CRC checks. No path, -module name, trusted-types list or external file reference can select loading -code. ONNX external tensor data is unsupported: the original file must be -self-contained. Native model formats do not sandbox their parsers or execution. -Decompression uses chunks of at most 64 KiB and a one-byte lookahead beyond the -declared output size; the lookahead is rejected before it is retained. Require -the exact output size, CRC and complete deflate stream, with no trailing packed -data. Declared sizes are checked against member, aggregate and ratio limits first. - -One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: - -| Limit | Maximum | -| --- | ---: | -| Member count, including manifest | 5 (minimum configured value 2) | -| Entire archive, including headers | 512 MiB | -| Sum of compressed member sizes | 512 MiB | -| Any uncompressed member | 512 MiB | -| Sum of all uncompressed member sizes, including manifest | 1 GiB | -| Each member's uncompressed/compressed ratio | 200 | -| `manifest.json` | 8 MiB | -| `structure.json` | 256 MiB | -| `schema.json` | 16 MiB | -| `samples.json` | 16 MiB | -| JSON container nesting depth | 32 | - -The effective member cap is the smaller of its specific cap and the general -member cap. Check the ratio as `uncompressed_size <= 200 * compressed_size`; -an empty member with both sizes zero is exempt, a nonempty member with compressed -size zero is invalid. This applies to stored members too. The mlbundle writer -always uses deflate level 6 with the RLE strategy and rejects inputs exceeding the ratio ceiling; -the reader also accepts stored entries from other tools. These limits admit -forests whose native model is tens of MiB and whose JSON tree arrays are larger. -Callers may lower archive limits, never raise the ceilings in version 1. -Consumers still need process memory, CPU and prediction limits; a 1 GiB byte -budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. - -### Container functions and inspection - -The internal functions are `mlbundle.container.read_container(source, *, -limits=None)` and `mlbundle.writing.write_container(manifest, members, *, -destination=None, overwrite=False, limits=None)`. Reading returns a validated -`Manifest` and a dictionary of original uncompressed bytes keyed by `MemberName`, -including `manifest.json`. Source is a path, bytes or a binary stream. It never -deserializes a native model or resolves an adapter. - -Writing accepts a `Manifest` or JSON-ready manifest parts, plus non-manifest -member bytes. The writer computes and replaces all integrity records; callers -must already set the model pairing and optional references. JSON-ready parts -may omit integrity. Optional JSON members pass the bounded canonical decoder; -their original bytes are preserved. The container validates JSON syntax and -bounds, while later model/schema APIs own semantic structure and schema checks. -Metadata inspection does not infer whether a model requires structure. - -The writer emits manifest, model, schema, structure, samples in that fixed order, -omitting absent members. ZIP timestamps are 1980-01-01 00:00:00, the creating -platform is Unix, permissions are regular-file 0600, compression is deflate -level 6 with the RLE strategy, memory level 8 and 64 KiB input chunks, and -extra fields/comments are empty. RLE avoids the different general match-search -algorithms used by zlib and zlib-ng across platforms; it compresses repeated-byte -runs and uses Huffman coding for other bytes. It creates no descriptors or ZIP64 -records within the version 1 ceilings. Identical inputs produce identical bytes -on Windows and Linux; the compression implementation is the standard zlib -provided by Python (byte identity across different future zlib implementations -is not a format requirement). -Compression output is bounded by the remaining archive and aggregate compressed -budgets, reserving the fixed header/directory overhead before compression. - -Writing returns bytes and optionally publishes them to a path after validating -the complete archive through the reader. Publication uses a flushed temporary -file in the destination directory: atomic replacement when overwrite is true, -or atomic hard-link creation otherwise, followed by temporary-file cleanup. -When hard links are unsupported, publication uses exclusive destination creation, -flush and fsync, removing its own destination on failure. This fallback prevents -overwrites and leaves no partial file after a reported failure, but the destination -is visible while copying; it is not an atomic visibility guarantee or crash-safe -publication. Other filesystem errors propagate. Existing -destinations raise `FileExistsError` unless overwrite is explicitly requested; -the no-overwrite decision also holds if another writer wins a race. - -Public `mlbundle.inspect(source, *, limits=None)` returns `MetadataView` with -the manifest and fixed-order member listing. It validates ZIP records, limits, -manifest, member hashes and JSON syntax without importing model libraries. -It cannot attest that model-derived claims or structure match the model; only -the full validating `load` can do that. The current export API includes neither -schema nor samples; future sample export must require explicit user intent. - -Container/layout/CRC inconsistencies raise `InvalidContainerError`; resource -ceilings raise `LimitExceededError`. Invalid manifest JSON or typed fields raise -`InvalidManifestError`; unknown version strings under the mlbundle identity -raise `UnsupportedFormatVersionError` before validating version-specific fields. -Invalid optional JSON raises `InvalidJSONError`. Member hash or integrity-size -disagreement raises `IntegrityMismatchError` naming the allowed member. -Messages do not include untrusted names, JSON blobs or ZIP exception text. +public export/load and the sklearn, XGBoost and ONNX adapters. +Schema inference and the CLI remain unimplemented. + +The normative container rules are in [Container and resource limits](format-container.md). +The normative structure, adapter and result rules are in [Adapters and structure](format-adapters.md). +The implemented ONNX mapping and validation rules are in [ONNX](format-onnx.md). +These sections are part of this version 1 contract. ## Members @@ -200,7 +70,7 @@ Identity fields are declarations, not proof of provenance. | `serialization` | `"skops"`, `"ubjson"`, `"onnx"` | Must pair respectively with the three libraries and model members above. | | `estimator_class` | nonempty string, at most 256 characters | Verified class name (not a Python import path); ONNX uses the adapter's fixed `"ONNX"` identity. Never instantiate from this field. | | `kind` | `"classifier"`, `"regressor"` | Verified prediction kind. | -| `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's parameters. Sklearn uses `get_params(deep=True)`; other adapters use their recoverable native parameter representation. ONNX uses `{}`. At most 1 MiB canonical bytes and depth 16. | +| `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's parameters. Sklearn uses `get_params(deep=True)`; other adapters use their recoverable native parameter representation. ONNX records graph signatures and metadata properties as defined in [ONNX](format-onnx.md), without inventing training settings. At most 1 MiB canonical bytes and depth 16. | | `n_features` | integer 1–100,000 | Verified ordered input width. | | `n_classes` | null or array of integers 1–100,000 | Verified per-output class counts; classifier array length equals `n_outputs`, regressor value is null. | | `n_outputs` | integer 1–1,024 | Verified target output count. Probability columns are classes, not separate target outputs. | @@ -279,14 +149,15 @@ file, not the historical truth of the author's original-label mapping. Native wrapper `predict` still returns encoded indices; the bundle predictor returns probabilities aligned with the bound labels. -For a future ONNX graph with no native labels, extraction may return null labels. +For an ONNX graph with no native labels, extraction may return null labels. The shared pipeline then uses supplied labels, or generates `0..N-1` per output, and verifies their lengths against native class counts. Such labels are declarations, not verified original labels; the ONNX bytes must stay unchanged. Graphs exposing labels must return them as claims for exact comparison. ONNX must reject supplied labels that disagree with native labels; the shared resolver -enforces this comparison. The ONNX implementation must establish kind and class -counts independently of labels. +enforces this comparison. Kind is verified from the supported output layouts; class counts come from +fixed probability width or native labels for an unknown width/ZipMap. +See [ONNX kind and labels](format-onnx.md#kind-and-labels). ### `environment` @@ -393,191 +264,6 @@ user metadata, identity and samples are not verified against the model; they mus still pass their own validation and integrity checks. Inspection verifies format and integrity only, and must never attest that model claims match the model. -## `structure.json` - -Structure is never embedded in the manifest. It is a JSON object with required -`family` and `feature_importances` fields, plus exactly the family fields below. -`feature_importances` is null or an ordered numeric array of length `n_features`. -Numbers use the canonical encoding, including non-finite tags. Arrays and tree -lists preserve native order; no pruning, rounding, flattening of output/class -dimensions or display-oriented simplification is permitted. - -| `family` | Additional required fields | -| --- | --- | -| `decision_tree` | `tree`: one tree object. | -| `tree_ensemble` | `estimator_shape`: original shape of the ensemble's estimator collection; `trees`: row-major flattened list of tree objects. Product of shape equals list length. A one-dimensional collection uses `[N]`. | -| `linear` | `coef`, `intercept`: native numeric arrays/scalars converted without reshaping. | -| `xgboost` | `trees`: ordered list of parsed JSON tree dumps, each from `get_dump(dump_format="json", with_stats=True)`. Retain all native fields, including node IDs, split/leaf values, missing-child IDs, gain/cover and children. Do not embed dump strings. | -| `feature_importances` | No additional fields; the importance array must be non-null. | - -A scikit-learn tree object has exactly `node_count` (positive integer), -`n_features` (positive integer), `n_outputs` (positive integer), `n_classes` -(native per-output class-count array), and these native `tree_` arrays: -`children_left`, `children_right`, `feature`, `threshold`, `value`, `impurity`, -`n_node_samples`, `weighted_n_node_samples`. Except `value`, each array's length -is `node_count`; `value` retains its native `[node, output, class/value]` shape. -Preserve leaf and undefined-feature sentinel values, and sklearn's native value -normalization for the installed version. Do not reinterpret `value` as counts. -Trees use each constituent tree's own dimensions, not an ensemble-wide guess. - -Select family in this precedence: a single `tree_`, a tree ensemble, linear -coefficients/intercept, then feature importances only. An ensemble exposing -unsupported non-tree constituents must not masquerade as a supported tree -ensemble. Unsupported or oversized mandatory structure fails export rather than -being silently omitted. ONNX graph visualizations and additional structure -families require a deliberate future contract change. - -Sklearn `Pipeline` returns null structure in version 1. Its final estimator -operates on transformed features; version 1 has no input transformation mapping, -so no final-step tree or coefficient member is emitted. Claims, names and -predictions still describe the complete pipeline. Standalone DecisionTree, -RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, LinearRegression -and Ridge retain their native structures as specified above. HistGradientBoosting -is currently rejected: its `TreePredictor` storage is not on mlbundle's trust -allowlist. No approximate trees are emitted for it. - -## Adapter and result contracts - -The internal `Adapter` protocol in `mlbundle.adapters` owns recognition, -export preparation, serialization, loading, claim extraction, structure -extraction and a `Predictor`. `prepare_export(model, ExportDeclarations)` returns -the object to serialize and extract, leaving the caller's model unchanged. -`ExportDeclarations` is a strict frozen record with `class_labels` only. Adapters -bind or reject declarations according to their native contracts; neither -`deserialize` nor extraction receives manifest fields. The shared pipeline -resolves labels only when extraction returns null labels for a classifier. -The registry imports only the three constant mlbundle module paths `_sklearn`, -`_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library -imports must be lazy, with dependency failures naming the extra to install. -Missing adapter modules raise `AdapterNotImplementedError` on load; export skips -unimplemented adapters while looking for one that recognizes its input. A missing -optional dependency reports `pip install "mlbundle[EXTRA]"`. An unrecognized -object raises `UnsupportedModelError`. - -`export(model, destination, *, feature_names=None, class_labels=None, target_name=None, metadata=None, -overwrite=False, limits=None)` returns a small `MetadataView` of the written -bundle. It serializes, extracts, reloads and compares both claims and structure -before writing. Model-exposed names take precedence over supplied names. It -captures distribution metadata, including `claims.onnx` in the environment for -the future ONNX adapter. No unimplemented schema/data parameters are accepted. -`load(source, *, limits=None)` validates the container, resolves its closed enum, -deserializes, recomputes claims/structure and compares canonical bytes before -constructing a `LoadedBundle`. `ClaimMismatchError` identifies the claim without -echoing values; absent/present structure disagreements are checked separately, -including a present JSON null. Malformed native payloads raise `InvalidModelError`, -an `MLBundleError` subclass, without native exception text. - -The sklearn adapter uses only `skops.io.dumps/loads`. Its fixed supplemental -trusted list is exactly `["sklearn.tree._tree.Tree"]`, for numeric node storage -in the supported tree families; it adds no callable, arbitrary class or I/O type. -Skops' own defaults remain part of the dependency trust boundary. Dumped bytes -are audited with `get_untrusted_types`; every type outside the list is rejected -at export and independently at load. XGBoost estimators inside sklearn pipelines -are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). -The native XGBoost adapter owns standalone XGBoost loading. -The nested skops archive is bounded before parsing; every schema-referenced -numeric array header is checked regardless of filename suffix. Headers must -match the stored byte sizes and cannot use object-array loading or zero-itemsize -dtypes. Tree constructor dimensions/class counts and state-array shapes are -bounded before native construction. Loaded tree dimensions, child/feature indices, -reachability and depth are checked before native feature-importance traversal or -prediction, and input/output/class counts must agree with owning estimators. -Ensemble feature mappings must address the parent input width. Every loaded root object is -checked again for classifier/regressor identity, fitted state and probability -support. Multilabel classifiers with independent positive-class columns and -classifier chains are rejected because their probabilities cannot satisfy the -output-major contract; ordinary multi-output probability-matrix lists are supported. - -### Implemented XGBoost mapping - -Only exact `XGBClassifier` and `XGBRegressor` classes are supported. Subclasses, -ranking, survival, custom objectives, raw `Booster`, and the deprecated -`XGBRFClassifier`/`XGBRFRegressor` wrappers are rejected. Raw boosters do not -carry enough wrapper identity or custom-objective history for this narrow -training-side boundary; RF wrappers add no required v0.1 contract. Supported -classifier objectives are `binary:logistic` and `multi:softprob`. Supported -regression objectives are `reg:squarederror`, `reg:absoluteerror`, -`reg:pseudohubererror`, `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, -`reg:gamma`, and `reg:tweedie`. Only one target is supported. Binary classifiers -return two probability columns; softmax-only objectives are rejected. - -Only native `gbtree` storage is admitted. In XGBoost 3.4.1, the deprecated `dart` -option uses `gbtree` storage with dropout; that representation is supported and -all native dump statistics are retained. Legacy native `dart` storage is rejected. -`gblinear` is rejected: its multi-class coefficient/importance dimensions and -deprecated format are outside this phase's tree contract. There is no silently -omitted structure for an admitted XGBoost model. - -Serialization is exclusively `bytes(booster.save_raw(raw_format="ubj"))` and -`load_model(bytearray(data))`, in memory. Class selection uses the closed adapter -map and the `mlbundle` booster attribute, never the manifest. That attribute is -exactly `{ "estimator_class": CLASS, "class_labels": LABELS_OR_NULL, -"prediction": SETTINGS }` encoded canonically. `SETTINGS` has exactly `missing` -(a finite binary64 value or the canonical NaN tag), `importance_type` (one of -`gain`, `weight`, `cover`, `total_gain`, `total_cover`; omitted native option -normalizes to `gain`), and `enable_categorical` (boolean). These settings survive -loading and are compared with the wrapper. Other booster attributes, including -`best_iteration`, survive unchanged; prediction respects native best iteration. - -`hyperparameters` has exactly these four keys: - -```json -{ - "learner_model_param": {"base_score":"[5E-1]","boost_from_average":"1","num_class":"0","num_feature":"4","num_target":"1"}, - "objective": {"name":"binary:logistic","reg_loss_param":{"scale_pos_weight":"1"}}, - "gradient_booster": {"name":"gbtree","gbtree_model_param":{"num_parallel_tree":"1","num_trees":"3"}}, - "prediction": {"missing":{"$float":"nan"},"importance_type":"gain","enable_categorical":true} -} -``` - -The first three records are a projection of the parsed native `save_config()`: -retain every field in `learner_model_param`, `objective`, and -`gradient_booster.gbtree_model_param`, plus the booster name, with native value -types/spellings unchanged. `prediction` is the bound wrapper settings record. -Neither `get_params()` nor the whole config survives native model I/O. Training -settings such as learning rate, max depth, seed, threads and metrics are therefore -not claimed or reconstructed. Export compares this projection and the complete -structure before and after making the native copy, and again through the shared -serialization round trip. Unsupported unrecoverable claims fail publication. - -Booster feature names and feature types become verified claims; absent values -are null. Structure is exactly the `xgboost` family above, with the wrapper's -native float32-normalized importance vector for the bound importance type. -Prediction values and structure scalars promote float32 exactly to binary64. -Claim/structure comparisons use exact canonical bytes; probability row-sum -validation allows `rtol=1e-6`, `atol=1e-7` for float32 arithmetic. - -Before native parsing, the adapter bounds bytes, recognizes the native UBJSON -prefix, validates the complete bounded document, and checks feature/class/target, -tree and node-array dimensions, output-group indices, iteration pointers and -child/feature indices. Numeric arrays remain -byte views. At most 100,000 trees are supported; reachable tree depth is at most -14 so the complete nested dump fits the existing JSON depth ceiling of 32. -Cycles, aliases and unreachable nodes are rejected. Native dump generation is -count-bounded; total dump bytes and canonical structure bytes must also fit -256 MiB and caller-selected lower limits. No truncation is permitted. These -checks do not sandbox the native parser or cap its internal allocations. - -Predictors accept adapter-supported tabular objects without a core pandas/numpy -dependency. Regressors return `list[float]` for a single output or row-major -`list[list[float]]` for multiple outputs. Classifiers return row-major -`list[list[float]]` for one output or an output-major list of those matrices for -multiple outputs, aligned with class labels. Validate probability dimensions, -finite probabilities, range and row sums in the adapter. Classifier prediction -uses `predict_proba`; regressor prediction uses `predict`. Unsupported probability -or regression contracts fail rather than synthesizing outputs. - -`MetadataView` carries `manifest` and a tuple of `MemberName` values including -the manifest; it has no model. `LoadedBundle` additionally carries the exact -native object, a uniform predictor, optional schema/structure/sample values, and -properties for kind, library, class name, ordered feature names and origin, and -output-major original class labels. Calling the prediction method for the wrong -kind raises a bounded `ValueError`. Result records and typed models prevent field -reassignment, but nested JSON values and the native model are not deeply frozen. -Construction itself performs no model validation; `load` supplies the loaded-result -guarantee. Consumers must not mutate a validated -model and continue treating its original metadata as verified. - ## Versioning and integrity Readers must reject unknown format versions rather than guess. Any change to diff --git a/src/mlbundle/_onnx/__init__.py b/src/mlbundle/_onnx/__init__.py new file mode 100644 index 0000000..da7293d --- /dev/null +++ b/src/mlbundle/_onnx/__init__.py @@ -0,0 +1,93 @@ +"""Original ONNX bytes with a validated CPU session; optional imports stay here.""" + +from dataclasses import dataclass +from os import PathLike +from pathlib import Path +from typing import Any + +import onnx +from pydantic import JsonValue + +from mlbundle.claims import ModelClaims +from mlbundle.contract import Library, MemberName +from mlbundle.declarations import ExportDeclarations, resolve_labels +from mlbundle.errors import InvalidModelError, LimitExceededError, MLBundleError +from mlbundle.limits import ArchiveLimits + +from .description import Description, describe +from .prediction import Predictor +from .session import create_session +from .validation import parse_model + + +@dataclass(frozen=True) +class OnnxModel: + """Validated native inference session and the exact bytes that created it.""" + + data: bytes + session: Any + + +def _description(data: bytes) -> Description: + try: + return describe(parse_model(data)) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid ONNX model claims.") from None + + +class Adapter: + library = Library.ONNX + model_member = MemberName.ONNX + + def recognizes(self, model: object) -> bool: + return isinstance(model, (bytes, onnx.ModelProto, OnnxModel)) or ( + isinstance(model, (str, PathLike)) and Path(model).suffix.lower() == ".onnx" + ) + + def prepare_export(self, model: object, declarations: ExportDeclarations) -> object: + try: + if isinstance(model, OnnxModel): + data = model.data + elif isinstance(model, bytes): + data = model + elif isinstance(model, onnx.ModelProto): + if model.ByteSize() > ArchiveLimits().max_member_bytes: + raise LimitExceededError("ONNX model exceeds the byte limit.") + data = model.SerializeToString() + else: + path = Path(model) # type: ignore[arg-type] + cap = ArchiveLimits().max_member_bytes + if path.stat().st_size > cap: + raise LimitExceededError("ONNX model exceeds the byte limit.") + with path.open("rb") as stream: + data = stream.read(cap + 1) + description = _description(data) + resolve_labels(description.claims, declarations) + return self.deserialize(data) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Cannot read ONNX model input.") from None + + def serialize(self, model: Any) -> bytes: + return model.data + + def deserialize(self, data: bytes) -> OnnxModel: + description = _description(data) + try: + return OnnxModel(data, create_session(data, description)) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("ONNX CPU session creation failed.") from None + + def extract_claims(self, model: Any) -> ModelClaims: + return _description(model.data).claims + + def extract_structure(self, model: object) -> dict[str, JsonValue] | None: # noqa: ARG002 + return None + + def predictor(self, model: Any) -> Predictor: + return Predictor(model.session, _description(model.data)) diff --git a/src/mlbundle/_onnx/description.py b/src/mlbundle/_onnx/description.py new file mode 100644 index 0000000..7cb1f4f --- /dev/null +++ b/src/mlbundle/_onnx/description.py @@ -0,0 +1,243 @@ +"""Native graph signatures, output contracts and original label claims.""" + +from dataclasses import dataclass +from typing import Any, cast + +from pydantic import JsonValue + +from mlbundle.canonical import canonical_dumps +from mlbundle.claims import ClassLabels, ModelClaims +from mlbundle.contract import ModelKind +from mlbundle.environment import OnnxInfo, Opset +from mlbundle.errors import InvalidModelError +from mlbundle.limits import MAX_CLASSES + +from .signatures import _batch, _inputs, signature + +LABEL_OPS = frozenset( + {"ZipMap", "LinearClassifier", "TreeEnsembleClassifier", "SVMClassifier"} +) + + +@dataclass(frozen=True) +class Description: + claims: ModelClaims + inputs: tuple[dict[str, Any], ...] + output: str + maps: bool + + +def _ancestors(graph: Any, output: str) -> list[Any]: + producers = {name: node for node in graph.node for name in node.output if name} + pending, seen, result = [output], set(), [] + while pending: + name = pending.pop() + if name in seen: + continue + seen.add(name) + node = producers.get(name) + if node is not None: + result.append(node) + pending.extend(node.input) + return result + + +def _labels(nodes: list[Any]) -> ClassLabels | None: + found = [] + for node in nodes: + if node.domain != "ai.onnx.ml" or node.op_type not in LABEL_OPS: + continue + for attr in node.attribute: + if attr.name == "classlabels_strings": + found.append([value.decode("utf-8") for value in attr.strings]) + elif attr.name in ("classlabels_ints", "classlabels_int64s"): + found.append(list(attr.ints)) + if not found: + return None + if any( + canonical_dumps(labels) != canonical_dumps(found[0]) for labels in found[1:] + ): + raise InvalidModelError("ONNX native label records disagree.") + return cast(ClassLabels, [found[0]]) + + +def _native_labels(graph: Any, output: str) -> ClassLabels | None: + producers = {name: node for node in graph.node for name in node.output if name} + original = output + for _ in range(len(graph.node)): + node = producers.get(output) + if node is None: + return None + if node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS: + if node.op_type == "ZipMap" or ( + len(node.output) > 1 and output == node.output[1] + ): + return _labels([node]) + break + if ( + node.domain in ("", "ai.onnx") + and node.op_type in ("Identity", "Cast") + and len(node.input) == 1 + ): + output = node.input[0] + else: + break + if any( + node.domain == "ai.onnx.ml" and node.op_type in LABEL_OPS + for node in _ancestors(graph, original) + ): + raise InvalidModelError( + "Unsupported transformation of ONNX labeled probabilities." + ) + return None + + +def _softmax(graph: Any, output: str) -> bool: + producers = {name: node for node in graph.node for name in node.output if name} + for _ in range(len(graph.node)): + node = producers.get(output) + if node is None or node.domain not in ("", "ai.onnx"): + return False + if node.op_type == "Softmax": + axis = next((attr.i for attr in node.attribute if attr.name == "axis"), -1) + return axis in (1, -1) + if node.op_type not in ("Identity", "Cast") or len(node.input) != 1: + return False + output = node.input[0] + return False + + +def _probability(value: dict[str, Any]) -> bool: + if value["dtype"].startswith("sequence bool: + shape = value["shape"] + return ( + value["dtype"] in ("int64", "string") + and shape is not None + and _batch(shape) + and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1)) + ) + + +def _regression_width(graph: Any, output: str) -> None: + producers = {name: node for node in graph.node for name in node.output if name} + for _ in range(len(graph.node)): + node = producers.get(output) + if node is None: + return + if node.domain == "ai.onnx.ml" and node.op_type in ( + "LinearRegressor", + "TreeEnsembleRegressor", + ): + attr_name = "targets" if node.op_type == "LinearRegressor" else "n_targets" + width = next( + (attr.i for attr in node.attribute if attr.name == attr_name), 1 + ) + if width != 1: + raise InvalidModelError( + "ONNX native regression operator must expose one target." + ) + return + if ( + node.domain not in ("", "ai.onnx") + or node.op_type not in ("Identity", "Cast") + or len(node.input) != 1 + ): + return + output = node.input[0] + + +def _outputs( + graph: Any, outputs: list[dict[str, Any]] +) -> tuple[ModelKind, dict[str, Any], ClassLabels | None, int | None]: + if len(outputs) == 2: + candidates = [ + value + for index, value in enumerate(outputs) + if _probability(value) and _label_output(outputs[1 - index]) + ] + elif len(outputs) == 1: + value = outputs[0] + evidence = _native_labels(graph, value["name"]) is not None or _softmax( + graph, value["name"] + ) + candidates = [value] if _probability(value) and evidence else [] + else: + candidates = [] + if len(candidates) == 1: + value = candidates[0] + labels = _native_labels(graph, value["name"]) + count = value["shape"][1] if value["shape"] is not None else None + if not isinstance(count, int): + count = len(labels[0]) if labels else None + if count is None or (labels is not None and len(labels[0]) != count): + raise InvalidModelError( + "ONNX probability width and native labels disagree." + ) + return ModelKind.CLASSIFIER, value, labels, count + if len(outputs) == 1: + value = outputs[0] + shape = value["shape"] + if ( + value["dtype"] in ("float32", "float64") + and shape is not None + and _batch(shape) + and (len(shape) == 1 or (len(shape) == 2 and shape[1] == 1)) + ): + _regression_width(graph, value["name"]) + return ModelKind.REGRESSOR, value, None, None + raise InvalidModelError( + "ONNX kind or probability/regression output layout is indeterminate." + ) + + +def describe(model: Any) -> Description: + graph = model.graph + if not 1 <= len(graph.output) <= 2: + raise InvalidModelError( + "ONNX requires one regression or probability output, optionally with labels." + ) + inputs = _inputs(graph) + outputs = [signature(value) for value in graph.output] + kind, output, labels, count = _outputs(graph, outputs) + width = sum(value["shape"][1] for value in inputs) + metadata = {item.key: item.value for item in model.metadata_props} + if len(metadata) != len(model.metadata_props): + raise InvalidModelError("Duplicate ONNX metadata keys.") + claims = ModelClaims( + estimator_class="ONNX", + kind=kind, + hyperparameters=cast( + dict[str, JsonValue], + {"inputs": list(inputs), "outputs": outputs, "metadata_props": metadata}, + ), + n_features=width, + n_classes=None if count is None else [count], + n_outputs=1, + feature_names=[value["name"] for value in inputs] if len(inputs) > 1 else None, + feature_dtypes=[ + value["dtype"] for value in inputs for _ in range(value["shape"][1]) + ], + class_labels=labels, + onnx=OnnxInfo( + opsets=[ + Opset(domain=item.domain, version=item.version) + for item in sorted(model.opset_import, key=lambda item: item.domain) + ], + ir_version=model.ir_version, + producer_name=model.producer_name, + producer_version=model.producer_version, + ), + ) + return Description(claims, inputs, output["name"], output["shape"] is None) diff --git a/src/mlbundle/_onnx/prediction.py b/src/mlbundle/_onnx/prediction.py new file mode 100644 index 0000000..5d6089d --- /dev/null +++ b/src/mlbundle/_onnx/prediction.py @@ -0,0 +1,138 @@ +"""Numeric tabular feeds and validated tensor/ZipMap outputs, entirely in memory.""" + +import math +import warnings +from typing import Any, cast + +import numpy as np + +from mlbundle.adapters import ProbabilityOutput, RegressionOutput +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError + +from .description import Description + + +class _InputShapeError(ValueError): + pass + + +def _array(data: Any, dtype: str) -> np.ndarray: + raw = np.asarray(data, dtype=object) + if np.dtype(dtype).kind in "iu": + limits = np.iinfo(dtype) + for value in raw.flat: + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, float, np.integer, np.floating)) + or not math.isfinite(value) + or int(value) != value + or not limits.min <= int(value) <= limits.max + ): + raise InvalidModelError( + "ONNX integer inputs require whole numbers within the dtype range." + ) + elif any( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, float, np.integer, np.floating)) + for value in raw.flat + ): + raise InvalidModelError("ONNX inputs require finite numeric values.") + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + values = np.asarray(raw, dtype=dtype) + if not np.isfinite(values).all(): + raise InvalidModelError("ONNX inputs require finite numeric values.") + return values + + +class Predictor: + def __init__(self, session: Any, description: Description) -> None: + self.session = session + self.description = description + self.claims = description.claims + + def _feeds(self, data: object) -> tuple[dict[str, np.ndarray], int]: + frame = None + if any( + base.__module__.split(".")[0] == "pandas" for base in type(data).__mro__ + ): + import pandas as pd # noqa: PLC0415 + + if isinstance(data, pd.DataFrame): + frame = data + values = np.asarray(data, dtype=object) + if values.ndim != 2 or values.shape[1] != self.claims.n_features: + raise _InputShapeError( + "Prediction input must have the model's feature width." + ) + rows = values.shape[0] + feeds = {} + for index, value in enumerate(self.description.inputs): + shape = value["shape"] + if isinstance(shape[0], int) and rows != shape[0]: + raise _InputShapeError( + "Prediction row count must match the ONNX static batch." + ) + if len(self.description.inputs) == 1: + column = values + elif frame is not None: + if not frame.columns.is_unique or value["name"] not in frame.columns: + raise InvalidModelError( + "ONNX DataFrame columns must match model input names." + ) + column = frame[[value["name"]]].to_numpy(dtype=object) + else: + column = values[:, index : index + 1] + feeds[value["name"]] = _array(column, value["dtype"]) + return feeds, rows + + def _run(self, data: object) -> tuple[Any, int]: + try: + feeds, rows = self._feeds(data) + return self.session.run([self.description.output], feeds)[0], rows + except (InvalidModelError, _InputShapeError): + raise + except Exception: + raise InvalidModelError( + "ONNX prediction failed or input values are invalid." + ) from None + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + raw, rows = self._run(data) + try: + values = np.asarray(raw, dtype=float) + if values.shape == (rows, 1): + values = values[:, 0] + if values.shape != (rows,) or not np.isfinite(values).all(): + raise ValueError + return cast(RegressionOutput, values.tolist()) + except Exception: + raise InvalidModelError("Invalid ONNX regression output.") from None + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + raw, rows = self._run(data) + try: + if self.description.maps: + labels = (self.claims.class_labels or [[]])[0] + if not isinstance(raw, list) or any( + not isinstance(row, dict) or set(row) != set(labels) for row in raw + ): + raise ValueError + raw = [[row[label] for label in labels] for row in raw] + values = np.asarray(raw, dtype=float) + if ( + values.shape != (rows, (self.claims.n_classes or [0])[0]) + or not np.isfinite(values).all() + or np.any(values < 0) + or np.any(values > 1) + or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-5) + ): + raise ValueError + return cast(ProbabilityOutput, values.tolist()) + except Exception: + raise InvalidModelError("Invalid ONNX probability output.") from None diff --git a/src/mlbundle/_onnx/protobuf.py b/src/mlbundle/_onnx/protobuf.py new file mode 100644 index 0000000..da5d543 --- /dev/null +++ b/src/mlbundle/_onnx/protobuf.py @@ -0,0 +1,95 @@ +"""Wire bounds before protobuf constructs objects; no tensor decoding or file I/O.""" + +from typing import Any + +from mlbundle.errors import InvalidModelError, LimitExceededError +from mlbundle.limits import ArchiveLimits + +MAX_FIELDS = 1_000_000 +MAX_DEPTH = 32 + + +def _varint(data: memoryview, position: int, end: int) -> tuple[int, int]: + value = 0 + for shift in range(0, 70, 7): + if position >= end: + raise InvalidModelError("Truncated ONNX protobuf.") + byte = data[position] + position += 1 + value |= (byte & 127) << shift + if byte < 128: + if shift == 63 and byte > 1: + break + return value, position + raise InvalidModelError("Invalid ONNX protobuf integer.") + + +def validate_wire(data: bytes, descriptor: Any) -> None: + if not data: + raise InvalidModelError("Empty ONNX model.") + if len(data) > ArchiveLimits().max_member_bytes: + raise LimitExceededError("ONNX model exceeds the byte limit.") + budget = [0] + _message(memoryview(data), 0, len(data), descriptor, depth=0, budget=budget) + + +def _message(data, position, end, descriptor, *, depth, budget): + if depth > MAX_DEPTH: + raise LimitExceededError("ONNX protobuf nesting exceeds the limit.") + seen = set() + while position < end: + budget[0] += 1 + if budget[0] > MAX_FIELDS: + raise LimitExceededError("ONNX protobuf field count exceeds the limit.") + tag, position = _varint(data, position, end) + number, wire = tag >> 3, tag & 7 + field = descriptor.fields_by_number.get(number) + if field is None or (not field.is_repeated and number in seen): + raise InvalidModelError("Unknown or duplicate ONNX protobuf field.") + seen.add(number) + position = _field(data, position, end, field, wire, depth=depth, budget=budget) + + +def _field(data, position, end, field, wire, *, depth, budget): + expected = ( + 2 + if field.type in (9, 11, 12) + else {1: 1, 2: 5, 6: 1, 7: 5, 15: 5, 16: 1}.get(field.type, 0) + ) + packed = field.is_repeated and expected != 2 and wire == 2 + if wire != expected and not packed: + raise InvalidModelError("Invalid ONNX protobuf wire type.") + if wire == 0: + return _varint(data, position, end)[1] + if wire in (1, 5): + stop = position + (8 if wire == 1 else 4) + else: + size, position = _varint(data, position, end) + stop = position + size + if stop > end: + raise InvalidModelError("Truncated ONNX protobuf field.") + if field.type == 11: + _message( + data, position, stop, field.message_type, depth=depth + 1, budget=budget + ) + elif packed: + _packed(data, position, stop, expected, budget) + return stop + + +def _packed(data, position, end, wire, budget): + if wire in (1, 5): + size = 8 if wire == 1 else 4 + if (end - position) % size: + raise InvalidModelError("Invalid packed ONNX protobuf field.") + budget[0] += (end - position) // size + else: + while position < end: + _, position = _varint(data, position, end) + budget[0] += 1 + if budget[0] > MAX_FIELDS: + raise LimitExceededError( + "ONNX protobuf scalar count exceeds the limit." + ) + if budget[0] > MAX_FIELDS: + raise LimitExceededError("ONNX protobuf scalar count exceeds the limit.") diff --git a/src/mlbundle/_onnx/session.py b/src/mlbundle/_onnx/session.py new file mode 100644 index 0000000..5058647 --- /dev/null +++ b/src/mlbundle/_onnx/session.py @@ -0,0 +1,87 @@ +"""Fixed runtime settings and agreement with its inferred input/output metadata.""" + +from typing import Any, cast + +import onnxruntime as ort + +from mlbundle.errors import InvalidModelError + +from .description import Description + +ORT_TYPES = { + "float32": "tensor(float)", + "float64": "tensor(double)", + "int32": "tensor(int32)", + "int64": "tensor(int64)", + "string": "tensor(string)", +} + + +def _agree(expected: dict[str, Any], actual: Any) -> None: + dtype = expected["dtype"] + if dtype.startswith("sequence>") + key, value = dtype.split(",") + native_type = f"seq(map({key},{ORT_TYPES[value]}))" + else: + native_type = ORT_TYPES[dtype] + if expected["name"] != actual.name or native_type != actual.type: + raise InvalidModelError("ONNX graph and runtime input/output types disagree.") + shape = expected["shape"] + if shape is not None and ( + actual.shape is None + or len(shape) != len(actual.shape) + or any( + isinstance(a, int) and a != b + for a, b in zip(shape, actual.shape, strict=True) + ) + ): + raise InvalidModelError( + "ONNX graph and runtime input/output dimensions disagree." + ) + + +def create_session(data: bytes, description: Description) -> Any: + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL + options.enable_cpu_mem_arena = False + options.enable_mem_pattern = False + options.enable_profiling = False + options.optimized_model_filepath = "" + options.log_severity_level = 4 + options.add_session_config_entry("session.load_model_format", "ONNX") + options.add_session_config_entry("session.disable_prepacking", "1") + session = ort.InferenceSession( + data, + sess_options=options, + providers=["CPUExecutionProvider"], + read_config_from_model=False, + ) + inputs, outputs = session.get_inputs(), session.get_outputs() + expected_outputs = cast( + list[dict[str, Any]], description.claims.hyperparameters["outputs"] + ) + if len(inputs) != len(description.inputs) or len(outputs) != len(expected_outputs): + raise InvalidModelError("ONNX graph and runtime input/output counts disagree.") + for expected, actual in zip(description.inputs, inputs, strict=True): + _agree(expected, actual) + for expected, actual in zip(expected_outputs, outputs, strict=True): + _agree(expected, actual) + if actual.name == description.output and expected["shape"] is not None: + width = ( + 1 + if description.claims.n_classes is None + else description.claims.n_classes[0] + ) + if ( + len(actual.shape) == 2 + and isinstance(actual.shape[1], int) + and actual.shape[1] != width + ): + raise InvalidModelError( + "ONNX runtime prediction width disagrees with native claims." + ) + return session diff --git a/src/mlbundle/_onnx/signatures.py b/src/mlbundle/_onnx/signatures.py new file mode 100644 index 0000000..9334605 --- /dev/null +++ b/src/mlbundle/_onnx/signatures.py @@ -0,0 +1,63 @@ +"""Supported tabular graph signatures and feature input layouts.""" + +from typing import Any + +from mlbundle.errors import InvalidModelError +from mlbundle.limits import MAX_FEATURES + +DTYPES = {1: "float32", 11: "float64", 6: "int32", 7: "int64"} + + +def signature(value: Any) -> dict[str, Any]: + typ = value.type + if typ.HasField("tensor_type"): + tensor = typ.tensor_type + if not tensor.HasField("shape"): + raise InvalidModelError("ONNX input/output ranks must be declared.") + dtype = {**DTYPES, 8: "string"}.get(tensor.elem_type) + if dtype is None: + raise InvalidModelError("Unsupported ONNX input/output dtype.") + shape = [ + dim.dim_value if dim.HasField("dim_value") else dim.dim_param or None + for dim in tensor.shape.dim + ] + return {"name": value.name, "dtype": dtype, "shape": shape} + if typ.HasField("sequence_type"): + element = typ.sequence_type.elem_type + if element.HasField("map_type"): + mapping = element.map_type + dtype = DTYPES.get(mapping.value_type.tensor_type.elem_type) + if mapping.key_type in (7, 8) and dtype in ("float32", "float64"): + key = "int64" if mapping.key_type == 7 else "string" + return { + "name": value.name, + "dtype": f"sequence>", + "shape": None, + } + raise InvalidModelError("Unsupported ONNX input/output layout.") + + +def _batch(shape: list[Any]) -> bool: + return bool(shape) and (not isinstance(shape[0], int) or shape[0] == 1) + + +def _inputs(graph: Any) -> tuple[dict[str, Any], ...]: + inputs = tuple(signature(value) for value in graph.input) + for value in inputs: + shape = value["shape"] + if ( + value["dtype"] not in DTYPES.values() + or shape is None + or len(shape) != 2 + or not _batch(shape) + or not isinstance(shape[1], int) + or not 1 <= shape[1] <= MAX_FEATURES + ): + raise InvalidModelError( + "ONNX inputs require numeric matrices with fixed feature width and dynamic or one-row batches." + ) + if len(inputs) > 1 and shape[1] != 1: + raise InvalidModelError( + "Multiple ONNX inputs require one column per input." + ) + return inputs diff --git a/src/mlbundle/_onnx/validation.py b/src/mlbundle/_onnx/validation.py new file mode 100644 index 0000000..c94f202 --- /dev/null +++ b/src/mlbundle/_onnx/validation.py @@ -0,0 +1,164 @@ +"""Self-contained inference graph validation before checker or runtime loading.""" + +from math import prod +from typing import Any + +import onnx + +from mlbundle.errors import InvalidModelError, LimitExceededError, MLBundleError +from mlbundle.limits import MAX_FEATURES, ArchiveLimits + +from .protobuf import validate_wire + +DOMAINS = frozenset({"", "ai.onnx", "ai.onnx.ml"}) +MAX_NODES = 100_000 +MAX_RANK = 32 +BYTE_CAP = ArchiveLimits().max_member_bytes +# Storage field, bytes per element, primitive values per element. +TENSOR_TYPES = { + 1: ("float_data", 4, 1), + 2: ("int32_data", 1, 1), + 3: ("int32_data", 1, 1), + 4: ("int32_data", 2, 1), + 5: ("int32_data", 2, 1), + 6: ("int32_data", 4, 1), + 7: ("int64_data", 8, 1), + 8: ("string_data", 0, 1), + 9: ("int32_data", 1, 1), + 10: ("int32_data", 2, 1), + 11: ("double_data", 8, 1), + 12: ("uint64_data", 4, 1), + 13: ("uint64_data", 8, 1), + 14: ("float_data", 8, 2), + 15: ("double_data", 16, 2), + 16: ("int32_data", 2, 1), +} + + +def _dimensions(dims: Any, *, allow_zero: bool) -> int: + if len(dims) > MAX_RANK or any( + dimension < (0 if allow_zero else 1) or dimension > BYTE_CAP + for dimension in dims + ): + raise LimitExceededError("ONNX tensor dimensions exceed the limit.") + count = prod(dims) + if count > BYTE_CAP: + raise LimitExceededError("ONNX tensor element count exceeds the limit.") + return count + + +def _tensor(tensor: Any) -> int: + if tensor.data_location != onnx.TensorProto.DEFAULT or tensor.external_data: + raise InvalidModelError("ONNX external tensor data is unsupported.") + if tensor.HasField("segment") or tensor.data_type not in TENSOR_TYPES: + raise InvalidModelError("Unsupported ONNX tensor storage.") + count = _dimensions(tensor.dims, allow_zero=True) + field, size, factor = TENSOR_TYPES[tensor.data_type] + if count * max(size, 1) > BYTE_CAP: + raise LimitExceededError("ONNX tensor storage exceeds the limit.") + populated = [ + name + for name in {value[0] for value in TENSOR_TYPES.values()} + if len(getattr(tensor, name)) + ] + if tensor.HasField("raw_data"): + valid = size > 0 and not populated and len(tensor.raw_data) == count * size + else: + valid = ( + all(name == field for name in populated) + and len(getattr(tensor, field)) == count * factor + ) + if not valid: + raise InvalidModelError("ONNX tensor dimensions and stored values disagree.") + return max(count * max(size, 1), len(tensor.raw_data)) + + +def _shape(shape: Any) -> None: + if len(shape.dim) > MAX_RANK: + raise LimitExceededError("ONNX value rank exceeds the limit.") + static = [] + for dim in shape.dim: + if dim.HasField("dim_value"): + static.append(dim.dim_value) + elif len(dim.dim_param) > 1024: + raise LimitExceededError("ONNX symbolic dimension exceeds the limit.") + _dimensions(static, allow_zero=True) + + +def _operator(value: Any) -> None: + if value.domain not in DOMAINS: + raise InvalidModelError("Unsupported ONNX operator domain.") + if value.DESCRIPTOR.name == "NodeProto" and ( + value.overload or value.device_configurations + ): + raise InvalidModelError("Unsupported ONNX node configuration.") + + +def _sparse(value: Any) -> None: + count = _dimensions(value.dims, allow_zero=True) + size = max(TENSOR_TYPES.get(value.values.data_type, (None, BYTE_CAP, 1))[1], 1) + if count * size > BYTE_CAP: + raise LimitExceededError("ONNX sparse tensor dense storage exceeds the limit.") + + +def _children(value: Any) -> list[Any]: + children = [] + for field, child in value.ListFields(): + if field.type == field.TYPE_MESSAGE: + children.extend(child if field.is_repeated else [child]) + return children + + +def _visit(model: Any) -> None: + pending = [model] + nodes = storage = 0 + while pending: + value = pending.pop() + name = value.DESCRIPTOR.name + if name == "TensorProto": + storage += _tensor(value) + if storage > BYTE_CAP: + raise LimitExceededError( + "ONNX aggregate tensor storage exceeds the limit." + ) + elif name == "SparseTensorProto": + _sparse(value) + elif name == "TensorShapeProto": + _shape(value) + elif name in ("NodeProto", "FunctionProto", "OperatorSetIdProto"): + _operator(value) + if name == "NodeProto": + nodes += 1 + if nodes > MAX_NODES: + raise LimitExceededError("ONNX node count exceeds the limit.") + pending.extend(_children(value)) + + +def parse_model(data: bytes) -> Any: + try: + validate_wire(data, onnx.ModelProto.DESCRIPTOR) + model = onnx.ModelProto() + model.ParseFromString(data) + _visit(model) + if model.training_info or model.functions or model.configuration: + raise InvalidModelError( + "ONNX training, local functions and device configuration are unsupported." + ) + if any(item.key == "ort_config" for item in model.metadata_props): + raise InvalidModelError( + "ONNX runtime configuration metadata is unsupported." + ) + if not 1 <= len(model.graph.input) <= MAX_FEATURES: + raise InvalidModelError("ONNX requires bounded tabular inputs.") + initializers = {item.name for item in model.graph.initializer} | { + item.values.name for item in model.graph.sparse_initializer + } + if initializers & {item.name for item in model.graph.input}: + raise InvalidModelError("ONNX overridable initializers are unsupported.") + # full_check=False avoids shape inference executing constant propagation. + onnx.checker.check_model(model, full_check=False) + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid ONNX protobuf or graph.") from None diff --git a/tests/conftest.py b/tests/conftest.py index ffc7411..fc8dccc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -226,3 +226,10 @@ def oversized_descriptor(parts): raw[directory:directory] = b"PK\x07\x08" + b"x" * 4092 struct.pack_into("q", value.count) - + bytes(value.data) - ) - if isinstance(value, dict): - return ( - b"{" - + b"".join( - b"L" - + struct.pack(">q", len(key.encode())) - + key.encode() - + ubjson(item) - for key, item in value.items() - ) - + b"}" - ) - if isinstance(value, list): - return b"[" + b"".join(ubjson(item) for item in value) + b"]" - if isinstance(value, str): - return b"SL" + struct.pack(">q", len(value.encode())) + value.encode() - return ubjson_scalar(value) - - -def ubjson_scalar(value): - if type(value) is bool: - return b"T" if value else b"F" - if type(value) is int: - return b"L" + struct.pack(">q", value) - if type(value) is float: - return b"D" + struct.pack(">d", value) - assert value is None - return b"Z" - - -@pytest.mark.parametrize( - "change", - [ - lambda learner: learner["learner_model_param"].update(num_feature="100001"), - lambda learner: learner["learner_model_param"].update(num_class="100001"), - lambda learner: learner["gradient_booster"]["model"][ - "gbtree_model_param" - ].update(num_trees="100001"), - lambda learner: learner["gradient_booster"]["model"]["trees"][0][ - "tree_param" - ].update(num_nodes="999999"), - lambda learner: learner["gradient_booster"]["model"]["trees"][0].update( - left_children=[999999] * 5 - ), - lambda learner: learner["objective"].update(name="rank:pairwise"), - lambda learner: learner["gradient_booster"]["model"].update(tree_info=[-1] * 9), - lambda learner: learner["gradient_booster"]["model"].update( - tree_info=[900000000] * 9 - ), - lambda learner: learner["gradient_booster"]["model"].update(tree_info=[0]), - lambda learner: learner["gradient_booster"]["model"].update( - iteration_indptr=[0, 999999999] - ), - ], -) -def test_preflight_rejects_before_native_parser(change, data, monkeypatch): - x, y = data - value = decode(bytes(classifier(x, y).get_booster().save_raw(raw_format="ubj"))) - change(value["learner"]) - calls = [] - monkeypatch.setattr(xgb.Booster, "load_model", lambda *args: calls.append(True)) - with pytest.raises(mlbundle.MLBundleError): - Adapter().deserialize(ubjson(value)) - assert not calls - - -def test_binding_validation_and_limits(data, tmp_path): - x, y = data - model = Adapter().prepare_export(classifier(x, y), export_declarations(None)) - binding = canonical_loads(model.get_booster().attr("mlbundle").encode()) - for change in [ - lambda v: v.update(estimator_class="os.system"), - lambda v: v.update(class_labels=None), - lambda v: v["prediction"].update(extra="bad"), - lambda v: v["prediction"].update(enable_categorical="yes"), - ]: - value = json.loads(json.dumps(binding)) - change(value) - model.get_booster().set_attr(mlbundle=canonical_dumps(value).decode()) - with pytest.raises(mlbundle.MLBundleError): - Adapter().deserialize(bytes(model.get_booster().save_raw(raw_format="ubj"))) - for limits in [ - mlbundle.ArchiveLimits(max_member_bytes=1), - mlbundle.ArchiveLimits(max_structure_bytes=1), - ]: - with pytest.raises(mlbundle.LimitExceededError): - mlbundle.export(classifier(x, y), tmp_path / "bad.mlbundle", limits=limits) - assert not list(tmp_path.iterdir()) - - def test_readme_example(tmp_path): rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] model = xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit( @@ -572,29 +174,3 @@ def test_readme_example(tmp_path): assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() assert bundle.class_labels == [["cold", "warm"]] - - -def test_other_library_model_bytes(data, tmp_path): - from sklearn.tree import DecisionTreeClassifier - - from mlbundle._sklearn import Adapter as SklearnAdapter - - x, y = data - path = tmp_path / "xgboost.mlbundle" - mlbundle.export(classifier(x, y), path) - other = SklearnAdapter().serialize(DecisionTreeClassifier().fit(x, y)) - with pytest.raises(InvalidModelError, match="UBJSON"): - mlbundle.load(rewrite(path, lambda m, b: b.update({MemberName.UBJSON: other}))) - - -def test_lying_regressor_kind(data, tmp_path): - x, y = data - path = tmp_path / "regressor.mlbundle" - mlbundle.export(xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y), path) - - def change(manifest, members): - manifest["model"].update(kind="classifier", n_classes=[2]) - manifest["target"].update(class_labels=[[False, True]]) - - with pytest.raises(ClaimMismatchError, match="kind"): - mlbundle.load(rewrite(path, change)) diff --git a/tests/test_xgboost_claims.py b/tests/test_xgboost_claims.py new file mode 100644 index 0000000..4ff052b --- /dev/null +++ b/tests/test_xgboost_claims.py @@ -0,0 +1,123 @@ +"""XGBoost claims contracts and rejection paths.""" + +import pandas as pd +import pytest +import xgboost as xgb + +import mlbundle +from mlbundle import ClaimMismatchError, MemberName +from mlbundle._xgboost import Adapter +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.declarations import export_declarations +from xgboost_helpers import classifier, rewrite + + +@pytest.mark.parametrize( + "change,claim", + [ + ( + lambda m, b: m["target"].update(class_labels=[["c", "b", "a"]]), + "class_labels", + ), + ( + lambda m, b: m["target"].update(class_labels=[["x", "b", "c"]]), + "class_labels", + ), + ( + lambda m, b: ( + m["model"].update(n_classes=[2]), + m["target"].update(class_labels=[["a", "b"]]), + ), + "n_classes", + ), + ( + lambda m, b: ( + m["model"].update(kind="regressor", n_classes=None), + m["target"].update(class_labels=None), + ), + "kind", + ), + ( + lambda m, b: m["model"].update(estimator_class="XGBRegressor"), + "estimator_class", + ), + (lambda m, b: m["features"].update(names=list("dcba")), "feature_names"), + ( + lambda m, b: m["model"]["hyperparameters"]["learner_model_param"].update( + base_score="[99E0]" + ), + "hyperparameters", + ), + ( + lambda m, b: ( + m["references"].update(structure=None), + b.pop(MemberName.STRUCTURE), + ), + "structure", + ), + ], +) +def test_lying_manifest(change, claim, xgb_data, tmp_path): + x, y = xgb_data + path = tmp_path / "model.mlbundle" + mlbundle.export( + classifier(pd.DataFrame(x, columns=list("abcd")), y), + path, + class_labels=[["a", "b", "c"]], + ) + with pytest.raises(ClaimMismatchError) as error: + mlbundle.load(rewrite(path, change)) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "alter", + [ + lambda s: s["trees"][0].update(split_condition=99.0), + lambda s: s["trees"][0]["children"][0].update(leaf=99.0), + lambda s: s["trees"].pop(), + ], +) +def test_lying_structure(alter, xgb_data, tmp_path): + x, y = xgb_data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + + def change(m, b): + value = canonical_loads(b[MemberName.STRUCTURE]) + alter(value) + b[MemberName.STRUCTURE] = canonical_dumps(value) + + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, change)) + + +def test_swapped_model(xgb_data, tmp_path): + x, y = xgb_data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + different = Adapter().prepare_export( + classifier(x, (y + 1) % 3), export_declarations(None) + ) + with pytest.raises(ClaimMismatchError): + mlbundle.load( + rewrite( + path, + lambda m, b: b.update( + {MemberName.UBJSON: Adapter().serialize(different)} + ), + ) + ) + + +def test_lying_regressor_kind(xgb_data, tmp_path): + x, y = xgb_data + path = tmp_path / "regressor.mlbundle" + mlbundle.export(xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y), path) + + def change(manifest, members): + manifest["model"].update(kind="classifier", n_classes=[2]) + manifest["target"].update(class_labels=[[False, True]]) + + with pytest.raises(ClaimMismatchError, match="kind"): + mlbundle.load(rewrite(path, change)) diff --git a/tests/test_xgboost_payload.py b/tests/test_xgboost_payload.py new file mode 100644 index 0000000..b6a9eb0 --- /dev/null +++ b/tests/test_xgboost_payload.py @@ -0,0 +1,119 @@ +"""XGBoost payload contracts and rejection paths.""" + +import pytest +import xgboost as xgb + +import mlbundle +from mlbundle import InvalidModelError, MemberName +from mlbundle._xgboost import Adapter +from mlbundle._xgboost.ubjson import decode +from mlbundle.declarations import export_declarations +from xgboost_helpers import classifier, rewrite, ubjson + + +@pytest.mark.parametrize( + "payload", + [ + b"random bytes", + b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{", + b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{}}", + b"{}", + b"PK\x03\x04other library", + ], +) +def test_hostile_payloads(payload, xgb_data, tmp_path): + x, y = xgb_data + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(x, y), path) + with pytest.raises(mlbundle.MLBundleError): + mlbundle.load( + rewrite( + path, + lambda m, b, payload=payload: b.update({MemberName.UBJSON: payload}), + ) + ) + + +def test_native_payload_rejections(xgb_data, tmp_path, monkeypatch): + x, y = xgb_data + model = classifier(x, y) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + native = bytes(model.get_booster().save_raw(raw_format="ubj")) + for payload in [ + native[: len(native) // 2], + bytes(model.get_booster().save_raw(raw_format="json")), + native, + ]: + with pytest.raises(InvalidModelError): + Adapter().deserialize(payload) + with pytest.raises(InvalidModelError): + mlbundle.load( + rewrite( + path, + lambda m, b, payload=payload: b.update( + {MemberName.UBJSON: payload} + ), + ) + ) + prepared = Adapter().prepare_export(model, export_declarations(None)) + native = Adapter().serialize(prepared) + monkeypatch.setattr( + xgb.Booster, + "load_model", + lambda *args: (_ for _ in ()).throw( + xgb.core.XGBoostError("untrusted huge message") + ), + ) + with pytest.raises(InvalidModelError, match="UBJSON payload") as error: + Adapter().deserialize(native) + assert "untrusted" not in str(error.value) + + +@pytest.mark.parametrize( + "change", + [ + lambda learner: learner["learner_model_param"].update(num_feature="100001"), + lambda learner: learner["learner_model_param"].update(num_class="100001"), + lambda learner: learner["gradient_booster"]["model"][ + "gbtree_model_param" + ].update(num_trees="100001"), + lambda learner: learner["gradient_booster"]["model"]["trees"][0][ + "tree_param" + ].update(num_nodes="999999"), + lambda learner: learner["gradient_booster"]["model"]["trees"][0].update( + left_children=[999999] * 5 + ), + lambda learner: learner["objective"].update(name="rank:pairwise"), + lambda learner: learner["gradient_booster"]["model"].update(tree_info=[-1] * 9), + lambda learner: learner["gradient_booster"]["model"].update( + tree_info=[900000000] * 9 + ), + lambda learner: learner["gradient_booster"]["model"].update(tree_info=[0]), + lambda learner: learner["gradient_booster"]["model"].update( + iteration_indptr=[0, 999999999] + ), + ], +) +def test_preflight_rejects_before_native_parser(change, xgb_data, monkeypatch): + x, y = xgb_data + value = decode(bytes(classifier(x, y).get_booster().save_raw(raw_format="ubj"))) + change(value["learner"]) + calls = [] + monkeypatch.setattr(xgb.Booster, "load_model", lambda *args: calls.append(True)) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(ubjson(value)) + assert not calls + + +def test_other_library_model_bytes(xgb_data, tmp_path): + from sklearn.tree import DecisionTreeClassifier + + from mlbundle._sklearn import Adapter as SklearnAdapter + + x, y = xgb_data + path = tmp_path / "xgboost.mlbundle" + mlbundle.export(classifier(x, y), path) + other = SklearnAdapter().serialize(DecisionTreeClassifier().fit(x, y)) + with pytest.raises(InvalidModelError, match="UBJSON"): + mlbundle.load(rewrite(path, lambda m, b: b.update({MemberName.UBJSON: other}))) diff --git a/tests/test_xgboost_validation.py b/tests/test_xgboost_validation.py new file mode 100644 index 0000000..049e50a --- /dev/null +++ b/tests/test_xgboost_validation.py @@ -0,0 +1,154 @@ +"""XGBoost validation contracts and rejection paths.""" + +import json +import subprocess +import sys +import warnings + +import numpy as np +import pytest +import xgboost as xgb + +import mlbundle +from mlbundle import InvalidModelError, UnsupportedModelError +from mlbundle._xgboost import Adapter +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.declarations import export_declarations +from xgboost_helpers import classifier + + +@pytest.mark.parametrize( + "labels", + [ + [[0, 0, 1]], + [[0, 1]], + [[0, 1, 2, 3]], + [["a", 1, "c"]], + [[None, None, None]], + [[float("nan"), 1, 2]], + [[{}, {}, {}]], + ], +) +def test_label_rejections(labels, xgb_data, tmp_path): + x, y = xgb_data + with pytest.raises(UnsupportedModelError): + mlbundle.export( + classifier(x, y), tmp_path / "bad.mlbundle", class_labels=labels + ) + assert not list(tmp_path.iterdir()) + + +def test_unsupported_models(xgb_data, tmp_path): + x, y = xgb_data + + def custom(labels, predictions): + return predictions - labels, np.ones_like(predictions) + + models = [ + xgb.XGBClassifier(), + xgb.XGBRegressor(), + xgb.XGBRanker(n_estimators=2, n_jobs=1).fit(x, y, qid=np.zeros(len(y))), + xgb.XGBRegressor(n_estimators=2, n_jobs=1, objective=custom).fit(x, y), + classifier(x, y).get_booster(), + ] + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + models.extend( + [ + xgb.XGBRFClassifier(n_estimators=2, n_jobs=1).fit(x, y), + xgb.XGBRFRegressor(n_estimators=2, n_jobs=1).fit(x, y), + ] + ) + for model in models: + with pytest.raises(UnsupportedModelError): + mlbundle.export(model, tmp_path / "bad.mlbundle") + regressor = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y) + with pytest.raises(UnsupportedModelError, match="Regressors"): + mlbundle.export(regressor, tmp_path / "bad.mlbundle", class_labels=[[0, 1, 2]]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + linear = xgb.XGBRegressor(booster="gblinear", n_estimators=2, n_jobs=1).fit( + x, y + ) + with pytest.raises(UnsupportedModelError, match="gbtree"): + mlbundle.export(linear, tmp_path / "bad.mlbundle") + multi = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, np.column_stack([y, y])) + with pytest.raises(UnsupportedModelError, match="single target"): + mlbundle.export(multi, tmp_path / "bad.mlbundle") + + +def test_lazy_import_and_missing_extra(xgb_data, tmp_path, monkeypatch): + x, y = xgb_data + path = tmp_path / "model.mlbundle" + model = classifier(x, y) + mlbundle.export(model, path) + code = "import sys,mlbundle; mlbundle.inspect(sys.argv[1]); assert not {'sklearn','skops','numpy','pandas','xgboost','onnx','onnxruntime'} & sys.modules.keys(); print('lazy import and inspect: OK')" + result = subprocess.run( + [sys.executable, "-c", code, str(path)], + capture_output=True, + text=True, + check=True, + ) + assert "lazy import and inspect: OK" in result.stdout + original = mlbundle.registry.import_module + + def missing(name): + if name == "mlbundle._xgboost": + raise ModuleNotFoundError(name="xgboost") + return original(name) + + monkeypatch.setattr(mlbundle.registry, "import_module", missing) + for action in [lambda: mlbundle.export(model, path), lambda: mlbundle.load(path)]: + with pytest.raises( + mlbundle.MissingOptionalDependencyError, match="mlbundle\\[xgboost\\]" + ): + action() + + +def test_prediction_validation(xgb_data, monkeypatch): + x, y = xgb_data + model = classifier(x, y) + predictor = Adapter().predictor(model) + with pytest.raises(ValueError, match="width"): + predictor.predict_proba([[0]]) + with pytest.raises(ValueError, match="regressor"): + predictor.predict(x) + for output in [[[float("nan")] * 3], [[0.5] * 3], [[1.1, -0.1, 0]], [[1.0]]]: + monkeypatch.setattr( + model, "predict_proba", lambda values, output=output: output + ) + with pytest.raises(InvalidModelError): + predictor.predict_proba([x[0]]) + regressor = xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y) + predictor = Adapter().predictor(regressor) + with pytest.raises(ValueError, match="classifier"): + predictor.predict_proba(x) + monkeypatch.setattr( + regressor, "predict", lambda values: [float("inf")] * len(values) + ) + with pytest.raises(InvalidModelError): + predictor.predict(x) + + +def test_binding_validation_and_limits(xgb_data, tmp_path): + x, y = xgb_data + model = Adapter().prepare_export(classifier(x, y), export_declarations(None)) + binding = canonical_loads(model.get_booster().attr("mlbundle").encode()) + for change in [ + lambda v: v.update(estimator_class="os.system"), + lambda v: v.update(class_labels=None), + lambda v: v["prediction"].update(extra="bad"), + lambda v: v["prediction"].update(enable_categorical="yes"), + ]: + value = json.loads(json.dumps(binding)) + change(value) + model.get_booster().set_attr(mlbundle=canonical_dumps(value).decode()) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(bytes(model.get_booster().save_raw(raw_format="ubj"))) + for limits in [ + mlbundle.ArchiveLimits(max_member_bytes=1), + mlbundle.ArchiveLimits(max_structure_bytes=1), + ]: + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export(classifier(x, y), tmp_path / "bad.mlbundle", limits=limits) + assert not list(tmp_path.iterdir()) diff --git a/tests/xgboost_helpers.py b/tests/xgboost_helpers.py new file mode 100644 index 0000000..6416cc7 --- /dev/null +++ b/tests/xgboost_helpers.py @@ -0,0 +1,61 @@ +"""Shared native XGBoost fixtures; tests remain grouped by behavior.""" + +import struct + +import xgboost as xgb + +from mlbundle import MemberName +from mlbundle._xgboost.ubjson import Buffer +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +def classifier(x, y, **params): + return xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1, **params).fit(x, y) + + +def rewrite(path, change): + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + del members[MemberName.MANIFEST] + change(parts, members) + return write_container(parts, members) + + +def ubjson(value): + if isinstance(value, Buffer): + return ( + b"[$" + + value.marker.encode() + + b"#L" + + struct.pack(">q", value.count) + + bytes(value.data) + ) + if isinstance(value, dict): + return ( + b"{" + + b"".join( + b"L" + + struct.pack(">q", len(key.encode())) + + key.encode() + + ubjson(item) + for key, item in value.items() + ) + + b"}" + ) + if isinstance(value, list): + return b"[" + b"".join(ubjson(item) for item in value) + b"]" + if isinstance(value, str): + return b"SL" + struct.pack(">q", len(value.encode())) + value.encode() + return ubjson_scalar(value) + + +def ubjson_scalar(value): + if type(value) is bool: + return b"T" if value else b"F" + if type(value) is int: + return b"L" + struct.pack(">q", value) + if type(value) is float: + return b"D" + struct.pack(">d", value) + assert value is None + return b"Z" From 6fe3f4e9f3372b09bd93017f62d26111e5d621d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 01:21:23 +0200 Subject: [PATCH 06/15] feat: add local schema and explicit samples (#7) --- AGENTS.md | 22 ++- README.md | 89 +++++++--- SECURITY.md | 25 ++- docs/format-adapters.md | 13 +- docs/format-container.md | 5 +- docs/format-schema.md | 156 +++++++++++++++++ docs/format.md | 23 +-- pyproject.toml | 4 +- src/mlbundle/__init__.py | 4 + src/mlbundle/_schema_fields.py | 68 ++++++++ src/mlbundle/contract.py | 1 + src/mlbundle/errors.py | 10 +- src/mlbundle/inspection.py | 12 +- src/mlbundle/model_io.py | 66 +++++--- src/mlbundle/results.py | 2 + src/mlbundle/samples.py | 71 ++++++++ src/mlbundle/schema.py | 151 +++++++++++++++++ src/mlbundle/training.py | 104 ++++++++++++ tests/test_errors.py | 2 + tests/test_inspection.py | 24 ++- tests/test_loading.py | 6 - tests/test_package.py | 46 ++++- tests/test_samples.py | 299 +++++++++++++++++++++++++++++++++ tests/test_schema.py | 289 +++++++++++++++++++++++++++++++ tests/test_schema_fields.py | 131 +++++++++++++++ tests/test_sklearn.py | 2 +- tests/test_xgboost.py | 2 +- uv.lock | 21 ++- 28 files changed, 1556 insertions(+), 92 deletions(-) create mode 100644 docs/format-schema.md create mode 100644 src/mlbundle/_schema_fields.py create mode 100644 src/mlbundle/samples.py create mode 100644 src/mlbundle/schema.py create mode 100644 src/mlbundle/training.py create mode 100644 tests/test_samples.py create mode 100644 tests/test_schema.py create mode 100644 tests/test_schema_fields.py diff --git a/AGENTS.md b/AGENTS.md index 1bb1563..4d22c7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,13 +5,12 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phases 1–4b implement the version 1 format contract, strict manifest models, +Phases 1–5 implement the version 1 format contract, strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing, public metadata-only `inspect`, export/load, -and the scikit-learn, XGBoost and ONNX adapters. Schema inference and the CLI -remain unimplemented. -Rules for later phases must not be described as implemented until code and tests -prove them. `docs/format.md` owns the wire contract. +the scikit-learn, XGBoost and ONNX adapters, local schema inference, training +dtypes and explicit samples. The CLI remains unimplemented. +Rules for later phases require code and test proof before claiming implementation. `docs/format.md` owns the wire contract. ## What makes mlbundle special? @@ -146,8 +145,8 @@ still preserve validation, security and reproducibility. ## Development -Work from the repository root using `uv`. Python 3.14 is the minimum because -mlschema 0.2.1 supports that version. Keep `uv_build` as the build backend. +Work from the repository root using `uv` on Python 3.14. The optional `schema` +extra uses mlschema 0.2.1 on 3.14 only. Keep `uv_build` as the build backend. ```shell uv python install 3.14 @@ -211,7 +210,9 @@ wrappers with closed probability/regression objectives and one target. Original class labels supplied through output-major `class_labels=` are bound to a native copy through an mlbundle-owned booster attribute and verified on load. Omitted labels use encoded indices; sklearn rejects this option because it owns classes_. -Schema inference is future work; export currently includes no training rows. +Local `training_data=` inference uses mlschema with `__`, excludes a named target, +and records training dtypes. Only explicit `sample_rows=N` includes real rows. +Schema/report and sample validation is shared by export, inspect and load. ONNX preserves path/bytes unchanged (ModelProto is serialized once), rejects external tensors, custom domains, functions and training/runtime configuration before a CPU session, and verifies graph signatures, metadata and environment @@ -263,6 +264,9 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. - `src/mlbundle/canonical.py` — deterministic JSON encoding and bounded strict decoding. - `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. +- `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. +- `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. +- `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. - `src/mlbundle/_sklearn/` — skops trust/loading, parameter and claim extraction, native structure, nested payload bounds and validated prediction. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. @@ -280,7 +284,7 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. - `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. - `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. -- `docs/format.md` — version 1 wire specification, with normative container, adapter/structure and ONNX sections in linked files. +- `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. diff --git a/README.md b/README.md index 680ef9e..7c909e8 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # mlbundle -mlbundle is a Python library for packaging a trained model, a rich manifest and -its internal structure in one compressed `.mlbundle` file. The same package +mlbundle is a Python library for packaging a trained model, a rich manifest, +locally inferred schema and structure in one compressed `.mlbundle` file. The same package writes bundles on the training machine and reads them with validation in a runtime. -Local schema inference will be added in a later phase. -**Status: pre-alpha, Phase 4b.** The [version 1 format contract](docs/format.md), +**Status: pre-alpha, Phase 5.** The [version 1 format contract](docs/format.md), strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing and public `inspect` and public sklearn/XGBoost/ONNX `export`/`load` are implemented and tested. -Schema inference and the CLI are still missing. Nothing has been +Local schema inference, training dtypes and explicit samples are implemented. +The CLI is still missing. Nothing has been published to PyPI. ## Why it exists @@ -21,14 +21,14 @@ DataFrame. Loading those files can execute arbitrary code, the artifacts do not record the training environment, and training data leaves the user's machine just to infer a schema. -mlbundle will compute the schema locally with -[mlschema](https://github.com/UlloaSP/mlschema), record the model and training -environment, and carry everything the runtime needs in one file. Training rows -will be excluded unless the user explicitly asks to include samples. +mlbundle computes the schema locally with +[mlschema](https://github.com/UlloaSP/mlschema), records the model and training +environment, and carries everything the runtime needs in one file. Training rows +are excluded unless the user explicitly asks to include samples. -The first version will support scikit-learn through skops, XGBoost through its -native UBJSON format, and existing ONNX files without conversion. Model-library -dependencies are optional extras (`sklearn`, `xgboost`, `onnx`, `all`), with lazy +The first version supports scikit-learn through skops, XGBoost through its +native UBJSON format, and existing ONNX files without conversion. Model and schema +dependencies are optional extras (`sklearn`, `xgboost`, `onnx`, `schema`, `all`), with lazy adapter resolution. The design takes ideas from MLflow's versioned manifest and native model formats; it copies no MLflow code. mlbundle is MIT licensed. @@ -70,7 +70,7 @@ Inspection validates format and integrity. It cannot attest that model-derived claims or structure match the model. `load` supplies that guarantee and returns the native model, predictor and metadata. Model-exposed feature names take precedence over `feature_names=`; otherwise those names are declarations with -origin `dataframe`, or omitted names become `feature_1` through `feature_N`. +origin `declared`, or omitted names become `feature_1` through `feature_N`. `target_name=` and `metadata=` record author declarations. Existing destinations require `overwrite=True`. Paths, bytes and binary streams are accepted by `load`. @@ -81,7 +81,55 @@ LinearRegression, Ridge and scaler/classifier pipelines are covered by round-tri tests. Multi-output labels and probabilities retain output-major order. Pipeline structure is null because v1 does not map transformed features. Custom types, user functions, HistGradientBoosting and XGBoost steps inside sklearn pipelines -are rejected. Export currently includes no training DataFrame, schema or samples. +are rejected. + +For local schema inference, install `mlbundle[sklearn,schema]` on Python 3.14. +Use the training frame with string column names in model input order: + +```python +import pandas as pd +from sklearn.tree import DecisionTreeClassifier +from mlbundle import export, inspect, load + +training = pd.DataFrame( + { + "temperature": [0.0, 1.0, 0.1, 0.9], + "humidity": [1.0, 0.0, 0.9, 0.1], + "target": ["cold", "warm", "cold", "warm"], + } +) +features = training.drop(columns="target") +model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) +export(model, "schema.mlbundle", training_data=training, target_name="target") +view = inspect("schema.mlbundle") +assert view.manifest.features.dtypes == ["float64", "float64"] +assert view.schema["reports"][0]["labels"] == ["cold", "warm"] +assert view.samples is None +assert load("schema.mlbundle").schema == view.schema +``` + +Without `target_name`, the frame must contain only features. With a frame, a +named target must exist and is excluded from inference and samples. Native +names take precedence and must match frame columns in order; a model without +names uses origin `dataframe`. `feature_names=` with a frame must agree exactly. +One-hot binary columns such as `color__red`/`color__blue` share a category field +using the same `__` convention as MLSuite. Schema is a declaration; load verifies +its mappings and reports against the model's validated metadata. + +What leaves the machine: column names, dtype strings, inferred kinds, +nullability, numeric steps from dtypes, category values (including unused ones), +one-hot suffixes and pair-axis metadata, plus class labels and model metadata. +Inference adds no numeric/date ranges, means, arbitrary text values or example +rows. Category names can be sensitive. Native models and structure may themselves +retain training information; omitting samples is not protection against memorization. + +`export(..., training_data=training, target_name="target", sample_rows=2)` explicitly +includes the first two feature rows. **Samples are real data.** This option is +off by default, requires a positive integer within the frame, and excludes the +target and index. Missing values become null, timestamps ISO strings and NumPy/ +categorical scalars JSON values. Both members have a 16 MiB ceiling. See +[the schema and sample contract](docs/format-schema.md) for exact shapes, +conversion, reports per output and MLSuite's series presentation mapping. XGBoost supports fitted `XGBClassifier` and `XGBRegressor` tree wrappers, with native UBJSON stored and loaded entirely in memory. Train classifiers on encoded @@ -157,10 +205,11 @@ exact shapes, claim fields and native dependency limits. ## Development Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows -mlschema 0.2.1; source code must avoid syntax that requires Python 3.14 so the +the development baseline; source code must avoid syntax that requires Python 3.14 so the supported range can be widened later. Pydantic v2 is the sole core runtime dependency, for strict manifest validation. Model libraries are optional; -mlschema integration comes in Phase 5. +mlschema is the lazy `schema` extra, included in `all`. Its current version +supports Python 3.14 only; mlbundle's own Python range has no upper bound. ```shell uv python install 3.14 @@ -173,12 +222,14 @@ uv run pytest uv build ``` -The default dev group installs all model extras. Base consumers can use +The default dev group installs all adapter and schema extras. Base consumers can use `uv sync --no-dev` without installing model libraries. The committed `uv.lock` fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX Runtime to the target MLSuite runtime baseline; all three adapters are exercised -by the round-trip tests. Pandas is a development dependency for DataFrame tests, -not a core runtime dependency. `uv run pytest` +by the round-trip tests. The schema extra pins mlschema 0.2.1 from PyPI and brings +pandas transitively; neither is a core dependency. Import and inspect require +neither; load works without them, although native sklearn imports an installed +pandas opportunistically. `uv run pytest` produces terminal and XML coverage reports and enforces an 80% minimum. CI runs lint, type checks, and Python 3.14 tests on Ubuntu and Windows. `uv build` creates local wheel and source archives; it does not publish them. diff --git a/SECURITY.md b/SECURITY.md index 38f0edf..87d9d7e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,9 @@ # Security policy -mlbundle is pre-alpha. Phases 1–4b implement strict manifest validation, +mlbundle is pre-alpha. Phases 1–5 implement strict manifest validation, contract helpers, secure container reading/writing, metadata-only `inspect`, -public export/load and the sklearn, XGBoost and ONNX adapters. +public export/load, the sklearn, XGBoost and ONNX adapters, local schema inference +and explicitly requested sample rows. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. The writer publishes complete validated files atomically where hard links work; @@ -163,10 +164,22 @@ eliminate vulnerabilities in native parsers and dependencies. A consuming runtime still owns authentication, authorization, process isolation and execution resource limits. -Training rows must stay on the training machine unless the user explicitly -requests samples. Samples are real data; schemas, feature names and user -metadata may also contain sensitive information. Users must review what they -choose to share. +Training frames are never serialized. Schema inference runs locally and includes +column names, dtype strings, inferred kinds, nullability, categorical values +(including unused categories), one-hot suffixes, dtype-based numeric steps and +pair-axis metadata. It adds no numeric/date ranges, means, row counts, arbitrary +text values or example rows. These declarations may still be sensitive and are +not proof of the underlying training data. Native models, learned structure, +class labels and user metadata can independently reveal training information. +Omitting samples does not prevent model memorization or model-side disclosure. + +Only explicit `sample_rows=N` includes real feature rows: exactly the first N, +without target or index. The schema/sample members have 16 MiB/depth-32 limits. +Inspect and load validate closed shapes, feature mappings/row keys and report +kind/labels without mlschema. Load also verifies native model claims; label-free +ONNX original labels remain declarations. Consumers must treat schema and sample +values as untrusted display/input data, never executable instructions. +See [the exact data-derived contract](docs/format-schema.md) and review what is shared. ## Reporting a vulnerability diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 6aa493a..566ce5b 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -63,12 +63,14 @@ unimplemented adapters while looking for one that recognizes its input. A missin optional dependency reports `pip install "mlbundle[EXTRA]"`. An unrecognized object raises `UnsupportedModelError`. -`export(model, destination, *, feature_names=None, class_labels=None, target_name=None, metadata=None, -overwrite=False, limits=None)` returns a small `MetadataView` of the written +`export(model, destination, *, training_data=None, feature_names=None, class_labels=None, +target_name=None, sample_rows=None, metadata=None, overwrite=False, limits=None)` +returns a `MetadataView` of the written bundle. It serializes, extracts, reloads and compares both claims and structure before writing. Model-exposed names take precedence over supplied names. It captures distribution metadata, including `claims.onnx` in the environment for -the ONNX adapter. No unimplemented schema/data parameters are accepted. +the ONNX adapter. [Local schema export](format-schema.md) owns frame alignment, +training dtypes, schema/report declarations and explicit sample selection. `load(source, *, limits=None)` validates the container, resolves its closed enum, deserializes, recomputes claims/structure and compares canonical bytes before constructing a `LoadedBundle`. `ClaimMismatchError` identifies the claim without @@ -176,8 +178,9 @@ finite probabilities, range and row sums in the adapter. Classifier prediction uses `predict_proba`; regressor prediction uses `predict`. Unsupported probability or regression contracts fail rather than synthesizing outputs. -`MetadataView` carries `manifest` and a tuple of `MemberName` values including -the manifest; it has no model. `LoadedBundle` additionally carries the exact +`MetadataView` carries `manifest`, a tuple of `MemberName` values including +the manifest, and optional `schema` and `samples` JSON values; it has no model. +`LoadedBundle` additionally carries the exact native object, a uniform predictor, optional schema/structure/sample values, and properties for kind, library, class name, ordered feature names and origin, and output-major original class labels. Calling the prediction method for the wrong diff --git a/docs/format-container.md b/docs/format-container.md index 5f65b95..3ade8dd 100644 --- a/docs/format-container.md +++ b/docs/format-container.md @@ -126,8 +126,9 @@ Public `mlbundle.inspect(source, *, limits=None)` returns `MetadataView` with the manifest and fixed-order member listing. It validates ZIP records, limits, manifest, member hashes and JSON syntax without importing model libraries. It cannot attest that model-derived claims or structure match the model; only -the full validating `load` can do that. The current export API includes neither -schema nor samples; future sample export must require explicit user intent. +the full validating `load` can do that. Inspection additionally validates and +exposes schema and samples under [their declaration contract](format-schema.md), +without pandas or mlschema. Export requires explicit `sample_rows=` for real rows. Container/layout/CRC inconsistencies raise `InvalidContainerError`; resource ceilings raise `LimitExceededError`. Invalid manifest JSON or typed fields raise diff --git a/docs/format-schema.md b/docs/format-schema.md new file mode 100644 index 0000000..1f30740 --- /dev/null +++ b/docs/format-schema.md @@ -0,0 +1,156 @@ +# Version 1 schema and samples + +Normative section of [the format contract](format.md). Phase 5 implements local +mlschema 0.2.1 inference. Schema and samples are declarations from data the +reader never sees. Integrity does not authenticate that data or its history. + +## Export inputs + +`export(training_data=frame)` accepts a nonempty pandas DataFrame. Without +`target_name`, all columns are features. With `target_name`, that column must +exist and is excluded before inference, dtype capture and sample selection. +Without a frame, `target_name` remains a metadata declaration as before. +Index values are never included. The input frame is not modified. + +Column names must be unique, nonempty strings of at most 1,024 characters. +Their count must equal the native feature count. When native names exist, +columns must match those names **in order**, after target exclusion. Supplying +`feature_names` with a frame requires exactly those feature columns in order; +it cannot rename or reorder them. Frame errors raise `InvalidSchemaError` before +publication. Names remain model-origin when the model exposes them; otherwise +the frame yields dataframe-origin names. Training dtypes are `str(dtype)` in +feature order, separate from verified `model_dtypes`. + +Export calls exactly `mlschema.infer_schema(feature_frame, onehot_separator="__")`, +without builders, overrides or custom kinds. It uses the same separator as +MLSuite's `schema_service.py`. Unlike MLSuite's older positional fallback, named +frame columns are preserved even when a model has no names; inferred mappings +therefore always refer to manifest names, never integer positions. Fields retain +the direct mlschema output. MLSuite's MLForm integration still converts a `series` +field's `field1`/`field2` into `columns` with those IDs and removes nested +`mappedTo`; this is presentation mapping and needs no DataFrame. Consumers may +format typed class labels for display and select reports by output index. + +`mlbundle[schema]` optionally installs mlschema 0.2.1 from PyPI, transitively +pandas; it is included in `all`. The dependency is marked for Python 3.14 because +that mlschema version requires `>=3.14,<3.15`. mlbundle's own `>=3.14` range and +sole core dependency (Pydantic) are unchanged. Training export with missing +pandas/mlschema raises `MissingOptionalDependencyError` naming `schema`. +mlbundle import and inspection do not import either library. Loading does not +require them, although native sklearn imports an installed pandas opportunistically. + +## `schema.json` + +The exact top-level object is: + +```json +{ + "schema_version": "1", + "fields": [ + {"kind":"number","label":"age","mappedTo":"age","required":true,"step":1.0} + ], + "reports": [ + {"kind":"classifier","label":"Predicted class","mappedTo":"classifier", + "output":0,"labels":["cold","warm"],"showClassProbabilities":true} + ] +} +``` + +All keys shown are required. Unknown keys and versions are rejected. Arrays are +ordered; `fields` has 1–100,000 entries and `reports` 1–1,024. Typed records use +strict types, finite numbers and no arbitrary extensibility. This envelope +supports only the default inferred vocabulary, not every mlschema override. + +Every ordinary field has exactly `kind`, `label`, `mappedTo`, `required`, plus +the kind-specific keys below. Labels are nonempty strings of at most 100 +characters; `mappedTo` is a manifest feature name; `required` is a boolean. +At the top level, an ordinary field's label equals its feature name. + +| Kind | Additional required keys | +| --- | --- | +| `text`, `boolean`, `date` | None. | +| `number` | `step`: finite positive number; default inference emits `1.0` for integers or `0.1` for floats. | +| `category` | `options`: 1–100,000 unique strings, retaining categorical order and unused categories. | +| `series` | `field1`, `field2`: ordinary scalar fields from the same feature column; no nested series or one-hot groups. Subfield labels describe pair axes; each `mappedTo` equals its parent mapping. | +| `onehot-category` | Special shape below. | + +A one-hot group has exactly `kind`, `label`, `required`, `options`; no parent +`mappedTo`. Its label is the shared prefix before the first `__`. At least two +binary columns with that prefix form a group. There are 2–100,000 options, each +exactly `{"label":SUFFIX,"value":SUFFIX,"mappedTo":COLUMN}`. These three strings +are nonempty and at most 1,024 characters. `COLUMN = PREFIX + "__" + SUFFIX`; +the option label and value agree. Other columns stay ordinary fields, even if +their names contain `__`; the reader cannot verify whether their values were +binary. Group options preserve feature order, and fields are emitted in order +of their first mapped feature. Non-adjacent group columns are supported. + +Field/option mappings must cover every manifest feature exactly once, with no +duplicates, missing features, declared target or additional columns. The field count may be +smaller than the feature count because of one-hot groups. The reader verifies +these mappings and ordering without mlschema, not historical inference outcomes. + +There is exactly one report per model output, in output order. `output` is a +zero-based integer index; every report has the model's kind and `mappedTo` equal +to that kind. A classifier report has exactly the classifier keys in the example: +its label is `Predicted class`, `showClassProbabilities` is exactly true, and +`labels` equals that output's manifest class-label array by canonical byte equality. +Types and prediction-column order are preserved. A regressor report has exactly +`kind`, `label`, `mappedTo`, `output`, with label `Predicted value` and no labels +or probability setting. This extends MLSuite's single-output report shape with +an output index and typed labels instead of display-string coercion. + +Inspection validates reports against the manifest; it cannot verify that manifest +against the model. Load also verifies native claims, so a changed manifest/report +pair cannot conceal disagreement with native kind, counts or labels. Label-free +ONNX mappings remain count-checked declarations, as in the ONNX contract. + +## What leaves the training machine + +Default mlschema inference carries feature names/labels, field kinds, nullability +(`required`), categorical values (including unused categories), one-hot names and +suffixes, numeric step determined by dtype, and pair-axis names/kinds/nullability. +Training dtype strings and reports carry their declarations and native/resolved +class labels. Inference emits **no numeric/date minima, maxima, means, counts, +example values, defaults or arbitrary text row values**. No schema overrides are +offered. These declarations can still reveal sensitive category or column names. +Native models, learned structure and user metadata can independently reveal data; +omitting samples is not a guarantee against model memorization or model metadata. + +## `samples.json` + +Samples are **real user data**, enabled only by an explicit `sample_rows=N` with +a frame. N must be a strict positive integer (not a bool), no larger than the +frame length. Exactly the first N feature rows are selected; no random sampling, +implicit clipping, target column or index is included. The option defaults to +null; zero is rejected to keep sample intent explicit. + +The member is a canonical JSON array of row objects, for example +`[{"age":42,"active":true}]`. Each row must have exactly the declared feature +names. JSON keys are sorted canonically; consumers use manifest feature order. +An externally written empty array is valid, but public export selects N > 0. +Reader validation also rejects a declared target name that is a sample feature. + +Cell mappings: Python and NumPy boolean/integer/real/string scalars become JSON +scalars; float32 promotes exactly to binary64. Categorical cells use their scalar +values. `None`, pandas NA/NaT and scalar NaN become null. Date/datetime/Timestamp +and NumPy datetime64 become ISO 8601 strings; timestamps retain available +fractional precision and UTC offset, dates use `YYYY-MM-DD`. Infinities use +canonical `$float` tags. Lists/tuples become arrays; dictionaries require string +keys and recursively use the same rules. Byte strings, complex values, timedeltas, +arbitrary objects and unsupported NumPy values fail without stringification. + +Schema and samples each have the existing 16 MiB cap and JSON depth 32, further +lowerable through `ArchiveLimits`. Sample rows are encoded incrementally within +the byte budget; no row is truncated. The five-member ceiling already permits +manifest, model, schema, structure and samples together. + +## Error boundaries + +Invalid schema shape, vocabulary, mappings, order, report kind or report labels +raise `InvalidSchemaError`. Invalid sample selection, cell conversion or row keys +raise `InvalidSamplesError`. These are bounded `MLBundleError` subclasses without +data values or third-party exception text. Native claim disagreement remains +`ClaimMismatchError`, including a manifest/report lie that agrees internally but +disagrees with the model. Malformed JSON and resource caps retain `InvalidJSONError` +and `LimitExceededError`. Reference/integrity or actual member-presence mismatches +retain `InvalidManifestError` or `InvalidContainerError` at the container boundary. diff --git a/docs/format.md b/docs/format.md index 60cad0c..cc8f17a 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,14 +2,15 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -"Must" denotes a compatibility or security requirement. Phases 1–4b implement +"Must" denotes a compatibility or security requirement. Phases 1–5 implement the manifest, encoding, limits, secure container, metadata-only inspection, public export/load and the sklearn, XGBoost and ONNX adapters. -Schema inference and the CLI remain unimplemented. +Local schema inference and explicit samples are implemented; the CLI is missing. The normative container rules are in [Container and resource limits](format-container.md). The normative structure, adapter and result rules are in [Adapters and structure](format-adapters.md). The implemented ONNX mapping and validation rules are in [ONNX](format-onnx.md). +The normative schema/declaration rules are in [Schema and samples](format-schema.md). These sections are part of this version 1 contract. ## Members @@ -20,7 +21,7 @@ These sections are part of this version 1 contract. | `model.skops` | Exactly one model member; scikit-learn, skops format. | | `model.ubj` | Exactly one model member; XGBoost native UBJSON format. | | `model.onnx` | Exactly one model member; original, self-contained ONNX bytes, unchanged. | -| `schema.json` | Optional; object with `fields` and `reports` arrays from local schema/report generation. No training DataFrame. The schema integration phase defines the supported mlschema field/report vocabulary. | +| `schema.json` | Optional; exact object with `schema_version`, `fields` and `reports` from local inference. See [Schema and samples](format-schema.md). No training DataFrame. | | `structure.json` | Optional only for families without extractable structure; object as defined below. | | `samples.json` | Optional; array of row objects keyed by feature name, in declared feature order when consumed. Only explicit user intent may enable it. Values use the canonical JSON value rules. Samples are real user data. | @@ -108,17 +109,16 @@ faithful projection. The round-trip comparison checks all these records. | `names` | array of 1–100,000 unique nonempty strings, each at most 1,024 characters | Effective input names, in prediction order; length equals `n_features`. | | `dtypes` | equally sized array of null or nonempty strings of at most 256 characters | Training DataFrame dtype declarations in feature order; null means unknown. They do not prove model input types. | | `model_dtypes` | optional equally sized array of nonempty strings of at most 256 characters | Verified dtypes exposed by the native model, or null if it exposes none; separate from training dtypes. ONNX uses normalized NumPy dtype names such as `float32`, `float64`, `int32`, `int64`. | -| `origin` | `"model"`, `"dataframe"`, `"generated"` | Model names, declared training-input names, or generated names. | +| `origin` | `"model"`, `"dataframe"`, `"declared"`, `"generated"` | Native names, local frame columns, explicitly supplied names, or generated names. | If a model exposes feature names, origin must be `model` and names must equal -its extracted names in order. Otherwise `dataframe` names are author declarations -of training-input column names: currently supplied through `feature_names=`, -and later also through a locally supplied training frame. This identifier does -not prove that a DataFrame was used. The reader verifies their count, not their -historical truth. With no model or declared names, origin is `generated` and +its extracted names in order, even when a frame supplies training dtypes. +Otherwise `dataframe` means columns from `training_data=`, and `declared` means +`feature_names=` without a frame. Both are author declarations: the reader checks +count/alignment, not historical truth. With neither, origin is `generated` and names must be `feature_1` through `feature_N`. Claim extraction represents missing model feature names as null, so falsely declaring a named model's origin as -`dataframe` or `generated` also fails comparison. Model dtype extraction is +`dataframe`, `declared` or `generated` also fails comparison. Model dtype extraction is independent of name origin and must be compared even when its claim is null. ### `target` @@ -259,7 +259,7 @@ metadata. Classifier extraction may leave labels null only when the native model carries none; the shared pipeline resolves the declaration as specified above. Library/serialization/member pairing is validated by the closed registry. Structure is compared separately using the same canonical encoding. -Schema, training dtypes, frame-origin names, target name, training environment, +Schema, training dtypes, frame/declared-origin names, target name, training environment, user metadata, identity and samples are not verified against the model; they must still pass their own validation and integrity checks. Inspection verifies format and integrity only, and must never attest that model claims match the model. @@ -272,6 +272,7 @@ encoding or mandatory structure shapes requires a new format version and an explicit reader implementation. Tightening caller-selected resource limits does not change the format. Corrections to documentation that preserve wire behavior and implementation changes behind these contracts do not require a version bump. +Phase 5 completes the unreleased v1 schema vocabulary and adds `declared` origin. Hashes detect corruption, not deliberate tampering: an attacker can replace a model and update every record. Claim recomputation prevents a bundle from showing diff --git a/pyproject.toml b/pyproject.toml index 1fa8e7d..64f2653 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,9 +40,11 @@ dependencies = ["pydantic>=2.13.4,<3"] sklearn = ["scikit-learn>=1.6", "skops>=0.16.0"] xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] +schema = ["mlschema==0.2.1; python_version == '3.14'"] all = [ "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", "onnx>=1.23.0", "onnxruntime>=1.30.0", + "mlschema==0.2.1; python_version == '3.14'", ] [project.urls] @@ -57,7 +59,7 @@ dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", "pre-commit>=4.6.0", - # Install every adapter in the default development environment. + # Install every adapter and schema inference in the development environment. "mlbundle[all]", "pandas>=3.0.6", ] diff --git a/src/mlbundle/__init__.py b/src/mlbundle/__init__.py index 78ac138..22ddb49 100644 --- a/src/mlbundle/__init__.py +++ b/src/mlbundle/__init__.py @@ -21,6 +21,8 @@ InvalidJSONError, InvalidManifestError, InvalidModelError, + InvalidSamplesError, + InvalidSchemaError, LimitExceededError, MissingOptionalDependencyError, MLBundleError, @@ -50,6 +52,8 @@ "InvalidJSONError", "InvalidManifestError", "InvalidModelError", + "InvalidSamplesError", + "InvalidSchemaError", "Library", "LimitExceededError", "LoadedBundle", diff --git a/src/mlbundle/_schema_fields.py b/src/mlbundle/_schema_fields.py new file mode 100644 index 0000000..50fd57f --- /dev/null +++ b/src/mlbundle/_schema_fields.py @@ -0,0 +1,68 @@ +"""Closed inference-only mlschema 0.2.1 wire vocabulary; no optional imports.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field, model_validator + +from ._base import ContractModel, Name +from .limits import MAX_FEATURES + +FieldLabel = Annotated[str, Field(min_length=1, max_length=100)] + + +class FieldBase(ContractModel): + label: FieldLabel + required: bool + mappedTo: Name + + +class SimpleField(FieldBase): + kind: Literal["text", "boolean", "date"] + + +class NumberField(FieldBase): + kind: Literal["number"] + step: Annotated[float, Field(gt=0)] + + +class CategoryField(FieldBase): + kind: Literal["category"] + options: Annotated[list[str], Field(min_length=1, max_length=MAX_FEATURES)] + + @model_validator(mode="after") + def unique_options(self) -> CategoryField: + if len(set(self.options)) != len(self.options): + raise ValueError("Category options must be unique.") + return self + + +ScalarField = Annotated[ + SimpleField | NumberField | CategoryField, Field(discriminator="kind") +] + + +class SeriesField(FieldBase): + kind: Literal["series"] + field1: ScalarField + field2: ScalarField + + +class OneHotOption(ContractModel): + label: Name + value: Name + mappedTo: Name + + +class OneHotField(ContractModel): + kind: Literal["onehot-category"] + label: FieldLabel + required: bool + options: Annotated[list[OneHotOption], Field(min_length=2, max_length=MAX_FEATURES)] + + +SchemaField = Annotated[ + SimpleField | NumberField | CategoryField | SeriesField | OneHotField, + Field(discriminator="kind"), +] diff --git a/src/mlbundle/contract.py b/src/mlbundle/contract.py index b16ee36..aa65b3a 100644 --- a/src/mlbundle/contract.py +++ b/src/mlbundle/contract.py @@ -27,6 +27,7 @@ class SerializationFormat(StrEnum): class FeatureOrigin(StrEnum): MODEL = "model" DATAFRAME = "dataframe" + DECLARED = "declared" GENERATED = "generated" diff --git a/src/mlbundle/errors.py b/src/mlbundle/errors.py index 4a6e54a..5208727 100644 --- a/src/mlbundle/errors.py +++ b/src/mlbundle/errors.py @@ -66,11 +66,19 @@ class InvalidModelError(MLBundleError): """Native model content is malformed or violates the adapter contract.""" +class InvalidSchemaError(MLBundleError): + """Training inputs or schema declarations violate the schema contract.""" + + +class InvalidSamplesError(MLBundleError): + """Sample selection or row declarations violate the samples contract.""" + + class MissingOptionalDependencyError(MLBundleError): def __init__(self, extra: str) -> None: self.extra = _identifier(extra) super().__init__( - f'Install this adapter with pip install "mlbundle[{self.extra}]".' + f'Install this feature with pip install "mlbundle[{self.extra}]".' ) diff --git a/src/mlbundle/inspection.py b/src/mlbundle/inspection.py index 6ea98b0..727f7ad 100644 --- a/src/mlbundle/inspection.py +++ b/src/mlbundle/inspection.py @@ -1,9 +1,10 @@ """Public metadata-only inspection, without resolving any model adapter.""" -from ._container_rules import MEMBER_ORDER, BundleSource +from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits from .container import read_container from .limits import ArchiveLimits from .results import MetadataView +from .schema import read_declarations def inspect( @@ -14,7 +15,12 @@ def inspect( Inspection cannot attest that model-derived claims or structure match the model. Only a full validating model load can provide that guarantee. """ - manifest, members = read_container(source, limits=limits) + selected = effective_limits(limits) + manifest, members = read_container(source, limits=selected) + schema, samples = read_declarations(manifest, members, selected) return MetadataView( - manifest, tuple(name for name in MEMBER_ORDER if name in members) + manifest, + tuple(name for name in MEMBER_ORDER if name in members), + schema=schema, + samples=samples, ) diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py index c329533..ee749b3 100644 --- a/src/mlbundle/model_io.py +++ b/src/mlbundle/model_io.py @@ -24,11 +24,13 @@ ) from .declarations import ExportDeclarations, export_declarations, resolve_labels from .environment import capture_environment -from .errors import ClaimMismatchError, InvalidModelError, LimitExceededError +from .errors import ClaimMismatchError, LimitExceededError from .inspection import inspect from .limits import ArchiveLimits from .registry import find_adapter, get_adapter from .results import LoadedBundle, MetadataView +from .schema import read_declarations +from .training import TrainingDeclaration, prepare_training from .writing import write_container @@ -39,6 +41,7 @@ def _manifest_parts( *, structure: dict[str, JsonValue] | None, feature_names: Sequence[str] | None, + training: TrainingDeclaration | None, target_name: str | None, metadata: Mapping[str, JsonValue] | None, ) -> dict[str, object]: @@ -46,7 +49,7 @@ def _manifest_parts( origin = FeatureOrigin.MODEL if names is None: origin = ( - FeatureOrigin.DATAFRAME + FeatureOrigin.DECLARED if feature_names is not None else FeatureOrigin.GENERATED ) @@ -55,6 +58,8 @@ def _manifest_parts( if feature_names is not None else [f"feature_{index}" for index in range(1, claims.n_features + 1)] ) + if training is not None: + names, origin = training.names, FeatureOrigin.DATAFRAME model = claims.model_dump( mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} ) @@ -68,14 +73,20 @@ def _manifest_parts( "model": model, "features": { "names": names, - "dtypes": [None] * claims.n_features, + "dtypes": [None] * claims.n_features + if training is None + else training.dtypes, "model_dtypes": claims.feature_dtypes, "origin": origin.value, }, "target": {"class_labels": claims.class_labels, "name": target_name}, "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), "references": { - "structure": None if structure is None else MemberName.STRUCTURE.value + "structure": None if structure is None else MemberName.STRUCTURE.value, + "schema": None if training is None else MemberName.SCHEMA.value, + "samples": None + if training is None or training.samples is None + else MemberName.SAMPLES.value, }, "user_metadata": {} if metadata is None else dict(metadata), } @@ -85,9 +96,11 @@ def export( model: object, destination: str | PathLike[str], *, + training_data: object | None = None, feature_names: Sequence[str] | None = None, class_labels: Sequence[Sequence[Label]] | None = None, target_name: str | None = None, + sample_rows: int | None = None, metadata: Mapping[str, JsonValue] | None = None, overwrite: bool = False, limits: ArchiveLimits | None = None, @@ -98,7 +111,16 @@ def export( supplied names are declarations; omitted names become feature_1 … feature_N. Class labels are output-major declarations handled by the native adapter; omitted labels use native labels or generated encoded indices. - No training rows, schema or sample data are included by this API. + training_data requires mlbundle[schema] and a nonempty pandas DataFrame. + Its columns must match native names and order; feature_names must agree. + With a frame, target_name must name its target column, which is excluded; + otherwise the frame contains features only. Dtypes and locally inferred + schema fields are declarations. Category values and nullability leave the + training machine in the schema, but rows stay local by default. + sample_rows explicitly includes the first N feature rows: samples are real + user data. N must be a positive integer within the frame; target values and + the index are excluded. Missing values become null, timestamps ISO strings, + categorical/NumPy scalars JSON scalars and infinities canonical float tags. """ selected = effective_limits(limits) declarations = export_declarations(class_labels) @@ -108,6 +130,14 @@ def export( if len(data) > selected.member_bytes(adapter.model_member): raise LimitExceededError("Model member size limit exceeded.") claims = resolve_labels(adapter.extract_claims(model), declarations) + training = prepare_training( + training_data, + claims, + feature_names=feature_names, + target_name=target_name, + sample_rows=sample_rows, + limits=selected, + ) structure = adapter.extract_structure(model) restored = adapter.deserialize(data) verify_claims( @@ -119,12 +149,17 @@ def export( members[MemberName.STRUCTURE] = canonical_dumps( structure, max_bytes=selected.member_bytes(MemberName.STRUCTURE) ) + if training is not None: + members[MemberName.SCHEMA] = training.schema + if training.samples is not None: + members[MemberName.SAMPLES] = training.samples parts = _manifest_parts( claims, adapter.library, MODEL_MEMBERS[adapter.library][0], structure=structure, feature_names=feature_names, + training=training, target_name=target_name, metadata=metadata, ) @@ -146,6 +181,7 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded """Verify format, integrity and recomputed native claims before returning a model.""" selected = effective_limits(limits) manifest, members = read_container(source, limits=selected) + schema, samples = read_declarations(manifest, members, selected) adapter = get_adapter(manifest.model.library) model = adapter.deserialize(members[adapter.model_member]) actual = adapter.extract_claims(model) @@ -168,28 +204,12 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded if (MemberName.STRUCTURE in members) != (extracted is not None): raise ClaimMismatchError("structure") verify_structure(structure, extracted) - schema = _optional_json(members, MemberName.SCHEMA, selected) - samples = _optional_json(members, MemberName.SAMPLES, selected) - if MemberName.SCHEMA in members and ( - not isinstance(schema, dict) - or not isinstance(schema.get("fields"), list) - or not isinstance(schema.get("reports"), list) - ): - raise InvalidModelError("Schema member must contain fields and reports arrays.") - if MemberName.SAMPLES in members and ( - not isinstance(samples, list) - or any( - not isinstance(row, dict) or set(row) != set(manifest.features.names) - for row in samples - ) - ): - raise InvalidModelError("Samples member must contain row objects.") return LoadedBundle( manifest=manifest, members=tuple(name for name in MEMBER_ORDER if name in members), native_model=model, predictor=adapter.predictor(model), - schema=cast(dict[str, JsonValue] | None, schema), + schema=schema, structure=cast(dict[str, JsonValue] | None, structure), - samples=cast(list[dict[str, JsonValue]] | None, samples), + samples=samples, ) diff --git a/src/mlbundle/results.py b/src/mlbundle/results.py index 49c55ec..47e773f 100644 --- a/src/mlbundle/results.py +++ b/src/mlbundle/results.py @@ -16,6 +16,8 @@ class MetadataView: manifest: Manifest members: tuple[MemberName, ...] + schema: dict[str, JsonValue] | None = None + samples: list[dict[str, JsonValue]] | None = None @dataclass(frozen=True) diff --git a/src/mlbundle/samples.py b/src/mlbundle/samples.py new file mode 100644 index 0000000..312ea56 --- /dev/null +++ b/src/mlbundle/samples.py @@ -0,0 +1,71 @@ +"""Explicit feature-row selection and bounded pandas/NumPy JSON conversion.""" + +from __future__ import annotations + +from datetime import date, datetime +from types import ModuleType +from typing import TYPE_CHECKING + +from .canonical import canonical_dumps +from .errors import InvalidJSONError, InvalidSamplesError, LimitExceededError +from .limits import JSON_DEPTH + +if TYPE_CHECKING: + from pandas import DataFrame + + +def _value(value: object, pandas: ModuleType, depth: int = 0) -> object: + if depth >= JSON_DEPTH: + raise LimitExceededError("Sample nesting exceeds the depth limit.") + if isinstance(value, dict): + return {key: _value(item, pandas, depth + 1) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_value(item, pandas, depth + 1) for item in value] + if value is None or value is pandas.NA or value is pandas.NaT: + return None + if isinstance(value, (datetime, date)): + return value.isoformat() + if ( + type(value).__module__.split(".")[0] == "numpy" + and getattr(getattr(value, "dtype", None), "kind", None) == "M" + ): + import numpy as np # noqa: PLC0415 + + if isinstance(value, np.datetime64): + return ( + None + if np.isnat(value) + else str(np.datetime_as_string(value, unit="auto")) + ) + raise InvalidSamplesError("Unsupported sample value type.") + if ( + isinstance(value, (str, bool, int, float)) + or type(value).__module__.split(".")[0] == "numpy" + ): + return None if bool(pandas.isna(value)) else value + raise InvalidSamplesError("Unsupported sample value type.") + + +def sample_bytes(frame: DataFrame, count: int, cap: int) -> bytes: + import pandas as pd # noqa: PLC0415 + + if type(count) is not int or count <= 0 or count > len(frame): + raise InvalidSamplesError("sample_rows must be positive and within the frame.") + content = bytearray(b"[") + try: + for values in frame.iloc[:count].itertuples(index=False, name=None): + row = dict(zip(frame.columns, values, strict=True)) + encoded = canonical_dumps( + _value(row, pd), + max_bytes=cap - len(content) - 1, + max_depth=JSON_DEPTH - 1, + ) + if len(content) > 1: + content.extend(b",") + content.extend(encoded) + if len(content) + 1 > cap: + raise LimitExceededError("Samples exceed the byte limit.") + except (InvalidJSONError, TypeError, ValueError): + raise InvalidSamplesError("Unsupported sample value type.") from None + content.extend(b"]") + return bytes(content) diff --git a/src/mlbundle/schema.py b/src/mlbundle/schema.py new file mode 100644 index 0000000..e1769d5 --- /dev/null +++ b/src/mlbundle/schema.py @@ -0,0 +1,151 @@ +"""Schema/report and sample validation shared by export, inspect and load.""" + +from typing import Annotated, Literal, cast + +from pydantic import Field, JsonValue, ValidationError + +from ._base import ContractModel +from ._schema_fields import OneHotField, SchemaField, SeriesField +from .canonical import canonical_dumps, canonical_loads +from .claims import Label, ModelClaims +from .contract import MemberName, ModelKind +from .errors import InvalidSamplesError, InvalidSchemaError +from .limits import MAX_CLASSES, MAX_FEATURES, MAX_OUTPUTS, ArchiveLimits +from .manifest import Manifest + +ONEHOT_SEPARATOR = "__" + + +class ClassifierReport(ContractModel): + kind: Literal["classifier"] + label: Literal["Predicted class"] + mappedTo: Literal["classifier"] + output: Annotated[int, Field(ge=0, lt=MAX_OUTPUTS)] + labels: Annotated[list[Label], Field(min_length=1, max_length=MAX_CLASSES)] + showClassProbabilities: Literal[True] + + +class RegressorReport(ContractModel): + kind: Literal["regressor"] + label: Literal["Predicted value"] + mappedTo: Literal["regressor"] + output: Annotated[int, Field(ge=0, lt=MAX_OUTPUTS)] + + +Report = Annotated[ClassifierReport | RegressorReport, Field(discriminator="kind")] + + +class SchemaDeclaration(ContractModel): + schema_version: Literal["1"] + fields: Annotated[list[SchemaField], Field(min_length=1, max_length=MAX_FEATURES)] + reports: Annotated[list[Report], Field(min_length=1, max_length=MAX_OUTPUTS)] + + +def reports_for(claims: ModelClaims) -> list[dict[str, JsonValue]]: + classifier = claims.kind == ModelKind.CLASSIFIER + reports: list[dict[str, JsonValue]] = [] + for output in range(claims.n_outputs): + report: dict[str, JsonValue] = { + "kind": claims.kind.value, + "label": "Predicted class" if classifier else "Predicted value", + "mappedTo": claims.kind.value, + "output": output, + } + if classifier: + report.update( + labels=list((claims.class_labels or [])[output]), + showClassProbabilities=True, + ) + reports.append(report) + return reports + + +def _field_names(field: SchemaField) -> list[str]: + if isinstance(field, OneHotField): + names = [] + for option in field.options: + if option.mappedTo.split(ONEHOT_SEPARATOR, 1) != [ + field.label, + option.value, + ]: + raise ValueError + if option.label != option.value: + raise ValueError + names.append(option.mappedTo) + return names + if field.label != field.mappedTo: + raise ValueError + if isinstance(field, SeriesField) and any( + sub.mappedTo != field.mappedTo for sub in (field.field1, field.field2) + ): + raise ValueError + return [field.mappedTo] + + +def validate_schema( + value: JsonValue, names: list[str], claims: ModelClaims, *, max_bytes: int +) -> dict[str, JsonValue]: + """Check declarations against resolved claims, without inferring historical truth.""" + try: + declaration = SchemaDeclaration.model_validate(value) + positions = {name: index for index, name in enumerate(names)} + mappings = [_field_names(field) for field in declaration.fields] + flattened = [name for group in mappings for name in group] + if len(flattened) != len(names) or set(flattened) != set(names): + raise ValueError + if any(group != sorted(group, key=positions.__getitem__) for group in mappings): + raise ValueError + first_positions = [positions[group[0]] for group in mappings] + if first_positions != sorted(first_positions): + raise ValueError + schema = cast(dict[str, JsonValue], value) + if canonical_dumps(schema["reports"], max_bytes=max_bytes) != canonical_dumps( + reports_for(claims), max_bytes=max_bytes + ): + raise ValueError + except (ValidationError, ValueError, KeyError): + raise InvalidSchemaError("Invalid schema fields or reports.") from None + return cast(dict[str, JsonValue], value) + + +def validate_samples(value: JsonValue, names: list[str]) -> list[dict[str, JsonValue]]: + expected = set(names) + if not isinstance(value, list) or any( + not isinstance(row, dict) or set(row) != expected for row in value + ): + raise InvalidSamplesError("Samples must contain exactly the declared features.") + return cast(list[dict[str, JsonValue]], value) + + +def read_declarations( + manifest: Manifest, members: dict[MemberName, bytes], limits: ArchiveLimits +) -> tuple[dict[str, JsonValue] | None, list[dict[str, JsonValue]] | None]: + schema = samples = None + if ( + MemberName.SAMPLES in members + and manifest.target.name in manifest.features.names + ): + raise InvalidSamplesError("Samples cannot contain the declared target.") + if MemberName.SCHEMA in members: + if manifest.target.name in manifest.features.names: + raise InvalidSchemaError( + "Schema features cannot contain the declared target." + ) + schema = validate_schema( + canonical_loads( + members[MemberName.SCHEMA], + max_bytes=limits.member_bytes(MemberName.SCHEMA), + ), + manifest.features.names, + manifest.verified_claims(), + max_bytes=limits.member_bytes(MemberName.SCHEMA), + ) + if MemberName.SAMPLES in members: + samples = validate_samples( + canonical_loads( + members[MemberName.SAMPLES], + max_bytes=limits.member_bytes(MemberName.SAMPLES), + ), + manifest.features.names, + ) + return schema, samples diff --git a/src/mlbundle/training.py b/src/mlbundle/training.py new file mode 100644 index 0000000..acc7b7a --- /dev/null +++ b/src/mlbundle/training.py @@ -0,0 +1,104 @@ +"""Local-only DataFrame inference; optional libraries enter only on export.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from importlib import import_module +from typing import TYPE_CHECKING, cast + +from .canonical import canonical_dumps, canonical_value +from .claims import ModelClaims +from .contract import MemberName +from .errors import ( + InvalidSamplesError, + InvalidSchemaError, + MissingOptionalDependencyError, + MLBundleError, +) +from .limits import ArchiveLimits +from .samples import sample_bytes +from .schema import ONEHOT_SEPARATOR, reports_for, validate_schema + +if TYPE_CHECKING: + from pandas import DataFrame + + +@dataclass(frozen=True) +class TrainingDeclaration: + names: list[str] + dtypes: list[str] + schema: bytes + samples: bytes | None + + +def _feature_frame( + data: DataFrame, + claims: ModelClaims, + feature_names: Sequence[str] | None, + target_name: str | None, +) -> DataFrame: + if data.empty: + raise InvalidSchemaError("training_data must be a nonempty pandas DataFrame.") + columns = list(data.columns) + if any(type(name) is not str or not 1 <= len(name) <= 1024 for name in columns): + raise InvalidSchemaError("Training columns must be bounded nonempty strings.") + if len(set(columns)) != len(columns): + raise InvalidSchemaError("Training column names must be unique.") + if target_name is not None and target_name not in columns: + raise InvalidSchemaError("target_name must identify a training column.") + names = [name for name in columns if name != target_name] + if len(names) != claims.n_features: + raise InvalidSchemaError("Training column count disagrees with the model.") + if claims.feature_names is not None and names != claims.feature_names: + raise InvalidSchemaError("Training names and order disagree with the model.") + if feature_names is not None and list(feature_names) != names: + raise InvalidSchemaError("feature_names must equal training names in order.") + return data.loc[:, names] + + +def prepare_training( + data: object | None, + claims: ModelClaims, + *, + feature_names: Sequence[str] | None, + target_name: str | None, + sample_rows: int | None, + limits: ArchiveLimits, +) -> TrainingDeclaration | None: + if data is None: + if sample_rows is not None: + raise InvalidSamplesError("sample_rows requires training_data.") + return None + try: + pd = import_module("pandas") + mlschema = import_module("mlschema") + except ImportError: + raise MissingOptionalDependencyError("schema") from None + if not isinstance(data, pd.DataFrame): + raise InvalidSchemaError("training_data must be a nonempty pandas DataFrame.") + frame = _feature_frame(cast("DataFrame", data), claims, feature_names, target_name) + names = list(frame.columns) + cap = limits.member_bytes(MemberName.SCHEMA) + try: + fields = mlschema.infer_schema(frame, onehot_separator=ONEHOT_SEPARATOR) + schema = canonical_value( + {"schema_version": "1", "fields": fields, "reports": reports_for(claims)}, + max_bytes=cap, + ) + except MLBundleError: + raise + except Exception: + raise InvalidSchemaError("Local mlschema inference failed.") from None + validate_schema(schema, names, claims, max_bytes=cap) + samples = ( + None + if sample_rows is None + else sample_bytes(frame, sample_rows, limits.member_bytes(MemberName.SAMPLES)) + ) + return TrainingDeclaration( + names=names, + dtypes=[str(dtype) for dtype in frame.dtypes], + schema=canonical_dumps(schema, max_bytes=cap), + samples=samples, + ) diff --git a/tests/test_errors.py b/tests/test_errors.py index 84167a2..886f185 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -13,6 +13,8 @@ ("UnsupportedFormatVersionError", ()), ("InvalidManifestError", ()), ("InvalidModelError", ("Invalid native model.",)), + ("InvalidSchemaError", ("Invalid schema.",)), + ("InvalidSamplesError", ("Invalid samples.",)), ("InvalidJSONError", ()), ("IntegrityMismatchError", ("model.ubj",)), ("ClaimMismatchError", ("n_features",)), diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 2e7c425..31a0b55 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -18,16 +18,36 @@ def test_inspect_and_optional_json(manifest_data): + from mlbundle.canonical import canonical_dumps + manifest_data["references"]["schema"] = "schema.json" + schema = { + "schema_version": "1", + "fields": [ + {"kind": "text", "label": name, "required": True, "mappedTo": name} + for name in ("a", "b") + ], + "reports": [ + { + "kind": "classifier", + "label": "Predicted class", + "mappedTo": "classifier", + "output": 0, + "labels": [1, "positive"], + "showClassProbabilities": True, + } + ], + } bundle = write_container( manifest_data, - {MemberName.SKOPS: b"model", MemberName.SCHEMA: b'{"fields":[],"reports":[]}'}, + {MemberName.SKOPS: b"model", MemberName.SCHEMA: canonical_dumps(schema)}, ) view = inspect(bundle) assert isinstance(view, MetadataView) assert view.members == (MemberName.MANIFEST, MemberName.SKOPS, MemberName.SCHEMA) assert view.manifest.integrity[MemberName.SKOPS].size == 5 assert not hasattr(view, "native_model") + assert view.schema == schema and view.samples is None def test_inspect_no_model_imports(manifest_data, tmp_path): @@ -38,7 +58,7 @@ def test_inspect_no_model_imports(manifest_data, tmp_path): from mlbundle import inspect view = inspect(sys.argv[1]) assert view.manifest.model.library == "sklearn" -for name in ("sklearn", "skops", "xgboost", "onnx", "onnxruntime", "numpy"): +for name in ("sklearn", "skops", "xgboost", "onnx", "onnxruntime", "numpy", "pandas", "mlschema"): assert not any(key == name or key.startswith(name + ".") for key in sys.modules), name print("inspect imported no model libraries") """ diff --git a/tests/test_loading.py b/tests/test_loading.py index 3da17d1..2e87597 100644 --- a/tests/test_loading.py +++ b/tests/test_loading.py @@ -226,12 +226,6 @@ def test_invalid_or_non_estimator_payload(payload, error, data, tmp_path): "member,value,error", [ (MemberName.STRUCTURE, None, ClaimMismatchError), - (MemberName.SCHEMA, [], InvalidModelError), - (MemberName.SAMPLES, [1], InvalidModelError), - (MemberName.SAMPLES, [{}], InvalidModelError), - (MemberName.SAMPLES, [{"feature_1": 0, "target": 1}], InvalidModelError), - (MemberName.SAMPLES, [{"feature_1": 0, "other": 1}], InvalidModelError), - (MemberName.SCHEMA, {"fields": [], "reports": []}, None), (MemberName.SAMPLES, [{"feature_1": 0}], None), ], ) diff --git a/tests/test_package.py b/tests/test_package.py index 7f4bd03..e3a32c4 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -4,6 +4,8 @@ import sys from importlib.metadata import version +import pytest + import mlbundle @@ -17,7 +19,7 @@ def test_base_import_does_not_touch_model_libraries(): import sys class BlockOptional: def find_spec(self, fullname, path=None, target=None): - if fullname.split('.')[0] in {'sklearn', 'xgboost', 'onnx', 'onnxruntime', 'skops', 'numpy', 'pandas', 'scipy'}: + if fullname.split('.')[0] in {'sklearn', 'xgboost', 'onnx', 'onnxruntime', 'skops', 'numpy', 'pandas', 'scipy', 'mlschema'}: raise AssertionError(fullname) sys.meta_path.insert(0, BlockOptional()) import mlbundle @@ -27,3 +29,45 @@ def find_spec(self, fullname, path=None, target=None): [sys.executable, "-c", script], capture_output=True, text=True, check=False ) assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("library", ["sklearn", "xgboost", "onnx"]) +def test_reader_works_without_schema_dependencies(library, tmp_path): + import pandas as pd + from sklearn.tree import DecisionTreeRegressor + from xgboost import XGBRegressor + + from onnx_helpers import regressor + + frame = pd.DataFrame({"a": [0, 1], "b": [1.0, 0.0]}) + if library == "onnx": + model = regressor() + elif library == "sklearn": + model = DecisionTreeRegressor().fit(frame, [0, 1]) + else: + model = XGBRegressor(n_estimators=1, n_jobs=1).fit(frame, [0, 1]) + path = tmp_path / "schema.mlbundle" + mlbundle.export(model, path, training_data=frame, sample_rows=1) + script = """ +import sys +class WithoutSchema: + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'pandas', 'mlschema'}: + raise ModuleNotFoundError(name=fullname) +sys.meta_path.insert(0, WithoutSchema()) +import mlbundle +assert not {'pandas', 'mlschema'} & sys.modules.keys() +view = mlbundle.inspect(sys.argv[1]) +assert view.schema and view.samples == [{'a': 0, 'b': 1.0}] +loaded = mlbundle.load(sys.argv[1]) +assert loaded.schema == view.schema and loaded.samples == view.samples +assert len(loaded.predict([[0, 1]])) == 1 +assert not {'pandas', 'mlschema'} & sys.modules.keys() +""" + result = subprocess.run( + [sys.executable, "-I", "-c", script, str(path)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 0000000..6933ed7 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,299 @@ +"""Explicit sample rows, privacy, conversion, limits and lying bundles.""" + +import numpy as np +import pandas as pd +import pytest +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import InvalidSamplesError, MemberName +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +@pytest.fixture +def data(): + return pd.DataFrame( + { + "number": [np.float32(12345.25), np.nan, np.inf], + "count": pd.Series([np.int64(987654321), pd.NA, 3], dtype="Int64"), + "text": ["ROW_SENTINEL_ALPHA", "ROW_SENTINEL_BETA", None], + "category": pd.Categorical( + ["SCHEMA_CATEGORY_ALPHA", "SCHEMA_CATEGORY_BETA", None], + categories=[ + "SCHEMA_CATEGORY_ALPHA", + "SCHEMA_CATEGORY_BETA", + "SCHEMA_UNUSED_GAMMA", + ], + ), + "active": pd.Series([np.bool_(True), False, pd.NA], dtype="boolean"), + "date": [ + pd.Timestamp("2031-09-17T12:34:56"), + pd.NaT, + pd.Timestamp("2031-09-18"), + ], + "target": ["TARGET_SENTINEL"] * 3, + }, + index=pd.Index([0, 1, 2], name="INDEX_SENTINEL"), + ) + + +@pytest.fixture +def model(data): + # Only category reaches the estimator, keeping row sentinels out of native storage. + from sklearn.preprocessing import OneHotEncoder + + return Pipeline( + [ + ( + "select", + ColumnTransformer( + [ + ( + "category", + OneHotEncoder(handle_unknown="ignore", dtype="float64"), + ["category"], + ) + ] + ), + ), + ("tree", DecisionTreeClassifier()), + ] + ).fit(data.drop(columns="target"), [0, 1, 0]) + + +def test_privacy_without_samples(data, model, tmp_path): + path = tmp_path / "private.mlbundle" + view = mlbundle.export(model, path, training_data=data, target_name="target") + _, members = read_container(path) + assert MemberName.SAMPLES not in members and view.samples is None + contents = b"\n".join(members.values()) + for private in [ + b"ROW_SENTINEL_ALPHA", + b"ROW_SENTINEL_BETA", + b"TARGET_SENTINEL", + b"INDEX_SENTINEL", + b"2031-09-17", + b"12345.25", + b"987654321", + ]: + assert private not in contents + # Category values are explicitly allowed schema information, including unused categories. + schema_bytes = members[MemberName.SCHEMA] + assert ( + b"SCHEMA_CATEGORY_ALPHA" in schema_bytes + and b"SCHEMA_CATEGORY_BETA" in schema_bytes + ) + number = view.schema["fields"][0] + assert b"SCHEMA_UNUSED_GAMMA" in schema_bytes + assert set(number) == {"kind", "label", "mappedTo", "required", "step"} + assert number["required"] is False + + +def test_exact_sample_selection_and_conversion(data, model, tmp_path): + path = tmp_path / "samples.mlbundle" + view = mlbundle.export( + model, path, training_data=data, target_name="target", sample_rows=2 + ) + expected = [ + { + "number": 12345.25, + "count": 987654321, + "text": "ROW_SENTINEL_ALPHA", + "category": "SCHEMA_CATEGORY_ALPHA", + "active": True, + "date": "2031-09-17T12:34:56", + }, + { + "number": None, + "count": None, + "text": "ROW_SENTINEL_BETA", + "category": "SCHEMA_CATEGORY_BETA", + "active": False, + "date": None, + }, + ] + assert ( + view.samples + == mlbundle.inspect(path).samples + == mlbundle.load(path).samples + == expected + ) + _, members = read_container(path) + assert members[MemberName.SAMPLES] == canonical_dumps(expected) + assert set(view.members) == { + MemberName.MANIFEST, + MemberName.SKOPS, + MemberName.SCHEMA, + MemberName.SAMPLES, + } + # Include the final row only when the explicitly selected count is three. + full = mlbundle.export( + model, + tmp_path / "full.mlbundle", + training_data=data, + target_name="target", + sample_rows=3, + ) + assert full.samples[-1]["number"] == {"$float": "+inf"} + assert full.samples[-1]["active"] is None + assert all(set(row) == set(data.columns) - {"target"} for row in full.samples) + + +def test_schema_structure_samples_coexist(tmp_path): + frame = pd.DataFrame({"a": [0, 1], "target": [False, True]}) + model = DecisionTreeClassifier().fit(frame[["a"]], frame.target) + view = mlbundle.export( + model, + tmp_path / "all.mlbundle", + training_data=frame, + target_name="target", + sample_rows=1, + ) + assert len(view.members) == 5 + assert view.samples == [{"a": 0}] + assert mlbundle.load(tmp_path / "all.mlbundle").structure is not None + + +@pytest.mark.parametrize("count", [0, -1, True, 1.0, "1", 4]) +def test_invalid_sample_count(count, data, model, tmp_path): + path = tmp_path / "never.mlbundle" + with pytest.raises(InvalidSamplesError, match="positive"): + mlbundle.export( + model, path, training_data=data, target_name="target", sample_rows=count + ) + assert not path.exists() + + +def test_samples_require_frame_and_respect_byte_limit(data, model, tmp_path): + path = tmp_path / "never.mlbundle" + with pytest.raises(InvalidSamplesError, match="requires training_data"): + mlbundle.export(model, path, sample_rows=1) + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export( + model, + path, + training_data=data, + target_name="target", + sample_rows=2, + limits=mlbundle.ArchiveLimits(max_samples_bytes=20), + ) + assert not path.exists() + + +@pytest.mark.parametrize("value", [b"bytes", complex(1, 2), pd.Timedelta(days=1)]) +def test_unsupported_sample_values(value, data, model, tmp_path): + data["text"] = pd.Series([value] * 3, dtype=object) + with pytest.raises(InvalidSamplesError): + mlbundle.export( + model, + tmp_path / "never.mlbundle", + training_data=data, + target_name="target", + sample_rows=1, + ) + + +def test_nested_dates_scalars_and_sample_depth(tmp_path): + from datetime import UTC, date, datetime + + from mlbundle.samples import sample_bytes + + row = { + "pair": ( + np.int32(1), + { + "when": np.datetime64("2026-10-01"), + "stamp": datetime(2026, 10, 1, tzinfo=UTC), + "date": date(2026, 10, 1), + }, + ) + } + frame = pd.DataFrame([row]) + assert canonical_loads(sample_bytes(frame, 1, 4096)) == [ + { + "pair": [ + 1, + { + "when": "2026-10-01", + "stamp": "2026-10-01T00:00:00+00:00", + "date": "2026-10-01", + }, + ] + } + ] + short = pd.DataFrame({"a": ["á", "é"]}) + encoded = sample_bytes(short, 2, 4096) + assert sample_bytes(short, 2, len(encoded)) == encoded + with pytest.raises(mlbundle.LimitExceededError): + sample_bytes(short, 2, len(encoded) - 1) + value = 1 + for _ in range(32): + value = [value] + with pytest.raises(mlbundle.LimitExceededError, match="nesting"): + sample_bytes(pd.DataFrame({"a": [value]}), 1, 4096) + + +@pytest.mark.parametrize( + "change", + [ + lambda rows: rows[0].update(extra="value"), + lambda rows: rows[0].pop("text"), + lambda rows: rows[0].update(target="TARGET_SENTINEL"), + lambda rows: rows.__setitem__(0, []), + ], +) +def test_lying_samples(change, data, model, tmp_path): + path = tmp_path / "samples.mlbundle" + mlbundle.export( + model, path, training_data=data, target_name="target", sample_rows=1 + ) + manifest, members = read_container(path) + samples = canonical_loads(members[MemberName.SAMPLES]) + change(samples) + members[MemberName.SAMPLES] = canonical_dumps(samples) + del members[MemberName.MANIFEST] + bundle = write_container(manifest, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSamplesError): + reader(bundle) + + +def test_manifest_target_cannot_be_a_sample_feature(data, model, tmp_path): + path = tmp_path / "samples.mlbundle" + mlbundle.export( + model, path, training_data=data, target_name="target", sample_rows=1 + ) + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["target"]["name"] = "text" + del members[MemberName.MANIFEST] + bundle = write_container(parts, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSamplesError, match="target"): + reader(bundle) + + +@pytest.mark.parametrize( + "fraction,unit", + [ + ("123456789012", "ps"), + ("123456789012345", "fs"), + ("123456789012345678", "as"), + ], +) +def test_numpy_timestamp_precision(fraction, unit, data, model, tmp_path): + expected = "1970-01-01T00:00:00." + fraction + data["text"] = pd.Series( + [np.datetime64(expected, unit), np.datetime64("NaT"), None], dtype=object + ) + path = tmp_path / "precise.mlbundle" + view = mlbundle.export( + model, path, training_data=data, target_name="target", sample_rows=2 + ) + assert view.samples[0]["text"] == expected + assert view.samples[1]["text"] is None + assert mlbundle.load(path).samples == view.samples diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..4d0d760 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,289 @@ +"""Local schema inference, adapter alignment and hostile declarations together.""" + +import pandas as pd +import pytest +from mlschema import infer_schema +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor +from xgboost import XGBClassifier, XGBRegressor + +import mlbundle +from mlbundle import InvalidSchemaError, MemberName +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.container import read_container +from mlbundle.writing import write_container +from onnx_helpers import classifier, regressor + + +@pytest.fixture +def frame(): + return pd.DataFrame({"a": [0, 1, 0, 1], "b": [1.0, 0.0, 0.9, 0.1]}) + + +@pytest.mark.parametrize("library", ["sklearn", "xgboost", "onnx"]) +@pytest.mark.parametrize("kind", ["classifier", "regressor"]) +@pytest.mark.parametrize("native_names", [False, True]) +def test_adapter_schema_roundtrip(library, kind, native_names, frame, tmp_path): + y = [0, 1, 0, 1] + if library == "onnx": + model = ( + classifier(("cold", "warm")) + if kind == "classifier" + else regressor(columns=native_names) + ) + else: + cls = { + ("sklearn", "classifier"): DecisionTreeClassifier, + ("sklearn", "regressor"): DecisionTreeRegressor, + ("xgboost", "classifier"): XGBClassifier, + ("xgboost", "regressor"): XGBRegressor, + }[library, kind] + kwargs = {"n_estimators": 2, "n_jobs": 1} if library == "xgboost" else {} + model = cls(**kwargs).fit(frame if native_names else frame.to_numpy(), y) + path = tmp_path / "schema.mlbundle" + original = frame.copy(deep=True) + options = ( + {"class_labels": [["cold", "warm"]]} + if (library, kind) == ("xgboost", "classifier") + else {} + ) + view = mlbundle.export( + model, path, training_data=frame, feature_names=["a", "b"], **options + ) + loaded = mlbundle.load(path) + assert view.schema == loaded.schema == mlbundle.inspect(path).schema + assert view.schema["schema_version"] == "1" + assert view.schema["fields"] == infer_schema(frame, onehot_separator="__") + assert view.samples is None and loaded.samples is None + assert MemberName.SCHEMA in view.members and MemberName.SAMPLES not in view.members + features = view.manifest.features + assert features.names == ["a", "b"] and features.dtypes == ["int64", "float64"] + named = native_names and (library != "onnx" or kind == "regressor") + assert features.origin == ("model" if named else "dataframe") + assert features.model_dtypes == loaded.manifest.features.model_dtypes + report = view.schema["reports"][0] + assert report["kind"] == report["mappedTo"] == kind and report["output"] == 0 + if kind == "classifier": + assert report["labels"] == loaded.class_labels[0] + assert report["showClassProbabilities"] is True + else: + assert set(report) == {"kind", "label", "mappedTo", "output"} + pd.testing.assert_frame_equal(frame, original) + + +def test_mixed_schema_and_series(tmp_path): + frame = pd.DataFrame( + { + "numeric": [1.0, 2.0], + "category": pd.Categorical(["low", "high"]), + "text": ["private_a", "private_b"], + "active": [True, False], + "date": pd.date_range("2026-01-01", periods=2), + "pair": [(1.0, 2.0), (2.0, 3.0)], + } + ) + model = Pipeline( + [ + ( + "select", + ColumnTransformer([("n", "passthrough", ["numeric", "active"])]), + ), + ("tree", DecisionTreeClassifier()), + ] + ).fit(frame, [False, True]) + view = mlbundle.export(model, tmp_path / "mixed.mlbundle", training_data=frame) + assert view.schema["fields"] == infer_schema(frame, onehot_separator="__") + assert [field["kind"] for field in view.schema["fields"]] == [ + "number", + "category", + "text", + "boolean", + "date", + "series", + ] + assert view.schema["reports"][0]["labels"] == [False, True] + assert view.manifest.features.dtypes == [str(dtype) for dtype in frame.dtypes] + + +def test_onehot_nonadjacent_columns_and_multioutput(tmp_path): + frame = pd.DataFrame({"color__red": [1, 0], "age": [20, 30], "color__blue": [0, 1]}) + model = DecisionTreeClassifier().fit(frame, [[False, 0], [True, 1]]) + view = mlbundle.export(model, tmp_path / "onehot.mlbundle", training_data=frame) + assert view.schema["fields"] == infer_schema(frame, onehot_separator="__") + field = view.schema["fields"][0] + assert field["kind"] == "onehot-category" and field["label"] == "color" + assert [option["mappedTo"] for option in field["options"]] == [ + "color__red", + "color__blue", + ] + assert [report["output"] for report in view.schema["reports"]] == [0, 1] + assert [ + report["labels"] for report in view.schema["reports"] + ] == view.manifest.target.class_labels + reg = DecisionTreeRegressor().fit(frame, [[0.0, 1.0], [1.0, 0.0]]) + assert ( + len( + mlbundle.export(reg, tmp_path / "reg.mlbundle", training_data=frame).schema[ + "reports" + ] + ) + == 2 + ) + + +@pytest.mark.parametrize( + "change,options,match", + [ + (lambda df: [], {}, "DataFrame"), + (lambda df: df.iloc[:0], {}, "nonempty"), + (lambda df: df.iloc[:, :0], {}, "nonempty"), + (lambda df: df[["a"]], {}, "count"), + (lambda df: df.assign(extra=1), {}, "count"), + (lambda df: df[["b", "a"]], {}, "order"), + (lambda df: df.rename(columns={"a": "wrong"}), {}, "names"), + (lambda df: df.set_axis(["a", "a"], axis=1), {}, "unique"), + (lambda df: df.set_axis([0, "b"], axis=1), {}, "strings"), + (lambda df: df.set_axis(["", "b"], axis=1), {}, "strings"), + (lambda df: df.set_axis(["x" * 1025, "b"], axis=1), {}, "bounded"), + (lambda df: df, {"target_name": "absent"}, "target_name"), + (lambda df: df, {"feature_names": ["b", "a"]}, "feature_names"), + ], +) +def test_training_consistency_errors(change, options, match, frame, tmp_path): + model = DecisionTreeClassifier().fit(frame, [0, 1, 0, 1]) + path = tmp_path / "never.mlbundle" + with pytest.raises(InvalidSchemaError, match=match): + mlbundle.export(model, path, training_data=change(frame), **options) + assert not path.exists() + + +@pytest.mark.parametrize("library", ["xgboost", "onnx"]) +def test_other_native_name_order_errors(library, frame, tmp_path): + model = ( + XGBRegressor(n_estimators=1, n_jobs=1).fit(frame, [0, 1, 0, 1]) + if library == "xgboost" + else regressor(columns=True) + ) + with pytest.raises(InvalidSchemaError, match="order"): + mlbundle.export( + model, tmp_path / "never.mlbundle", training_data=frame[["b", "a"]] + ) + + +def test_frame_origin_target_exclusion_and_missing_extra(frame, tmp_path, monkeypatch): + from mlbundle import training + + model = DecisionTreeClassifier().fit(frame.to_numpy(), [0, 1, 0, 1]) + path = tmp_path / "frame.mlbundle" + data = frame.assign(target=["secret"] * 4) + view = mlbundle.export(model, path, training_data=data, target_name="target") + assert view.manifest.features.origin == "dataframe" + assert [field["mappedTo"] for field in view.schema["fields"]] == ["a", "b"] + assert view.manifest.target.name == "target" + declared = mlbundle.export( + model, tmp_path / "declared.mlbundle", feature_names=["a", "b"] + ) + assert declared.manifest.features.origin == "declared" and declared.schema is None + + def missing(name): + raise ModuleNotFoundError(name=name) + + monkeypatch.setattr(training, "import_module", missing) + with pytest.raises( + mlbundle.MissingOptionalDependencyError, match=r"mlbundle\[schema\]" + ): + mlbundle.export(model, tmp_path / "missing.mlbundle", training_data=frame) + + +def test_inference_failure_and_schema_budget(frame, tmp_path, monkeypatch): + import mlschema + + model = DecisionTreeClassifier().fit(frame, [0, 1, 0, 1]) + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export( + model, + tmp_path / "limit.mlbundle", + training_data=frame, + limits=mlbundle.ArchiveLimits(max_schema_bytes=1), + ) + + def fail(*args, **kwargs): + raise RuntimeError("private values must not appear") + + monkeypatch.setattr(mlschema, "infer_schema", fail) + with pytest.raises(InvalidSchemaError, match="Local mlschema inference failed"): + mlbundle.export(model, tmp_path / "failure.mlbundle", training_data=frame) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "change", + [ + lambda s: s["reports"][0].update(labels=["liar", "labels"]), + lambda s: s["reports"][0].update(kind="regressor"), + lambda s: s["reports"][0].update(output=1), + lambda s: s["reports"][0].update(showClassProbabilities=1), + lambda s: s["fields"][0].update(mappedTo="renamed", label="renamed"), + lambda s: s["fields"].append(s["fields"][0].copy()), + lambda s: s["fields"].pop(), + lambda s: s["fields"].reverse(), + lambda s: s["fields"][0].update(required="true"), + lambda s: s["fields"][0].update(defaultValue="private"), + lambda s: s.update(schema_version="2"), + lambda s: s.update(extra="unknown"), + lambda s: s.update(fields="invalid"), + lambda s: s.pop("reports"), + ], +) +def test_lying_schema(change, frame, tmp_path): + path = tmp_path / "schema.mlbundle" + mlbundle.export( + DecisionTreeClassifier().fit(frame, [0, 1, 0, 1]), path, training_data=frame + ) + manifest, members = read_container(path) + schema = canonical_loads(members[MemberName.SCHEMA]) + change(schema) + members[MemberName.SCHEMA] = canonical_dumps(schema) + del members[MemberName.MANIFEST] + bundle = write_container(manifest, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSchemaError): + reader(bundle) + + +@pytest.mark.parametrize("missing", [True, False]) +def test_schema_reference_presence(missing, frame, tmp_path): + from conftest import build_zip + + path = tmp_path / "schema.mlbundle" + mlbundle.export( + DecisionTreeClassifier().fit(frame, [0, 1, 0, 1]), path, training_data=frame + ) + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + del members[MemberName.MANIFEST] + if missing: + del members[MemberName.SCHEMA] + else: + parts["references"]["schema"] = None + del parts["integrity"]["schema.json"] + with pytest.raises(mlbundle.InvalidManifestError): + write_container(parts, members) + bundle = build_zip([("manifest.json", canonical_dumps(parts)), *members.items()]) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(mlbundle.InvalidContainerError): + reader(bundle) + + +def test_readme_training_example(tmp_path, monkeypatch): + import re + from pathlib import Path + + readme = Path(__file__).resolve().parents[1] / "README.md" + blocks = re.findall( + r"```python\n(.*?)\n```", readme.read_text(encoding="utf-8"), re.DOTALL + ) + example = next(block for block in blocks if "training_data=training" in block) + monkeypatch.chdir(tmp_path) + exec(compile(example, str(readme), "exec"), {}) diff --git a/tests/test_schema_fields.py b/tests/test_schema_fields.py new file mode 100644 index 0000000..934da1e --- /dev/null +++ b/tests/test_schema_fields.py @@ -0,0 +1,131 @@ +"""Closed field vocabulary: native inference shapes and malicious alterations.""" + +import copy + +import pandas as pd +import pytest +from mlschema import infer_schema +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import InvalidSchemaError, MemberName +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +@pytest.fixture +def bundle(tmp_path): + from sklearn.compose import ColumnTransformer + from sklearn.pipeline import Pipeline + + frame = pd.DataFrame( + { + "color__red": [1, 0], + "age": [20, 30], + "color__blue": [0, 1], + "category": pd.Categorical(["low", "high"]), + "pair": [(1.0, 2.0), (2.0, 3.0)], + } + ) + model = Pipeline( + [ + ("select", ColumnTransformer([("age", "passthrough", ["age"])])), + ("tree", DecisionTreeClassifier()), + ] + ).fit(frame, [0, 1]) + path = tmp_path / "fields.mlbundle" + view = mlbundle.export(model, path, training_data=frame, target_name=None) + assert view.schema["fields"] == infer_schema(frame, onehot_separator="__") + return path + + +@pytest.mark.parametrize( + "change", + [ + lambda s: s["fields"][0]["options"][0].update(mappedTo="age"), + lambda s: s["fields"][0]["options"][0].update(label="wrong"), + lambda s: s["fields"][0]["options"].reverse(), + lambda s: s["fields"][0]["options"].pop(), + lambda s: s["fields"][0].update(label="color__red"), + lambda s: s["fields"][1].update(label="wrong"), + lambda s: s["fields"][2].update(options=["low", "low"]), + lambda s: s["fields"][3]["field1"].update(mappedTo="age"), + lambda s: s["fields"][3].update(field1=copy.deepcopy(s["fields"][3])), + lambda s: s["fields"][1].update(kind="custom"), + lambda s: s["fields"][1].update(step=-1), + lambda s: s.update(fields=[], reports=[]), + ], +) +def test_invalid_field_contracts(change, bundle): + manifest, members = read_container(bundle) + schema = canonical_loads(members[MemberName.SCHEMA]) + change(schema) + members[MemberName.SCHEMA] = canonical_dumps(schema) + del members[MemberName.MANIFEST] + data = write_container(manifest, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSchemaError): + reader(data) + + +@pytest.mark.parametrize("schema", [None, [], 1, {"fields": [], "reports": []}]) +def test_malformed_schema_envelope(schema, bundle): + manifest, members = read_container(bundle) + members[MemberName.SCHEMA] = canonical_dumps(schema) + del members[MemberName.MANIFEST] + data = write_container(manifest, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSchemaError): + reader(data) + + +def test_matching_manifest_and_report_lie_still_fails_native_claims(bundle): + manifest, members = read_container(bundle) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["target"]["class_labels"] = [["changed", "labels"]] + schema = canonical_loads(members[MemberName.SCHEMA]) + schema["reports"][0]["labels"] = ["changed", "labels"] + members[MemberName.SCHEMA] = canonical_dumps(schema) + del members[MemberName.MANIFEST] + data = write_container(parts, members) + assert mlbundle.inspect(data).schema["reports"][0]["labels"] == [ + "changed", + "labels", + ] + with pytest.raises(mlbundle.ClaimMismatchError, match="class_labels"): + mlbundle.load(data) + + +def test_declared_target_cannot_be_a_schema_feature(bundle): + manifest, members = read_container(bundle) + parts = manifest.model_dump(mode="json", by_alias=True) + parts["target"]["name"] = "age" + del members[MemberName.MANIFEST] + data = write_container(parts, members) + for reader in (mlbundle.inspect, mlbundle.load): + with pytest.raises(InvalidSchemaError, match="target"): + reader(data) + + +def test_report_comparison_uses_schema_budget(tmp_path): + import random + import string + + from mlbundle.limits import MANIFEST_BYTES + + rng = random.Random(7) + labels = [ + f"{index:07d}" + "".join(rng.choices(string.ascii_letters, k=1000)) + for index in range(8) + ] + frame = pd.DataFrame({"a": range(8)}) + targets = [[label] * 1024 for label in labels] + path = tmp_path / "large-reports.mlbundle" + view = mlbundle.export( + DecisionTreeClassifier().fit(frame, targets), path, training_data=frame + ) + cap = mlbundle.ArchiveLimits().max_schema_bytes + assert len(canonical_dumps(view.schema["reports"], max_bytes=cap)) > MANIFEST_BYTES + assert len(view.manifest.to_bytes()) < MANIFEST_BYTES + assert mlbundle.inspect(path).schema == mlbundle.load(path).schema == view.schema diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 7a17e56..0bf16de 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -129,7 +129,7 @@ def test_multioutput(classifier, data, tmp_path): path = tmp_path / "multi.mlbundle" mlbundle.export(model, path, feature_names=list("abcd")) loaded = mlbundle.load(path) - assert loaded.manifest.model.n_outputs == 2 and loaded.feature_origin == "dataframe" + assert loaded.manifest.model.n_outputs == 2 and loaded.feature_origin == "declared" if classifier: assert loaded.class_labels == [[0, 1, 2], [0, 1]] assert all( diff --git a/tests/test_xgboost.py b/tests/test_xgboost.py index 8a5fd9d..cf0f964 100644 --- a/tests/test_xgboost.py +++ b/tests/test_xgboost.py @@ -56,7 +56,7 @@ def test_roundtrip(kind, frame, names, xgb_data, tmp_path): assert np.array_equal(loaded.native_model.predict(inputs), model.predict(inputs)) assert loaded.class_labels == (None if kind == "regression" else labels or [[0, 1]]) assert loaded.feature_origin == ( - "model" if frame else "dataframe" if names else "generated" + "model" if frame else "declared" if names else "generated" ) if frame: assert loaded.manifest.features.model_dtypes == ["float"] * 4 diff --git a/uv.lock b/uv.lock index bdab07b..5b4c7a4 100644 --- a/uv.lock +++ b/uv.lock @@ -209,6 +209,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "mlschema", marker = "python_full_version < '3.15'" }, { name = "onnx" }, { name = "onnxruntime" }, { name = "scikit-learn" }, @@ -219,6 +220,9 @@ onnx = [ { name = "onnx" }, { name = "onnxruntime" }, ] +schema = [ + { name = "mlschema", marker = "python_full_version < '3.15'" }, +] sklearn = [ { name = "scikit-learn" }, { name = "skops" }, @@ -241,6 +245,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'all'", specifier = "==0.2.1" }, + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'schema'", specifier = "==0.2.1" }, { name = "onnx", marker = "extra == 'all'", specifier = ">=1.23.0" }, { name = "onnx", marker = "extra == 'onnx'", specifier = ">=1.23.0" }, { name = "onnxruntime", marker = "extra == 'all'", specifier = ">=1.30.0" }, @@ -254,7 +260,7 @@ requires-dist = [ { name = "xgboost", marker = "extra == 'all'", specifier = ">=3.4.1" }, { name = "xgboost", marker = "extra == 'xgboost'", specifier = ">=3.4.1" }, ] -provides-extras = ["sklearn", "xgboost", "onnx", "all"] +provides-extras = ["sklearn", "xgboost", "onnx", "schema", "all"] [package.metadata.requires-dev] dev = [ @@ -267,6 +273,19 @@ dev = [ { name = "ruff", specifier = ">=0.15.15" }, ] +[[package]] +name = "mlschema" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pandas" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/97/b3295985c4250525069b86bf0eee31c211b3d35cebee5b995f64a35dcb58/mlschema-0.2.1.tar.gz", hash = "sha256:59f819d677fa662f9c620938a6bef7fd093a11dad5872e507e5035da723cdb9e", size = 56763, upload-time = "2026-06-18T11:21:12.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/4a/c4b8f535f3ea8cb60af7ab65f30c8dd2a7363768465a63fcccee72cc265f/mlschema-0.2.1-py3-none-any.whl", hash = "sha256:26ae50b7861d7bd907c5117498d384d003d8e2925dcaa14335add3d0bd7101ba", size = 36852, upload-time = "2026-06-18T11:21:11.292Z" }, +] + [[package]] name = "narwhals" version = "2.27.1" From bf9be7a2543a33e92255a6dcf1e2a14dd13a0569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 02:57:46 +0200 Subject: [PATCH 07/15] fix: bind reader state and bound native work (#8) --- README.md | 7 +- SECURITY.md | 12 +- docs/format-adapters.md | 35 +++- docs/format-state.md | 168 ++++++++++++++++ docs/format.md | 5 +- src/mlbundle/__init__.py | 2 + src/mlbundle/_canonical_work.py | 51 +++++ src/mlbundle/_onnx/prediction.py | 6 +- src/mlbundle/_prediction_input.py | 63 ++++++ src/mlbundle/_sklearn/__init__.py | 9 +- src/mlbundle/_sklearn/archive.py | 32 +++ src/mlbundle/_sklearn/arrays.py | 150 ++++++++++++++ src/mlbundle/_sklearn/consistency.py | 127 ++++++++++++ src/mlbundle/_sklearn/fitted.py | 113 +++++++++++ src/mlbundle/_sklearn/labels.py | 52 +++++ src/mlbundle/_sklearn/nodes.py | 54 ++++++ src/mlbundle/_sklearn/parameters.py | 28 ++- src/mlbundle/_sklearn/payload.py | 166 +++++++--------- src/mlbundle/_sklearn/prediction.py | 35 +++- src/mlbundle/_sklearn/prediction_state.py | 61 ++++++ src/mlbundle/_sklearn/references.py | 110 +++++++++++ src/mlbundle/_sklearn/state.py | 102 ++++++++++ src/mlbundle/_sklearn/state_fields.json | 226 ++++++++++++++++++++++ src/mlbundle/_sklearn/structure.py | 64 +++--- src/mlbundle/_xgboost/__init__.py | 34 +++- src/mlbundle/_xgboost/arrays.py | 47 +++++ src/mlbundle/_xgboost/categories.py | 109 +++++++++++ src/mlbundle/_xgboost/native.py | 118 +++++------ src/mlbundle/_xgboost/prediction.py | 21 +- src/mlbundle/_xgboost/state.py | 147 ++++++++++++++ src/mlbundle/_xgboost/structure.py | 8 +- src/mlbundle/_xgboost/ubjson.py | 4 + src/mlbundle/canonical.py | 2 + src/mlbundle/claims.py | 5 + src/mlbundle/errors.py | 4 + tests/conftest.py | 1 + tests/sklearn_helpers.py | 37 ++++ tests/test_canonical.py | 20 ++ tests/test_errors.py | 1 + tests/test_manifest.py | 2 + tests/test_prediction_errors.py | 84 ++++++++ tests/test_sklearn.py | 36 ---- tests/test_sklearn_families.py | 146 ++++++++++++++ tests/test_sklearn_fitted_state.py | 131 +++++++++++++ tests/test_sklearn_inference_state.py | 154 +++++++++++++++ tests/test_sklearn_parameters.py | 56 ++++++ tests/test_skops_payload.py | 10 +- tests/test_skops_resources.py | 180 +++++++++++++++++ tests/test_skops_state.py | 79 ++++++++ tests/test_writing.py | 10 +- tests/test_xgboost_inference_state.py | 198 +++++++++++++++++++ 51 files changed, 3050 insertions(+), 272 deletions(-) create mode 100644 docs/format-state.md create mode 100644 src/mlbundle/_canonical_work.py create mode 100644 src/mlbundle/_prediction_input.py create mode 100644 src/mlbundle/_sklearn/archive.py create mode 100644 src/mlbundle/_sklearn/arrays.py create mode 100644 src/mlbundle/_sklearn/consistency.py create mode 100644 src/mlbundle/_sklearn/fitted.py create mode 100644 src/mlbundle/_sklearn/labels.py create mode 100644 src/mlbundle/_sklearn/nodes.py create mode 100644 src/mlbundle/_sklearn/prediction_state.py create mode 100644 src/mlbundle/_sklearn/references.py create mode 100644 src/mlbundle/_sklearn/state.py create mode 100644 src/mlbundle/_sklearn/state_fields.json create mode 100644 src/mlbundle/_xgboost/arrays.py create mode 100644 src/mlbundle/_xgboost/categories.py create mode 100644 src/mlbundle/_xgboost/state.py create mode 100644 tests/sklearn_helpers.py create mode 100644 tests/test_prediction_errors.py create mode 100644 tests/test_sklearn_families.py create mode 100644 tests/test_sklearn_fitted_state.py create mode 100644 tests/test_sklearn_inference_state.py create mode 100644 tests/test_sklearn_parameters.py create mode 100644 tests/test_skops_resources.py create mode 100644 tests/test_skops_state.py create mode 100644 tests/test_xgboost_inference_state.py diff --git a/README.md b/README.md index 7c909e8..efe755f 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ mlbundle is a Python library for packaging a trained model, a rich manifest, locally inferred schema and structure in one compressed `.mlbundle` file. The same package writes bundles on the training machine and reads them with validation in a runtime. -**Status: pre-alpha, Phase 5.** The [version 1 format contract](docs/format.md), +**Status: pre-alpha, Phase 6.** The [version 1 format contract](docs/format.md), strict manifest models, canonical JSON, integrity helpers, environment capture, adapter/result contracts, secure container reading/writing and public `inspect` and public sklearn/XGBoost/ONNX `export`/`load` are implemented and tested. Local schema inference, training dtypes and explicit samples are implemented. +Reader hardening covers complete fitted state, unknown state rejection, bounded +shared references/sparse storage and bounded prediction errors. The CLI is still missing. Nothing has been published to PyPI. @@ -81,7 +83,8 @@ LinearRegression, Ridge and scaler/classifier pipelines are covered by round-tri tests. Multi-output labels and probabilities retain output-major order. Pipeline structure is null because v1 does not map transformed features. Custom types, user functions, HistGradientBoosting and XGBoost steps inside sklearn pipelines -are rejected. +are rejected. [Prediction-state rules](docs/format-state.md) also bind opaque +pipelines and reject extra attributes/files before native loading. For local schema inference, install `mlbundle[sklearn,schema]` on Python 3.14. Use the training frame with string column names in model input order: diff --git a/SECURITY.md b/SECURITY.md index 87d9d7e..ea4b0a2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -72,7 +72,11 @@ native construction, and checks state-array shapes against those dimensions. It also bounds the inner skops ZIP and validates every schema-referenced numeric array header, including renamed files, before skops reads it. Zero-itemsize arrays are rejected; object arrays never use pickle loading. Malformed native -payloads are wrapped in bounded `InvalidModelError` messages. These checks do not +payloads are wrapped in bounded `InvalidModelError` messages. Closed state catalogs and native construction comparisons reject extra or ignored +state and unreferenced files. Complete fitted-state commitments additionally bind +opaque pipelines and inference state outside tree/linear visualization. Shared-node +audit visits and materialized buffers are bounded before skops. Sparse NPZ headers +are checked against actual storage before NumPy allocation. These checks do not make the native parser a sandbox. The installed skops load path builds nodes, audits them and only then constructs @@ -98,7 +102,8 @@ code. Export rejects custom objectives, other wrappers, raw boosters, unsupporte objectives/boosters and multi-target models. A native copy holds the canonical mlbundle attribute binding class labels, wrapper identity and prediction settings; the caller's model is never mutated. Load requires and recomputes that binding, -recoverable config fields, names/types and the complete tree dumps/importances. +recoverable config fields, names/types, closed booster attributes and complete +native tree/routing/category/dropout state alongside dumps/importances. Label agreement proves consistency with the native file, not the historical truth of a user-supplied label mapping. @@ -113,7 +118,8 @@ XGBoost's native parser and native inference remain a dependency boundary: mlbundle cannot guarantee their memory safety or bound their internal memory/CPU use. Native tree dump generation also allocates inside XGBoost before mlbundle can inspect its returned byte size. Server consumers must isolate loading and -prediction and enforce process resource limits. See the exact support and +prediction and enforce process resource limits. All predictors reject invalid inputs and wrap remaining native failures +without echoing data cells. See the exact support and resource boundaries in [docs/format.md](docs/format.md). ## Implemented ONNX dependency boundary diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 566ce5b..31fdfc0 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -9,7 +9,9 @@ Structure is never embedded in the manifest. It is a JSON object with required `feature_importances` is null or an ordered numeric array of length `n_features`. Numbers use the canonical encoding, including non-finite tags. Arrays and tree lists preserve native order; no pruning, rounding, flattening of output/class -dimensions or display-oriented simplification is permitted. +dimensions or display-oriented simplification is permitted. Sklearn structures +add required `state_sha256` and `prediction_state` fields; XGBoost adds required +`native_model`. See [prediction state](format-state.md) for their exact rules. | `family` | Additional required fields | | --- | --- | @@ -20,10 +22,11 @@ dimensions or display-oriented simplification is permitted. | `feature_importances` | No additional fields; the importance array must be non-null. | A scikit-learn tree object has exactly `node_count` (positive integer), +`max_depth` (nonnegative integer, verified from reachability), `n_features` (positive integer), `n_outputs` (positive integer), `n_classes` (native per-output class-count array), and these native `tree_` arrays: `children_left`, `children_right`, `feature`, `threshold`, `value`, `impurity`, -`n_node_samples`, `weighted_n_node_samples`. Except `value`, each array's length +`n_node_samples`, `weighted_n_node_samples`, `missing_go_to_left`. Except `value`, each array's length is `node_count`; `value` retains its native `[node, output, class/value]` shape. Preserve leaf and undefined-feature sentinel values, and sklearn's native value normalization for the installed version. Do not reinterpret `value` as counts. @@ -39,7 +42,8 @@ families require a deliberate future contract change. Sklearn `Pipeline` returns null structure in version 1. Its final estimator operates on transformed features; version 1 has no input transformation mapping, so no final-step tree or coefficient member is emitted. Claims, names and -predictions still describe the complete pipeline. Standalone DecisionTree, +predictions still describe the complete pipeline; `model.state_sha256` also +binds every fitted step and transform. Standalone DecisionTree, RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, LinearRegression and Ridge retain their native structures as specified above. HistGradientBoosting is currently rejected: its `TreePredictor` storage is not on mlbundle's trust @@ -93,6 +97,8 @@ dtypes. Tree constructor dimensions/class counts and state-array shapes are bounded before native construction. Loaded tree dimensions, child/feature indices, reachability and depth are checked before native feature-importance traversal or prediction, and input/output/class counts must agree with owning estimators. +Closed state, shared-reference work and sparse-array storage rules are specified +in [prediction state](format-state.md) and apply identically to export and load. Ensemble feature mappings must address the parent input width. Every loaded root object is checked again for classifier/regressor identity, fitted state and probability support. Multilabel classifiers with independent positive-class columns and @@ -127,17 +133,19 @@ exactly `{ "estimator_class": CLASS, "class_labels": LABELS_OR_NULL, (a finite binary64 value or the canonical NaN tag), `importance_type` (one of `gain`, `weight`, `cover`, `total_gain`, `total_cover`; omitted native option normalizes to `gain`), and `enable_categorical` (boolean). These settings survive -loading and are compared with the wrapper. Other booster attributes, including -`best_iteration`, survive unchanged; prediction respects native best iteration. +loading and are compared with the wrapper. The only other allowed booster keys +are `best_iteration`, `best_score`, `scikit_learn`; they are bounded and included +in verified hyperparameters. Prediction respects the verified best iteration. -`hyperparameters` has exactly these four keys: +`hyperparameters` has exactly these five keys: ```json { "learner_model_param": {"base_score":"[5E-1]","boost_from_average":"1","num_class":"0","num_feature":"4","num_target":"1"}, "objective": {"name":"binary:logistic","reg_loss_param":{"scale_pos_weight":"1"}}, "gradient_booster": {"name":"gbtree","gbtree_model_param":{"num_parallel_tree":"1","num_trees":"3"}}, - "prediction": {"missing":{"$float":"nan"},"importance_type":"gain","enable_categorical":true} + "prediction": {"missing":{"$float":"nan"},"importance_type":"gain","enable_categorical":true}, + "attributes": {} } ``` @@ -145,6 +153,7 @@ The first three records are a projection of the parsed native `save_config()`: retain every field in `learner_model_param`, `objective`, and `gradient_booster.gbtree_model_param`, plus the booster name, with native value types/spellings unchanged. `prediction` is the bound wrapper settings record. +`attributes` contains the allowed native attributes other than `mlbundle`. Neither `get_params()` nor the whole config survives native model I/O. Training settings such as learning rate, max depth, seed, threads and metrics are therefore not claimed or reconstructed. Export compares this projection and the complete @@ -153,7 +162,8 @@ serialization round trip. Unsupported unrecoverable claims fail publication. Booster feature names and feature types become verified claims; absent values are null. Structure is exactly the `xgboost` family above, with the wrapper's -native float32-normalized importance vector for the bound importance type. +native float32-normalized importance vector for the bound importance type and +the complete `native_model` record, including routing and dropout state. Prediction values and structure scalars promote float32 exactly to binary64. Claim/structure comparisons use exact canonical bytes; probability row-sum validation allows `rtol=1e-6`, `atol=1e-7` for float32 arithmetic. @@ -176,7 +186,14 @@ dependency. Regressors return `list[float]` for a single output or row-major multiple outputs, aligned with class labels. Validate probability dimensions, finite probabilities, range and row sums in the adapter. Classifier prediction uses `predict_proba`; regressor prediction uses `predict`. Unsupported probability -or regression contracts fail rather than synthesizing outputs. +or regression contracts fail rather than synthesizing outputs. Inputs must have +rank two and the native feature width. Numeric inputs reject strings, complex +values and infinities; sklearn/XGBoost permit NaN for native missing-value handling. +A sklearn Pipeline with a fitted OneHotEncoder/OrdinalEncoder also admits strings; +categorical XGBoost DataFrame columns retain native recoding. ONNX requires finite +numeric cells as specified separately. Input and remaining native failures become +bounded `MLBundleError` subclasses without cell contents. Shape errors additionally +retain `ValueError` compatibility through `PredictionInputError`. `MetadataView` carries `manifest`, a tuple of `MemberName` values including the manifest, and optional `schema` and `samples` JSON values; it has no model. diff --git a/docs/format-state.md b/docs/format-state.md new file mode 100644 index 0000000..93d2f37 --- /dev/null +++ b/docs/format-state.md @@ -0,0 +1,168 @@ +# Version 1 prediction state and bounded native work + +Normative section of [the format contract](format.md). Phase 6 completes the +unreleased version 1 contract; it does not increment the format version. Earlier +unreleased bundles lacking the new state records fail full claim comparison. + +## Sklearn state ownership + +Skops' type audit is necessary but does not define which attributes belong to an +estimator. mlbundle additionally owns a closed class/attribute catalog in +`_sklearn/state_fields.json`, audited against scikit-learn 1.9.1. It covers public +estimator constructor parameters, inherited and fitted attributes, shared fit +helpers and the admitted loss/link internals. It is package data, never supplied +by a bundle and never used to import or construct a class. Unknown object types, +attributes, nested estimator attributes, node keys, loaders and unreferenced files +are rejected. The supplemental skops trusted list remains Tree only. A catalog +entry does not extend skops' trust or guarantee that every configuration exports. + +Object state must be a dictionary with one string key type per attribute; skops +may otherwise truncate attributes silently. Stateless admitted objects may use +the native null state. Tree constructor state has exactly `node_count`, +`max_depth`, `nodes`, `values`. Constructor dimensions, buffer shapes and loaded +tree reachability/indices are checked before native traversal. Tree capacity must +equal node count; maximum depth is recomputed, and maximum class width is derived +from the per-output class counts. Missing directions are zero or one. +Estimator classes must be the exact installed native class, rather than a subclass +impersonating its module and name. This rule also applies before export. + +Every admitted state field participates in a complete state commitment, including +fields omitted from visualization and optional training-only state. Deserialization +also compares the payload commitment with a fresh skops serialization of the +loaded object. Ignored declarations or state that changes during construction +are invalid models. Export applies the same rules before publication. + +### Commitment encoding + +`state_sha256` is a lowercase SHA-256 digest. A sklearn structure object requires +this field. Sklearn models with null structure, including Pipeline, instead put +the digest in `model.state_sha256`; other models use null there. This optional +manifest field defaults to null, and is part of the verified claim projection. +Missing commitments on an opaque sklearn model fail full load comparison. + +The commitment is a Merkle digest of the bounded skops schema and its buffers. +For each dictionary, resolve CachedNode to its definition, omit `__id__`, replace +each child with its digest, then hash `b"dict:" + canonical_dumps(record)`. +Lists hash `b"list:" + canonical_dumps(child_digest_list)`; scalars hash +`b"scalar:" + canonical_dumps(value)`. A `file` child uses +`SHA256(b"file:" + canonical_dumps(buffer_digest))` instead of a filename. +Shared definitions and files are memoized; identifiers and ZIP timestamps do not +affect the result. Metadata, loader/class identity and array dimensions do. + +A numeric buffer hashes canonical `[dtype.str, shape]` followed by contiguous +row-major element bytes. Floating NaNs normalize to NumPy's NaN before hashing; +finite bits, signed zero, dtype and shape are preserved. Structured arrays hash +the canonical dictionary of named-field numeric digests; unnamed padding is +excluded because native Tree storage leaves it uninitialized. Sparse buffers +hash the canonical dictionary of inner member names and their numeric digests. +These records bind state; they do not authenticate authorship or training history. + +### Readable inference composition + +Every sklearn structure also requires `prediction_state`, an object with exactly +the applicable fields below. Missing fields are omitted rather than invented. +Native arrays keep their shape and canonical numeric representation. + +| Native state | Record | +| --- | --- | +| `estimators_` | `estimator_labels`: row-major constituent `classes_` arrays, or null for a constituent without labels. | +| `estimator_weights_`, `estimators_features_` | The corresponding arrays under their native names. | +| `_x_mean`, `_x_std` | Native centering/scaling vectors under their native names. | +| `_loss` or `_base_loss` | `loss`: exactly `class`, `link` (class names), `n_classes`. | +| `init_` | `initializer`: literal native value, or exactly `class`, `parameters`, `state_sha256` for an estimator. Dummy initializers additionally expose `values`, with each present `classes_`, `class_prior_`, `constant_`, `n_classes_`, `n_outputs_`. `learning_rate` is also present. | +| `_probA`, `_probB` | `probability_calibration`: exactly `A`, `B` native arrays. | + +A custom supported initializer's commitment binds all fitted state; it does not +manufacture a visualization for that initializer. Linear coefficients are native +decision/regression coefficients. Centered models and calibrated classifiers +require the composition record to interpret the complete prediction. + +### Prediction-state coverage + +The commitment covers **all** allowed attributes and nested buffers, not a +selection inferred from attribute suffixes. The explicit checks supplement it: + +| Family | Prediction-relevant state and coverage | +| --- | --- | +| DecisionTree / ExtraTree | All eight node record fields, including missing routing, plus value arrays and native dimensions are in the tree record. Depth, capacity and class width are derived/checked. Parameters, names, owning output counts and labels are claims. | +| RandomForest / ExtraTrees | Constituent trees and order are structure; parent/child output and class counts agree. Parent counts, labels and parameters are claims; remaining state is committed. | +| GradientBoosting | Stage arrangement/counts, constituent trees, initializer, learning rate and loss/link state are structure/composition. Stage width agrees with classes; fitted loss/link class and loss class count agree with the native parameter-derived loss. Initializer feature width, dummy priors and dummy output dimensions agree with fitted state. | +| AdaBoost / Bagging / tree meta-estimators | Trees, native order/shape, weights, feature subsets and constituent labels are structure/composition. Feature subsets address parent input widths. All other state, including output mappings, is committed. | +| LinearRegression / Ridge / Bayesian / sparse-penalty / robust / SGD / LinearSVR / LDA | Native coefficients/intercepts are structure, parameters and labels/counts are claims, all fitted state is committed. Native probability transforms remain library-owned. | +| PLS / CCA | Coefficients/intercepts plus centering/scaling are structure/composition; fitted output-shape flags and remaining state are committed. | +| GLMs | Coefficients/intercepts plus loss/link are structure/composition. Fitted loss/link class and class count must agree with the parameter-derived loss. | +| SVC / NuSVC / SVR / NuSVR | Kernel parameters, support data, native/private coefficients, sparse flag, effective gamma and calibration are committed. Linear coefficients and calibration are structure/composition when available. Public and inference dual coefficients/intercepts must agree, including sklearn's binary sign reversal. Support counts/indices, coefficient/calibration dimensions and sparse storage agree. | +| Naive Bayes / QDA / neighbors / nearest centroid / MLP / semi-supervised / opaque meta-estimators | Their complete fitted statistics, data, class/output mappings, distance/activation state and nested models are committed in the manifest when structure is null. Statistics/likelihood/prior dimensions agree with classes/features; neighbor sample counts agree with fitted arrays. MLP layer sizes, output counts/activation and label-binarizer classes agree with public parameters/labels. Existing probability/count/trust constraints still apply. | +| Pipeline | All fitted steps, transformations, final estimator, order, names, classes and parameters are committed/claimed. Structure stays null; no transformed-feature visualization is inferred. | + +Unsupported custom types, callable/type parameters, HistGradientBoosting storage, +multilabel/chain probability layouts and untrusted nested internals remain +rejections. For example, actual fitted `csr_matrix` neighbor state is outside the +unchanged trust list; merely declaring its loader as the trusted base `spmatrix` +does not make its state survive the native construction comparison. + +## Work and allocation ceilings + +Before skops' type audit or construction: at most 100,000 schema nodes, 100,000 +CachedNode references, 100,000 expanded dictionary/list visits, and nesting 32. +Expanded work is calculated once per shared definition, with capped sums; cycles, +missing references and conflicting numeric/scalar definitions are rejected. +Sklearn parameter projection separately caps visits at 100,000 and ancestry at +16, within the existing 1 MiB/depth-16 hyperparameter contract. + +Nested ZIP directories are count-checked before ZipFile creates entries: at most +100,000 outer skops entries and nine inner sparse NPZ entries. Numeric headers +have rank at most 32, positive item size, no object storage, dimensions at most +512 MiB, and exact `product(shape) * itemsize` bytes, checked against both ZIP +metadata and actual streamed bytes before NumPy loads anything. Inner sparse +archives have a closed member set, ratio at most 200 and checked format, shape, +index/pointer/block/coordinate/diagonal consistency. Sparse declared dense bytes +are at most 512 MiB. Aggregate decoded buffers and aggregate materialized buffer +references are each at most 512 MiB; repeating a file under new identities cannot +bypass this bound. All these are fixed ceilings in addition to archive limits. + +Canonical JSON preflight calculates expanded minimum size/depth of shared native +containers with memoization before normalization, rejecting over-budget expansion +and cycles. Native arrays are size-checked before `tolist`. Wire JSON has no +references and remains bounded by its stored bytes/depth. + +## XGBoost and ONNX + +XGBoost's structure additionally requires `native_model`, the complete native +gradient-booster model returned by bounded UBJSON decoding of `save_raw`. +Numeric buffers become arrays without decimal rounding. It includes every tree +field, `tree_info`, iteration pointers, parallel-tree counts, category encodings, +categorical splits/default directions and optional dropout weights. Learner base +score and objective fields remain verified hyperparameters. Tree dumps alone are +insufficient to describe this prediction state. + +Booster attribute keys are closed: `mlbundle`, `best_iteration`, `best_score`, +`scikit_learn`. The last three are preserved in `hyperparameters.attributes`; +the binding is independently checked. Best iteration is a decimal integer with +at most six digits, in `[0, native_round_count)`. Best score is finite and at most +256 characters; sklearn identity has only the native classifier/regressor key. +Unknown wrapper attributes and unknown/missing native record fields fail. Native +wrapper objective and supplied feature types must agree with native state. The +wrapper booster is null or a supported tree alias (`gbtree`, or `dart`, which +XGBoost 3.4 normalizes to gbtree). Wrapper declarations cannot reinterpret the +native predictor. Native +iteration increments equal `num_parallel_tree * max(1,num_class)`. Objective +and learner class counts agree; dropout weights have native tree count, are +finite and nonnegative. Category indices are at most 1,000,000; split segments +are checked and aggregate declared categorical bitsets are capped at 512 MiB. +Category dictionary segments and sorted-index permutations agree with stored +values; string offsets must address the actual stored byte vector. +UBJSON has nesting 32, at most 100,000 object collection items and 1,000,000 +non-buffer values; numeric buffer counts require actual stored bytes. + +ONNX has no additional mutable fitted state. The CPU session is created from the +exact original bytes; signatures, metadata, kind, labels and graph/environment +claims are recomputed from those bytes. No initializer override, custom function, +domain or model-provided session configuration is admitted. Its descriptor scan +has no shared-reference mechanism, bounds fields/depth before construction, and +tensor dimensions require actual stored values before a session. See +[the ONNX contract](format-onnx.md) for execution limits. + +These checks are not a native-code sandbox. Runtime-computed allocations, +dependency memory safety and prediction row/CPU/process limits remain the host's +responsibility. No claim of exhaustive native-parser memory safety is made. diff --git a/docs/format.md b/docs/format.md index cc8f17a..2db16be 100644 --- a/docs/format.md +++ b/docs/format.md @@ -11,6 +11,7 @@ The normative container rules are in [Container and resource limits](format-cont The normative structure, adapter and result rules are in [Adapters and structure](format-adapters.md). The implemented ONNX mapping and validation rules are in [ONNX](format-onnx.md). The normative schema/declaration rules are in [Schema and samples](format-schema.md). +[Prediction state and bounded work](format-state.md) is also normative. These sections are part of this version 1 contract. ## Members @@ -255,7 +256,7 @@ from recomputing claims: plausible metadata alone never verifies a model. The manifest's claim projection is `ModelClaims`: estimator class, kind, hyperparameters, feature/class/output counts, model-origin feature names (null otherwise), model dtypes, original output-major class labels and ONNX graph -metadata. Classifier extraction may leave labels null only when the native model +metadata and the optional fitted-state commitment. Classifier extraction may leave labels null only when the native model carries none; the shared pipeline resolves the declaration as specified above. Library/serialization/member pairing is validated by the closed registry. Structure is compared separately using the same canonical encoding. @@ -273,6 +274,8 @@ explicit reader implementation. Tightening caller-selected resource limits does not change the format. Corrections to documentation that preserve wire behavior and implementation changes behind these contracts do not require a version bump. Phase 5 completes the unreleased v1 schema vocabulary and adds `declared` origin. +Phase 6 completes unreleased v1 prediction-state records, missing tree routing, +native XGBoost routing/dropout and closed state/work rules without a version bump. Hashes detect corruption, not deliberate tampering: an attacker can replace a model and update every record. Claim recomputation prevents a bundle from showing diff --git a/src/mlbundle/__init__.py b/src/mlbundle/__init__.py index 22ddb49..f063dcc 100644 --- a/src/mlbundle/__init__.py +++ b/src/mlbundle/__init__.py @@ -26,6 +26,7 @@ LimitExceededError, MissingOptionalDependencyError, MLBundleError, + PredictionInputError, UnsupportedFormatVersionError, UnsupportedModelError, ) @@ -68,6 +69,7 @@ "ModelKind", "OnnxInfo", "Opset", + "PredictionInputError", "SerializationFormat", "TargetInfo", "TrainingEnvironment", diff --git a/src/mlbundle/_canonical_work.py b/src/mlbundle/_canonical_work.py new file mode 100644 index 0000000..32a697e --- /dev/null +++ b/src/mlbundle/_canonical_work.py @@ -0,0 +1,51 @@ +"""Linear preflight for the expanded size/depth of native shared JSON containers.""" + +from typing import Any + +from .errors import LimitExceededError + + +def _array_cost(value: Any) -> tuple[int, int, Any]: + return value.ndim, value.size, value.flat if value.dtype.kind == "O" else () + + +def bound_work(value: Any, max_bytes: int, max_depth: int) -> None: + memo: dict[int, tuple[int, int]] = {} + active: set[int] = set() + + def measure(item): + array = ( + type(item).__module__.split(".")[0] == "numpy" + and type(item).__name__ == "ndarray" + ) + if not isinstance(item, (dict, list, tuple)) and not array: + return (len(item) + 2 if isinstance(item, str) else 1), 0 + identity = id(item) + if identity in active: + raise LimitExceededError("Cyclic JSON containers exceed the nesting limit.") + if identity in memo: + return memo[identity] + active.add(identity) + depth, size = 1, 1 + if array: + depth, size, children = _array_cost(item) + elif isinstance(item, dict): + size += sum(len(key) + 3 for key in item if isinstance(key, str)) + children = item.values() + else: + children = item + if size > max_bytes or depth > max_depth: + raise LimitExceededError("Expanded JSON size or depth exceeds the limit.") + for child in children: + child_size, child_depth = measure(child) + size += child_size + 1 + depth = max(depth, child_depth + 1) + if size > max_bytes or depth > max_depth: + raise LimitExceededError( + "Expanded JSON size or depth exceeds the limit." + ) + active.remove(identity) + memo[identity] = size, depth + return size, depth + + measure(value) diff --git a/src/mlbundle/_onnx/prediction.py b/src/mlbundle/_onnx/prediction.py index 5d6089d..177ebb2 100644 --- a/src/mlbundle/_onnx/prediction.py +++ b/src/mlbundle/_onnx/prediction.py @@ -8,13 +8,11 @@ from mlbundle.adapters import ProbabilityOutput, RegressionOutput from mlbundle.contract import ModelKind -from mlbundle.errors import InvalidModelError +from mlbundle.errors import InvalidModelError, PredictionInputError from .description import Description - -class _InputShapeError(ValueError): - pass +_InputShapeError = PredictionInputError def _array(data: Any, dtype: str) -> np.ndarray: diff --git a/src/mlbundle/_prediction_input.py b/src/mlbundle/_prediction_input.py new file mode 100644 index 0000000..9cae678 --- /dev/null +++ b/src/mlbundle/_prediction_input.py @@ -0,0 +1,63 @@ +"""Numeric input checks shared by optional sklearn and XGBoost predictors.""" + +import math +from typing import Any + +import numpy as np +from scipy.sparse import issparse + +from .errors import InvalidModelError, MLBundleError, PredictionInputError + + +def tabular( + data: Any, width: int, *, categorical: bool = False, strings: bool = False +) -> tuple[Any, int]: + try: + shape = getattr(data, "shape", None) + if shape is None: + data = np.asarray(data) + shape = data.shape + if len(shape) != 2 or shape[1] != width: + raise PredictionInputError( + "Prediction input must have the model's feature width." + ) + if issparse(data): + arrays = [data.data] + elif categorical and hasattr(data, "dtypes"): + arrays = [ + np.asarray(data.iloc[:, index]) + for index, dtype in enumerate(data.dtypes) + if str(dtype) != "category" + ] + else: + arrays = [np.asarray(data)] + for values in arrays: + _numeric(values, strings=strings) + return data, int(shape[0]) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid tabular prediction input.") from None + + +def _numeric(values: np.ndarray, *, strings: bool) -> None: + if values.dtype.kind == "O": + if any( + not (strings and isinstance(value, str)) + and ( + not isinstance( + value, (int, float, np.integer, np.floating, bool, np.bool_) + ) + or math.isinf(value) + ) + for value in values.flat + ): + raise InvalidModelError( + "Prediction input must contain numeric values without infinity." + ) + elif strings and values.dtype.kind == "U": + return + elif values.dtype.kind not in "biuf" or np.isinf(values).any(): + raise InvalidModelError( + "Prediction input must contain numeric values without infinity." + ) diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index 0dd3c5c..acdc1b5 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -149,10 +149,14 @@ def serialize(self, model: object) -> bytes: def deserialize(self, data: bytes) -> object: try: - validate_payload(data) + expected_state = validate_payload(data) _untrusted(data) model = sio.loads(data, trusted=list(TRUSTED_TYPES)) self._validate(model) + if expected_state != validate_payload(sio.dumps(model)): + raise InvalidModelError( + "Skops state does not survive native construction." + ) return model except MLBundleError: raise @@ -173,6 +177,9 @@ def extract_claims(self, model: Any) -> ModelClaims: estimator_class=type(model).__name__, kind=ModelKind.CLASSIFIER if classifier else ModelKind.REGRESSOR, hyperparameters=parameters(model), + state_sha256=validate_payload(sio.dumps(model)) + if isinstance(model, Pipeline) or extract_structure(model) is None + else None, n_features=int(model.n_features_in_), n_classes=None if labels is None diff --git a/src/mlbundle/_sklearn/archive.py b/src/mlbundle/_sklearn/archive.py new file mode 100644 index 0000000..a32de39 --- /dev/null +++ b/src/mlbundle/_sklearn/archive.py @@ -0,0 +1,32 @@ +"""Bound nested ZIP directory work before ZipFile creates entry objects.""" + +import stat +from io import BytesIO +from zipfile import ZipFile + +from mlbundle._zip_directory import CENTRAL, _directory_bounds, unpack +from mlbundle.errors import InvalidContainerError, InvalidModelError + + +def bounded_zip(data: bytes, max_files: int) -> ZipFile: + try: + offset, size, count = _directory_bounds(data) + except InvalidContainerError: + raise InvalidModelError("Invalid nested skops archive.") from None + if count > max_files: + raise InvalidModelError("Skops nested member count exceeds the limit.") + position = offset + boundary = offset + size + for _ in range(count): + record = unpack(CENTRAL, data, position) + if record[0] != b"PK\x01\x02" or stat.S_IFMT(record[15] >> 16) not in ( + 0, + stat.S_IFREG, + ): + raise InvalidModelError("Invalid skops nested ZIP directory.") + position += CENTRAL.size + sum(record[10:13]) + if position > boundary: + raise InvalidModelError("Invalid skops nested ZIP directory.") + if position != boundary: + raise InvalidModelError("Invalid skops nested ZIP member count.") + return ZipFile(BytesIO(data)) diff --git a/src/mlbundle/_sklearn/arrays.py b/src/mlbundle/_sklearn/arrays.py new file mode 100644 index 0000000..7dafba1 --- /dev/null +++ b/src/mlbundle/_sklearn/arrays.py @@ -0,0 +1,150 @@ +"""Check declared numeric and sparse storage against real bytes before allocation.""" + +from io import BytesIO +from math import prod +from zipfile import ZipFile + +import numpy as np + +from mlbundle.errors import InvalidModelError +from mlbundle.limits import ArchiveLimits + +from .archive import bounded_zip + +MAX_RANK = 32 +MAX_FILES = 100_000 +BYTE_CAP = ArchiveLimits().max_member_bytes + + +def array_header( + archive: ZipFile, name: str, size: int +) -> tuple[tuple[int, ...], np.dtype]: + with archive.open(name) as stream: + version = np.lib.format.read_magic(stream) + if version == (1, 0): + shape, _, dtype = np.lib.format.read_array_header_1_0(stream) + elif version == (2, 0): + shape, _, dtype = np.lib.format.read_array_header_2_0(stream) + else: + raise InvalidModelError("Unsupported skops numeric array header.") + if ( + dtype.hasobject + or dtype.itemsize == 0 + or len(shape) > MAX_RANK + or any(not 0 <= dimension <= BYTE_CAP for dimension in shape) + or prod(shape) * dtype.itemsize != size - stream.tell() + ): + raise InvalidModelError("Invalid skops numeric array dimensions.") + expected = size - stream.tell() + actual = 0 + while chunk := stream.read(min(64 * 1024, expected - actual + 1)): + actual += len(chunk) + if actual > expected: + raise InvalidModelError("Invalid skops numeric array stored size.") + if actual != expected: + raise InvalidModelError("Invalid skops numeric array stored size.") + return shape, dtype + + +def sparse_headers(data: bytes, max_bytes: int = BYTE_CAP) -> int: + with bounded_zip(data, 9) as archive: + entries = archive.infolist() + names = {entry.filename for entry in entries} + if ( + len(names) != len(entries) + or not {"format.npy", "shape.npy", "data.npy"} <= names + or names + - { + "format.npy", + "shape.npy", + "data.npy", + "indices.npy", + "indptr.npy", + "row.npy", + "col.npy", + "offsets.npy", + "_is_array.npy", + } + or sum(entry.file_size for entry in entries) > max_bytes + ): + raise InvalidModelError("Invalid skops sparse archive.") + headers = {} + for entry in entries: + if ( + entry.flag_bits & 1 + or entry.compress_type not in (0, 8) + or entry.file_size > BYTE_CAP + or entry.file_size > 200 * entry.compress_size + ): + raise InvalidModelError("Unsafe skops sparse member bounds.") + headers[entry.filename] = array_header( + archive, entry.filename, entry.file_size + ) + if headers["shape.npy"][0] != (2,) or headers["format.npy"][0] != (): + raise InvalidModelError("Invalid skops sparse shape or format.") + shape = np.load(BytesIO(archive.read("shape.npy")), allow_pickle=False) + if ( + shape.dtype.kind not in "iu" + or any(not 0 <= dim <= BYTE_CAP for dim in shape) + or prod(int(dim) for dim in shape) * headers["data.npy"][1].itemsize + > BYTE_CAP + ): + raise InvalidModelError("Unsafe skops sparse declared shape.") + # All buffers have already passed exact stored-byte checks. Loading them + # here cannot allocate in proportion to an unsupported header declaration. + values = { + name[:-4]: np.load(BytesIO(archive.read(name)), allow_pickle=False) + for name in names + } + _sparse_indices(values, shape) + return sum(entry.file_size for entry in entries) + + +def _sparse_indices(values, shape) -> None: + kind = values["format"].item() + rows, columns = (int(dim) for dim in shape) + if kind in (b"csr", b"csc", b"bsr"): + indices, pointers = values["indices"], values["indptr"] + blocks = len(values["data"]) + major, minor = (columns, rows) if kind == b"csc" else (rows, columns) + if kind == b"bsr": + block_shape = values["data"].shape + if len(block_shape) != 3 or min(block_shape[1:]) < 1: + raise InvalidModelError("Invalid skops sparse block shape.") + major //= block_shape[1] + minor //= block_shape[2] + if ( + pointers.dtype.kind not in "iu" + or indices.dtype.kind not in "iu" + or pointers.shape != (major + 1,) + or indices.shape != (blocks,) + or pointers[0] != 0 + or pointers[-1] != blocks + or np.any(pointers[1:] < pointers[:-1]) + or np.any(pointers < 0) + or np.any(indices < 0) + or np.any(indices >= minor) + ): + raise InvalidModelError("Invalid skops sparse index storage.") + elif kind == b"coo": + for name, width in (("row", rows), ("col", columns)): + index = values[name] + if ( + index.dtype.kind not in "iu" + or index.shape != values["data"].shape + or np.any(index < 0) + or np.any(index >= width) + ): + raise InvalidModelError("Invalid skops sparse coordinate storage.") + elif kind == b"dia": + offsets = values["offsets"] + if ( + offsets.dtype.kind not in "iu" + or offsets.ndim != 1 + or values["data"].ndim != 2 + or len(offsets) != len(values["data"]) + or len(set(offsets.tolist())) != len(offsets) + ): + raise InvalidModelError("Invalid skops sparse diagonal storage.") + else: + raise InvalidModelError("Unsupported skops sparse format.") diff --git a/src/mlbundle/_sklearn/consistency.py b/src/mlbundle/_sklearn/consistency.py new file mode 100644 index 0000000..d4d79ba --- /dev/null +++ b/src/mlbundle/_sklearn/consistency.py @@ -0,0 +1,127 @@ +"""Check alternate inference state against the estimator's displayed parameters.""" + +from typing import Any + +import numpy as np +from scipy.sparse import issparse +from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor +from sklearn.ensemble._forest import ForestClassifier, ForestRegressor +from sklearn.linear_model._glm.glm import _GeneralizedLinearRegressor +from sklearn.svm._base import BaseLibSVM + +from mlbundle.errors import InvalidModelError + + +def validate_consistency(model: Any) -> None: + if isinstance( + model, (GradientBoostingClassifier, GradientBoostingRegressor) + ) and hasattr(model, "estimators_"): + columns = ( + model.n_classes_ + if isinstance(model, GradientBoostingClassifier) and model.n_classes_ > 2 + else 1 + ) + trees = model.estimators_ + if ( + not isinstance(trees, np.ndarray) + or trees.ndim != 2 + or trees.shape != (model.n_estimators_, columns) + or model.n_trees_per_iteration_ != columns + ): + raise InvalidModelError("Invalid gradient boosting stage arrangement.") + expected = model._get_loss(None) + _loss(model._loss, expected) + _initializer(model) + if isinstance(model, (ForestClassifier, ForestRegressor)) and hasattr( + model, "estimators_" + ): + for estimator in model.estimators_: + if estimator.n_outputs_ != model.n_outputs_ or ( + isinstance(model, ForestClassifier) + and not np.array_equal( + np.atleast_1d(estimator.n_classes_), np.atleast_1d(model.n_classes_) + ) + ): + raise InvalidModelError( + "Forest and constituent output counts disagree." + ) + if isinstance(model, _GeneralizedLinearRegressor) and hasattr(model, "_base_loss"): + _loss(model._base_loss, model._get_loss()) + if isinstance(model, BaseLibSVM) and hasattr(model, "_dual_coef_"): + _svm(model) + + +def _loss(actual: Any, expected: Any) -> None: + if ( + type(actual) is not type(expected) + or type(actual.link) is not type(expected.link) + or getattr(actual, "n_classes", None) != getattr(expected, "n_classes", None) + ): + raise InvalidModelError("Fitted loss and declared parameters disagree.") + + +def _initializer(model: Any) -> None: + initializer = model.init_ + if ( + hasattr(initializer, "n_features_in_") + and initializer.n_features_in_ != model.n_features_in_ + ): + raise InvalidModelError("Gradient boosting initializer input width disagrees.") + + +def _same(left: Any, right: Any, sign: int) -> bool: + if issparse(left) or issparse(right): + return ( + issparse(left) + and issparse(right) + and left.format == right.format + and left.shape == right.shape + and np.array_equal(left.indices, right.indices) + and np.array_equal(left.indptr, right.indptr) + and np.array_equal(left.data, sign * right.data) + ) + return np.array_equal(left, sign * right) + + +def _svm(model: Any) -> None: + if ( + type(model._sparse) is not bool + or issparse(model.support_vectors_) != model._sparse + or issparse(model._dual_coef_) != model._sparse + ): + raise InvalidModelError("SVM sparse flag and fitted storage disagree.") + classes = len(model.classes_) if hasattr(model, "classes_") else None + count = len(model.support_) + supports = np.asarray(model._n_support) + rows = 1 if classes is None else classes - 1 + intercepts = 1 if classes is None else classes * (classes - 1) // 2 + if ( + supports.dtype.kind not in "iu" + or supports.shape != ((2,) if classes is None else (classes,)) + or np.any(supports < 0) + or np.any(supports > count) + or int(supports.sum()) != count * (2 if classes is None else 1) + or model._dual_coef_.shape != (rows, count) + or model._intercept_.shape != (intercepts,) + or model.support_.dtype.kind not in "iu" + or np.any(model.support_ < 0) + or np.any(model.support_ >= model.shape_fit_[0]) + or model.shape_fit_[1] != model.n_features_in_ + ): + raise InvalidModelError("Invalid SVM fitted dimensions or support indices.") + if model.kernel != "precomputed" and model.support_vectors_.shape != ( + count, + model.n_features_in_, + ): + raise InvalidModelError("Invalid SVM support-vector dimensions.") + if ( + classes is not None + and (model._probA.size or callable(getattr(model, "predict_proba", None))) + and (model._probA.shape != (intercepts,) or model._probB.shape != (intercepts,)) + ): + raise InvalidModelError("Invalid SVM probability calibration dimensions.") + sign = -1 if model._impl in ("c_svc", "nu_svc") and len(model.classes_) == 2 else 1 + if not _same(model.dual_coef_, model._dual_coef_, sign) or not np.array_equal( + model.intercept_, sign * model._intercept_ + ): + raise InvalidModelError("SVM public and inference coefficients disagree.") diff --git a/src/mlbundle/_sklearn/fitted.py b/src/mlbundle/_sklearn/fitted.py new file mode 100644 index 0000000..0088703 --- /dev/null +++ b/src/mlbundle/_sklearn/fitted.py @@ -0,0 +1,113 @@ +"""Fitted dimensions that inference uses must agree with displayed counts.""" + +from typing import Any + +import numpy as np +from sklearn.dummy import DummyRegressor +from sklearn.neural_network import MLPClassifier, MLPRegressor +from sklearn.svm._base import BaseLibSVM + +from mlbundle.errors import InvalidModelError + + +def validate_fitted(model: Any, width: int | None) -> None: + _statistics(model, width) + _dummy(model) + if width is None: + return + if hasattr(model, "_fit_X") and hasattr(model, "_y"): + rows = model._fit_X.shape[0] + if ( + model._fit_X.shape[1] != width + or len(model._y) != rows + or getattr(model, "n_samples_fit_", rows) != rows + ): + raise InvalidModelError("Neighbor fitted dimensions disagree.") + if isinstance(model, (MLPClassifier, MLPRegressor)) and hasattr(model, "coefs_"): + _mlp(model, width) + + +def _mlp(model: Any, width: int) -> None: + sizes = [width, *np.atleast_1d(model.hidden_layer_sizes).tolist()] + sizes.append(model.n_outputs_) + if ( + model.n_layers_ != len(sizes) + or len(model.coefs_) != len(sizes) - 1 + or len(model.intercepts_) != len(model.coefs_) + or any( + coef.shape != (left, right) or intercept.shape != (right,) + for coef, intercept, left, right in zip( + model.coefs_, model.intercepts_, sizes, sizes[1:], strict=False + ) + ) + ): + raise InvalidModelError("MLP fitted layer dimensions disagree.") + if isinstance(model, MLPClassifier): + classes = len(model.classes_) + if ( + classes < 2 + or model.n_outputs_ != (1 if classes <= 2 else classes) + or model.out_activation_ != ("logistic" if classes <= 2 else "softmax") + ): + raise InvalidModelError("MLP class count and output activation disagree.") + elif model.out_activation_ != ("exp" if model.loss == "poisson" else "identity"): + raise InvalidModelError("MLP regression loss and output activation disagree.") + + +def _statistics(model: Any, width: int | None) -> None: + if not hasattr(model, "classes_") or isinstance(model.classes_, list): + return + classes = len(model.classes_) + if width is not None and hasattr(model, "coef_"): + _coefficient(model, classes, width) + for name in ("theta_", "var_", "means_", "centroids_"): + if ( + width is not None + and hasattr(model, name) + and np.asarray(getattr(model, name)).shape + != ( + classes, + width, + ) + ): + raise InvalidModelError( + "Classifier fitted statistics and dimensions disagree." + ) + for name in ("class_prior_", "class_log_prior_", "class_count_"): + if hasattr(model, name) and np.asarray(getattr(model, name)).shape != ( + classes, + ): + raise InvalidModelError( + "Classifier fitted priors and class counts disagree." + ) + if hasattr(model, "feature_log_prob_"): + values = model.feature_log_prob_ + shapes = ( + [np.asarray(value).shape for value in values] + if isinstance(values, list) + else [np.asarray(values).shape] + ) + if any(len(shape) != 2 or shape[0] != classes for shape in shapes): + raise InvalidModelError("Classifier likelihoods and class counts disagree.") + if (isinstance(values, list) and len(values) != width) or ( + not isinstance(values, list) and shapes[0][1] != width + ): + raise InvalidModelError( + "Classifier likelihoods and feature counts disagree." + ) + + +def _coefficient(model: Any, classes: int, width: int) -> None: + if isinstance(model, BaseLibSVM): + return + if np.asarray(model.coef_).shape != (1 if classes <= 2 else classes, width): + raise InvalidModelError("Classifier coefficients and class counts disagree.") + + +def _dummy(model: Any) -> None: + if ( + isinstance(model, DummyRegressor) + and hasattr(model, "constant_") + and np.asarray(model.constant_).shape != (1, model.n_outputs_) + ): + raise InvalidModelError("Dummy regressor constant and output counts disagree.") diff --git a/src/mlbundle/_sklearn/labels.py b/src/mlbundle/_sklearn/labels.py new file mode 100644 index 0000000..b18b93f --- /dev/null +++ b/src/mlbundle/_sklearn/labels.py @@ -0,0 +1,52 @@ +"""One label/count check for every fitted classifier, including nested models.""" + +from typing import Any, cast + +import numpy as np +from pydantic import ConfigDict, TypeAdapter +from sklearn.neural_network import MLPClassifier + +from mlbundle.canonical import canonical_dumps, canonical_value +from mlbundle.claims import ClassLabels, validate_label_sets +from mlbundle.errors import InvalidModelError + +# The pinned pre-commit Pyright predates Annotated aliases as TypeForm arguments. +_LABELS = TypeAdapter( + cast(Any, ClassLabels), config=ConfigDict(strict=True, allow_inf_nan=False) +) + + +def validate_classes(model: Any) -> None: + classes = model.classes_ if isinstance(model.classes_, list) else [model.classes_] + if not classes or any(np.asarray(labels).ndim != 1 for labels in classes): + raise InvalidModelError("Invalid classifier label dimensions.") + counts = np.asarray([len(labels) for labels in classes]) + labels = _LABELS.validate_python(canonical_value(classes)) + validate_label_sets(labels) + binarizer = getattr(model, "_label_binarizer", None) + if binarizer is not None and canonical_dumps(model.classes_) != canonical_dumps( + binarizer.classes_ + ): + raise InvalidModelError("Classifier and label binarizer classes disagree.") + _outputs(model, len(classes)) + if hasattr(model, "n_classes_"): + native = np.asarray(model.n_classes_) + if ( + native.dtype.kind not in "iu" + or native.ndim != (0 if len(classes) == 1 else 1) + or not np.array_equal(np.atleast_1d(native), counts) + ): + raise InvalidModelError( + "Classifier labels and native class counts disagree." + ) + if hasattr(model, "tree_") and not np.array_equal(model.tree_.n_classes, counts): + raise InvalidModelError("Classifier labels and tree class counts disagree.") + + +def _outputs(model: Any, count: int) -> None: + if ( + not isinstance(model, MLPClassifier) + and hasattr(model, "n_outputs_") + and model.n_outputs_ != count + ): + raise InvalidModelError("Classifier labels and native output counts disagree.") diff --git a/src/mlbundle/_sklearn/nodes.py b/src/mlbundle/_sklearn/nodes.py new file mode 100644 index 0000000..e5ae85f --- /dev/null +++ b/src/mlbundle/_sklearn/nodes.py @@ -0,0 +1,54 @@ +"""Closed skops node layouts: no unused state, loaders or hidden child objects.""" + +from typing import Any + +from mlbundle.errors import InvalidModelError, UnsupportedModelError + +FIELDS = { + "ObjectNode": {"content"}, + "TreeNode": {"content", "__reduce__"}, + "LossNode": {"content", "__reduce__"}, + "DictNode": {"content", "key_types"}, + "ListNode": {"content"}, + "TupleNode": {"content"}, + "SetNode": {"content"}, + "JsonNode": {"content", "is_json"}, + "TypeNode": set(), + "CachedNode": set(), + "NdArrayNode": {"type", "file", "content", "shape"}, + "SparseMatrixNode": {"type", "file"}, + "RandomStateNode": {"content"}, + "RandomGeneratorNode": {"content"}, + "MaskedArrayNode": {"content"}, + "DTypeNode": {"content"}, + "SliceNode": {"content"}, + "ConstructorFromReduceNode": {"content"}, + "BytesNode": {"file"}, + "BytearrayNode": {"file"}, +} +COMMON = {"__class__", "__module__", "__loader__", "__id__"} + + +def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None: + for node in nodes: + loader = node["__loader__"] + if ( + node.get("__module__") == "sklearn.tree._tree" + and node.get("__class__") == "Tree" + and loader not in ("TreeNode", "CachedNode") + ): + raise InvalidModelError("Invalid skops native tree loader.") + if loader not in FIELDS: + name = ( + str(node.get("__module__", "")) + "." + str(node.get("__class__", "")) + ) + raise UnsupportedModelError((name,)) + allowed = ( + COMMON + | FIELDS[loader] + | ({"protocol", "_skops_version"} if node is root else set()) + ) + if set(node) - allowed: + raise InvalidModelError("Unknown skops node state.") + if loader in ("TreeNode", "LossNode") and set(node["__reduce__"]) != {"args"}: + raise InvalidModelError("Unknown skops constructor state.") diff --git a/src/mlbundle/_sklearn/parameters.py b/src/mlbundle/_sklearn/parameters.py index 41625d0..a3c4d76 100644 --- a/src/mlbundle/_sklearn/parameters.py +++ b/src/mlbundle/_sklearn/parameters.py @@ -7,10 +7,19 @@ from sklearn.base import BaseEstimator from mlbundle.canonical import canonical_value -from mlbundle.errors import UnsupportedModelError +from mlbundle.errors import LimitExceededError, UnsupportedModelError +from mlbundle.limits import HYPERPARAMETER_BYTES +MAX_VISITS = 100_000 -def _parameter(value: object, ancestors: frozenset[int]) -> JsonValue: + +def _parameter( + value: object, ancestors: frozenset[int], budget: list[int] | None = None +) -> JsonValue: + budget = [0] if budget is None else budget + budget[0] += 1 + if budget[0] > MAX_VISITS or len(ancestors) > 16: + raise LimitExceededError("Sklearn parameter work exceeds the limit.") if id(value) in ancestors: raise UnsupportedModelError(reason="Cyclic hyperparameters are unsupported.") path = ancestors | {id(value)} @@ -22,7 +31,7 @@ def _parameter(value: object, ancestors: frozenset[int]) -> JsonValue: return { "$estimator": { "class": type(value).__name__, - "params": parameters(value, path), + "params": parameters(value, path, budget), } } if isinstance(value, np.random.RandomState): @@ -30,23 +39,26 @@ def _parameter(value: object, ancestors: frozenset[int]) -> JsonValue: if isinstance(value, dict): if any(type(key) is not str for key in value): raise UnsupportedModelError(reason="Hyperparameter keys must be strings.") - return {key: _parameter(item, path) for key, item in value.items()} + return {key: _parameter(item, path, budget) for key, item in value.items()} if isinstance(value, (list, tuple)): - return [_parameter(item, path) for item in value] + return [_parameter(item, path, budget) for item in value] if callable(value): raise UnsupportedModelError( reason="Callable and type hyperparameters are unsupported." ) - return canonical_value(value) + return canonical_value(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) def parameters( - model: BaseEstimator, ancestors: frozenset[int] = frozenset() + model: BaseEstimator, + ancestors: frozenset[int] = frozenset(), + budget: list[int] | None = None, ) -> dict[str, JsonValue]: + budget = [0] if budget is None else budget return cast( dict[str, JsonValue], { - key: _parameter(value, ancestors) + key: _parameter(value, ancestors, budget) for key, value in model.get_params(deep=True).items() }, ) diff --git a/src/mlbundle/_sklearn/payload.py b/src/mlbundle/_sklearn/payload.py index e1ea591..3b02ce2 100644 --- a/src/mlbundle/_sklearn/payload.py +++ b/src/mlbundle/_sklearn/payload.py @@ -1,13 +1,12 @@ """Bound the nested skops ZIP before skops reads schema or numeric buffers.""" from io import BytesIO -from math import prod from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile import numpy as np from pydantic import JsonValue -from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.canonical import canonical_loads from mlbundle.errors import InvalidModelError from mlbundle.limits import ( MANIFEST_BYTES, @@ -17,14 +16,23 @@ ArchiveLimits, ) +from .archive import bounded_zip +from .arrays import MAX_FILES, array_header, sparse_headers +from .nodes import validate_nodes +from .references import References +from .state import state_digest, validate_state -def validate_payload(data: bytes) -> None: +BUFFER_BYTES = ArchiveLimits().max_member_bytes + + +def validate_payload(data: bytes) -> str: limits = ArchiveLimits() - with ZipFile(BytesIO(data)) as archive: + with bounded_zip(data, MAX_FILES) as archive: entries = archive.infolist() names = [entry.filename for entry in entries] if ( - len(names) != len(set(names)) + len(names) > MAX_FILES + or len(names) != len(set(names)) or "schema.json" not in names or any("/" in name or "\\" in name or ".." in name for name in names) or sum(entry.file_size for entry in entries) > limits.max_member_bytes @@ -42,14 +50,34 @@ def validate_payload(data: bytes) -> None: ): raise InvalidModelError("Unsafe nested skops member bounds.") schema = canonical_loads(archive.read(schema_entry)) + references = References(schema) + validate_nodes(references.nodes, schema) + validate_state(references.nodes, references.resolve) arrays = {name for name in names if name.endswith(".npy")} | _array_references( schema ) + sparse = { + node["file"] + for node in references.nodes + if node.get("__loader__") == "SparseMatrixNode" + } + remaining = limits.max_member_bytes - sum( + archive.getinfo(name).file_size for name in arrays if name in names + ) + sparse_sizes = {} + for name in sparse: + sparse_sizes[name] = sparse_headers(archive.read(name), remaining) + remaining -= sparse_sizes[name] + _buffer_budget(references, archive, sparse_sizes) for name in arrays: if name not in names: raise InvalidModelError("Missing skops numeric array member.") _array_header(archive, name, archive.getinfo(name).file_size) - _tree_constructors(schema, archive) + _tree_constructors(references, archive) + files = _array_references(schema) | sparse | {"schema.json"} + if set(names) != files: + raise InvalidModelError("Unreferenced skops member.") + return state_digest(schema, archive, references.resolve) def _array_references(schema: JsonValue) -> set[str]: @@ -69,97 +97,47 @@ def _array_references(schema: JsonValue) -> set[str]: return arrays -def _array_header( - archive: ZipFile, name: str, size: int -) -> tuple[tuple[int, ...], np.dtype]: - with archive.open(name) as stream: - version = np.lib.format.read_magic(stream) - if version == (1, 0): - shape, _, dtype = np.lib.format.read_array_header_1_0(stream) - elif version == (2, 0): - shape, _, dtype = np.lib.format.read_array_header_2_0(stream) - else: - raise InvalidModelError("Unsupported skops numeric array header.") +def _buffer_budget( + references: References, archive: ZipFile, sparse: dict[str, int] +) -> None: + seen: set[int] = set() + total = 0 + for node in references.nodes: if ( - dtype.hasobject - or dtype.itemsize == 0 - or any(dimension < 0 for dimension in shape) - or (prod(shape) * dtype.itemsize != size - stream.tell()) + node.get("__loader__") not in ("NdArrayNode", "SparseMatrixNode") + or "file" not in node ): - raise InvalidModelError("Invalid skops numeric array dimensions.") - return shape, dtype - - -def _tree_constructors(schema: JsonValue, archive: ZipFile) -> None: - nodes: list[dict[str, JsonValue]] = [] - memo: dict[int, dict[str, JsonValue]] = {} - variants: dict[int, list[dict[str, JsonValue]]] = {} - pending = [schema] - while pending: - value = pending.pop() - if isinstance(value, dict): - nodes.append(value) - if ( - value.get("__module__") == "sklearn.tree._tree" - and value.get("__class__") == "Tree" - and value.get("__loader__") not in ("TreeNode", "CachedNode") - ): - raise InvalidModelError("Invalid skops native tree loader.") - identity = value.get("__id__") - if ( - "__loader__" in value - and "__id__" in value - and (type(identity) is not int or identity <= 0) - ): - raise InvalidModelError("Invalid skops object identity.") - if type(identity) is int and value.get("__loader__") != "CachedNode": - if identity in memo and ( - value.get("__loader__") != memo[identity].get("__loader__") - or ( - value.get("__loader__") in ("JsonNode", "NdArrayNode") - and canonical_dumps(value) != canonical_dumps(memo[identity]) - ) - ): - raise InvalidModelError("Conflicting skops object references.") - memo[identity] = value - variants.setdefault(identity, []).append(value) - pending.extend(value.values()) - elif isinstance(value, list): - pending.extend(value) - for node in nodes: - if node.get("__loader__") == "TreeNode": - _tree_variants(node, memo, variants, archive) - - -def _tree_variants( - node: dict[str, JsonValue], - memo: dict[int, dict[str, JsonValue]], - variants: dict[int, list[dict[str, JsonValue]]], - archive: ZipFile, -) -> None: - reduced = node.get("__reduce__") - if not isinstance(reduced, dict): - raise InvalidModelError("Invalid skops tree constructor.") - for args in _variants(reduced.get("args"), variants): - for state in _variants(node.get("content"), variants): + continue + identity = node.get("__id__", id(node)) + if identity in seen: + continue + seen.add(identity) + total += sparse.get(node["file"], archive.getinfo(node["file"]).file_size) + if total > BUFFER_BYTES: + raise InvalidModelError("Skops materialized buffer bytes exceed the limit.") + + +_array_header = array_header + + +def _tree_constructors(references: References, archive: ZipFile) -> None: + seen: set[int] = set() + for node in references.nodes: + identity = node.get("__id__", id(node)) + if node.get("__loader__") == "TreeNode" and identity not in seen: + seen.add(identity) + reduced = node.get("__reduce__") + if not isinstance(reduced, dict): + raise InvalidModelError("Invalid skops tree constructor.") + args = references.resolve(reduced.get("args")) + state = references.resolve(node.get("content")) _tree_constructor( - {**node, "__reduce__": {"args": args}, "content": state}, memo, archive + {**node, "__reduce__": {"args": args}, "content": state}, + references.memo, + archive, ) -def _variants( - value: JsonValue, variants: dict[int, list[dict[str, JsonValue]]] -) -> list[dict[str, JsonValue]]: - if not isinstance(value, dict): - raise InvalidModelError("Invalid skops tree constructor schema.") - identity = value.get("__id__") - if type(identity) is int and identity in variants: - return variants[identity] - if value.get("__loader__") == "CachedNode": - raise InvalidModelError("Invalid skops tree constructor reference.") - return [value] - - def _resolve( value: JsonValue, memo: dict[int, dict[str, JsonValue]] ) -> dict[str, JsonValue]: @@ -207,9 +185,7 @@ def _tree_constructor( node: dict[str, JsonValue], memo: dict[int, dict[str, JsonValue]], archive: ZipFile ) -> None: reduced = node.get("__reduce__") - if not isinstance(reduced, dict): - raise InvalidModelError("Invalid skops tree constructor.") - arguments = reduced.get("args") + arguments = reduced.get("args") if isinstance(reduced, dict) else None if not isinstance(arguments, dict): raise InvalidModelError("Invalid skops tree constructor arguments.") args = arguments.get("content") @@ -234,6 +210,8 @@ def _tree_constructor( attributes = state.get("content") if not isinstance(attributes, dict): raise InvalidModelError("Invalid skops tree state.") + if set(attributes) != {"node_count", "max_depth", "nodes", "values"}: + raise InvalidModelError("Unknown skops tree state.") count = _integer(attributes.get("node_count"), memo) depth = _integer(attributes.get("max_depth"), memo) _, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, archive) diff --git a/src/mlbundle/_sklearn/prediction.py b/src/mlbundle/_sklearn/prediction.py index 6098489..3a326c9 100644 --- a/src/mlbundle/_sklearn/prediction.py +++ b/src/mlbundle/_sklearn/prediction.py @@ -4,30 +4,40 @@ from typing import Any, cast import numpy as np +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder +from mlbundle._prediction_input import tabular from mlbundle.adapters import ProbabilityOutput, RegressionOutput from mlbundle.claims import ModelClaims from mlbundle.contract import ModelKind -from mlbundle.errors import InvalidModelError +from mlbundle.errors import InvalidModelError, MLBundleError class Predictor: def __init__(self, model: Any, claims: ModelClaims) -> None: self.model = model self.claims = claims + self.strings = isinstance(self.model, Pipeline) and any( + isinstance(value, (OneHotEncoder, OrdinalEncoder)) + and hasattr(value, "categories_") + for value in self.model.get_params(deep=True).values() + ) def _data(self, data: object) -> tuple[object, int]: - shape = getattr(data, "shape", None) - if shape is None: - data = np.asarray(data) - shape = data.shape - if len(shape) != 2 or shape[1] != self.claims.n_features: - raise ValueError("Prediction input must have the model's feature width.") - return data, int(shape[0]) + return tabular(data, self.claims.n_features, strings=self.strings) def _call(self, data: object, *, probabilities: bool) -> Any: # Positional arrays/lists remain supported for named models; sklearn # still enforces DataFrame name/order when those names are supplied. + try: + return self._native_call(data, probabilities=probabilities) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Sklearn prediction failed.") from None + + def _native_call(self, data: object, *, probabilities: bool) -> Any: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", @@ -37,11 +47,18 @@ def _call(self, data: object, *, probabilities: bool) -> Any: warnings.filterwarnings( "ignore", message="X has feature names, but .*", category=UserWarning ) - return ( + raw = ( self.model.predict_proba(data) if probabilities else self.model.predict(data) ) + if probabilities: + return ( + [np.asarray(item, dtype=float) for item in raw] + if isinstance(raw, list) + else np.asarray(raw, dtype=float) + ) + return np.asarray(raw, dtype=float) def predict(self, data: object) -> RegressionOutput: if self.claims.kind != ModelKind.REGRESSOR: diff --git a/src/mlbundle/_sklearn/prediction_state.py b/src/mlbundle/_sklearn/prediction_state.py new file mode 100644 index 0000000..2b9cfae --- /dev/null +++ b/src/mlbundle/_sklearn/prediction_state.py @@ -0,0 +1,61 @@ +"""Readable composition facts supplement complete fitted-state commitments.""" + +from typing import Any + +import numpy as np +import skops.io as sio +from sklearn.base import BaseEstimator +from sklearn.dummy import DummyClassifier, DummyRegressor + +from .parameters import parameters +from .payload import validate_payload + + +def prediction_state(model: Any) -> dict[str, Any]: + state = {} + if hasattr(model, "estimators_"): + estimators = np.asarray(model.estimators_, dtype=object) + state["estimator_labels"] = [ + getattr(estimator, "classes_", None) for estimator in estimators.flat + ] + for attribute in ( + "estimator_weights_", + "estimators_features_", + "_x_mean", + "_x_std", + ): + if hasattr(model, attribute): + state[attribute] = getattr(model, attribute) + loss = getattr(model, "_loss", getattr(model, "_base_loss", None)) + if loss is not None: + state["loss"] = { + "class": type(loss).__name__, + "link": type(loss.link).__name__, + "n_classes": loss.n_classes, + } + if hasattr(model, "init_"): + initializer = model.init_ + if isinstance(initializer, BaseEstimator): + state["initializer"] = { + "class": type(initializer).__name__, + "parameters": parameters(initializer), + "state_sha256": validate_payload(sio.dumps(initializer)), + } + if isinstance(initializer, (DummyClassifier, DummyRegressor)): + state["initializer"]["values"] = { + name: getattr(initializer, name) + for name in ( + "classes_", + "class_prior_", + "constant_", + "n_classes_", + "n_outputs_", + ) + if hasattr(initializer, name) + } + else: + state["initializer"] = initializer + state["learning_rate"] = model.learning_rate + if hasattr(model, "_probA"): + state["probability_calibration"] = {"A": model._probA, "B": model._probB} + return state diff --git a/src/mlbundle/_sklearn/references.py b/src/mlbundle/_sklearn/references.py new file mode 100644 index 0000000..0c3fcf6 --- /dev/null +++ b/src/mlbundle/_sklearn/references.py @@ -0,0 +1,110 @@ +"""Bound skops' recursive audit work before it builds or audits shared nodes.""" + +from typing import Any + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidModelError + +MAX_NODES = 100_000 +MAX_REFERENCES = 100_000 +MAX_VISITS = 100_000 +MAX_DEPTH = 32 + + +def _without_ids(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _without_ids(child) for key, child in value.items() if key != "__id__" + } + if isinstance(value, list): + return [_without_ids(child) for child in value] + return value + + +class References: + def __init__(self, schema: Any) -> None: + self.nodes: list[dict[str, Any]] = [] + self.memo: dict[int, dict[str, Any]] = {} + pending = [schema] + self.reference_count = 0 + while pending: + value = pending.pop() + if isinstance(value, dict): + if "__loader__" in value: + self.nodes.append(value) + if len(self.nodes) > MAX_NODES: + raise InvalidModelError("Skops node count exceeds the limit.") + self._record(value) + pending.extend(reversed(list(value.values()))) + elif isinstance(value, list): + pending.extend(reversed(value)) + self._work(schema) + + def _record(self, value: dict[str, Any]) -> None: + identity = value.get("__id__") + if identity is not None and (type(identity) is not int or identity <= 0): + raise InvalidModelError("Invalid skops object identity.") + if value["__loader__"] == "CachedNode": + self.reference_count += 1 + if self.reference_count > MAX_REFERENCES: + raise InvalidModelError("Skops reference count exceeds the limit.") + elif identity is not None: + previous = self.memo.get(identity) + if previous is not None and ( + previous["__loader__"] != value["__loader__"] + or ( + value["__loader__"] in ("JsonNode", "NdArrayNode") + and canonical_dumps(_without_ids(previous)) + != canonical_dumps(_without_ids(value)) + ) + ): + raise InvalidModelError("Conflicting skops object references.") + self.memo.setdefault(identity, value) + + def resolve(self, value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise InvalidModelError("Invalid skops node reference.") + if value.get("__loader__") == "CachedNode": + identity = value.get("__id__") + if type(identity) is not int: + raise InvalidModelError("Invalid skops object identity.") + target = self.memo.get(identity) + if target is None: + raise InvalidModelError("Unknown skops object reference.") + if (target.get("__module__"), target.get("__class__")) != ( + value.get("__module__"), + value.get("__class__"), + ): + raise InvalidModelError("Conflicting skops reference type.") + return target + return value + + def _work(self, schema: Any) -> None: + memo: dict[int, tuple[int, int]] = {} + active: set[int] = set() + + def cost(value): + if not isinstance(value, (dict, list)): + return 0, 0 + value = self.resolve(value) if isinstance(value, dict) else value + identity = id(value) + if identity in active: + raise InvalidModelError("Cyclic skops object references.") + if identity in memo: + return memo[identity] + active.add(identity) + visits, depth = 1, 1 + children = value.values() if isinstance(value, dict) else value + for child in children: + child_visits, child_depth = cost(child) + visits += child_visits + depth = max(depth, child_depth + 1) + if visits > MAX_VISITS or depth > MAX_DEPTH: + raise InvalidModelError( + "Skops expanded node visits or depth exceed the limit." + ) + active.remove(identity) + memo[identity] = visits, depth + return visits, depth + + cost(schema) diff --git a/src/mlbundle/_sklearn/state.py b/src/mlbundle/_sklearn/state.py new file mode 100644 index 0000000..688e964 --- /dev/null +++ b/src/mlbundle/_sklearn/state.py @@ -0,0 +1,102 @@ +"""Closed sklearn state keys and a content digest independent of skops object IDs.""" + +import hashlib +import json +from importlib.resources import files +from io import BytesIO +from typing import Any +from zipfile import ZipFile + +import numpy as np + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidModelError, UnsupportedModelError + +FIELDS = json.loads(files(__package__).joinpath("state_fields.json").read_text()) + + +def _buffer_digest(data: bytes) -> str: + if data.startswith(b"PK"): + with ZipFile(BytesIO(data)) as archive: + record = { + name: _buffer_digest(archive.read(name)) for name in archive.namelist() + } + return hashlib.sha256(canonical_dumps(record)).hexdigest() + array = np.load(BytesIO(data), allow_pickle=False) + if array.dtype.names: + # Native Tree records have uninitialized padding; only named fields + # participate in inference and survive the native round trip. + record = {name: _numeric_digest(array[name]) for name in array.dtype.names} + return hashlib.sha256(canonical_dumps(record)).hexdigest() + return _numeric_digest(array) + + +def _numeric_digest(array: np.ndarray) -> str: + if array.dtype.kind == "f" and np.isnan(array).any(): + array = array.copy() + array[np.isnan(array)] = np.nan + digest = hashlib.sha256(canonical_dumps([array.dtype.str, list(array.shape)])) + digest.update(np.ascontiguousarray(array).tobytes()) + return digest.hexdigest() + + +def _hash(domain: bytes, value: Any) -> str: + return hashlib.sha256(domain + canonical_dumps(value)).hexdigest() + + +def validate_state(nodes: list[dict[str, Any]], resolve) -> None: + for node in nodes: + if node.get("__loader__") != "ObjectNode": + continue + name = node.get("__module__", "") + "." + node.get("__class__", "") + if name not in FIELDS: + raise UnsupportedModelError((name,)) + state = resolve(node.get("content")) + if state.get("__loader__") == "JsonNode" and state.get("content") == "null": + continue + if state.get("__loader__") != "DictNode": + raise InvalidModelError("Invalid sklearn object state.") + attrs = state.get("content") + if not isinstance(attrs, dict) or set(attrs) - set(FIELDS[name]): + raise InvalidModelError("Unknown sklearn estimator state.") + # DictNode otherwise silently truncates attributes through zip(). + keys = resolve(state.get("key_types")) + if keys.get("__loader__") != "ListNode" or len(keys["content"]) != len(attrs): + raise InvalidModelError("Invalid sklearn state key types.") + for raw_key in keys["content"]: + key = resolve(raw_key) + if key.get("__loader__") != "TypeNode" or ( + key.get("__module__"), + key.get("__class__"), + ) != ("builtins", "str"): + raise InvalidModelError("Sklearn state keys must be strings.") + + +def state_digest(schema: Any, archive, resolve) -> str: + """Merkle hash all state and buffers, without expanding shared references.""" + memo: dict[int, str] = {} + buffers: dict[str, str] = {} + + def digest(value: Any) -> str: + if isinstance(value, dict): + node = resolve(value) if "__loader__" in value else value + if id(node) in memo: + return memo[id(node)] + content = {} + for key, child in node.items(): + if key == "__id__": + continue + if key == "file": + if child not in buffers: + buffers[child] = _buffer_digest(archive.read(child)) + content[key] = _hash(b"file:", buffers[child]) + else: + content[key] = digest(child) + result = _hash(b"dict:", content) + memo[id(node)] = result + return result + if isinstance(value, list): + return _hash(b"list:", [digest(child) for child in value]) + return _hash(b"scalar:", value) + + return digest(schema) diff --git a/src/mlbundle/_sklearn/state_fields.json b/src/mlbundle/_sklearn/state_fields.json new file mode 100644 index 0000000..6a6902a --- /dev/null +++ b/src/mlbundle/_sklearn/state_fields.json @@ -0,0 +1,226 @@ +{ + "sklearn._loss.link.HalfLogitLink": [], + "sklearn._loss.link.IdentityLink": [], + "sklearn._loss.link.Interval": ["high", "high_inclusive", "low", "low_inclusive"], + "sklearn._loss.link.LogLink": [], + "sklearn._loss.link.LogitLink": [], + "sklearn._loss.link.MultinomialLogit": [], + "sklearn._loss.loss.AbsoluteError": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.ExponentialLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.HalfBinomialLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.HalfGammaLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.HalfMultinomialLoss": ["approx_hessian", "class_indexing_offsets", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp", "y_true_int", "y_true_one_hot"], + "sklearn._loss.loss.HalfPoissonLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.HalfSquaredError": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn._loss.loss.HuberLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "quantile", "xp"], + "sklearn._loss.loss.PinballLoss": ["approx_hessian", "closs", "constant_hessian", "device", "interval_y_pred", "interval_y_true", "link", "n_classes", "xp"], + "sklearn.calibration.CalibratedClassifierCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "calibrated_classifiers_", "classes_", "cv", "ensemble", "estimator", "feature_names_in_", "method", "n_features_in_", "n_jobs"], + "sklearn.cluster._affinity_propagation.AffinityPropagation": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "affinity", "affinity_matrix_", "cluster_centers_", "cluster_centers_indices_", "convergence_iter", "copy", "damping", "feature_names_in_", "labels_", "max_iter", "n_features_in_", "n_iter_", "preference", "random_state", "verbose"], + "sklearn.cluster._agglomerative.AgglomerativeClustering": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "children_", "compute_distances", "compute_full_tree", "connectivity", "distance_threshold", "distances_", "feature_names_in_", "labels_", "linkage", "memory", "metric", "n_clusters", "n_clusters_", "n_connected_components_", "n_features_in_", "n_leaves_"], + "sklearn.cluster._agglomerative.FeatureAgglomeration": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "children_", "compute_distances", "compute_full_tree", "connectivity", "distance_threshold", "distances_", "feature_names_in_", "labels_", "linkage", "memory", "metric", "n_clusters", "n_clusters_", "n_connected_components_", "n_features_in_", "n_leaves_", "pooling_func"], + "sklearn.cluster._bicluster.SpectralBiclustering": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "column_labels_", "columns_", "feature_names_in_", "init", "method", "mini_batch", "n_best", "n_clusters", "n_components", "n_features_in_", "n_init", "n_svd_vecs", "random_state", "row_labels_", "rows_", "svd_method"], + "sklearn.cluster._bicluster.SpectralCoclustering": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "column_labels_", "columns_", "feature_names_in_", "init", "mini_batch", "n_clusters", "n_features_in_", "n_init", "n_svd_vecs", "random_state", "row_labels_", "rows_", "svd_method"], + "sklearn.cluster._birch.Birch": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "_subcluster_norms", "branching_factor", "compute_labels", "dummy_leaf_", "feature_names_in_", "labels_", "n_clusters", "n_features_in_", "root_", "subcluster_centers_", "subcluster_labels_", "threshold"], + "sklearn.cluster._bisect_k_means.BisectingKMeans": ["_HTMLDocumentationLinkMixin__doc_link_template", "_X_mean", "_bisecting_tree", "_kmeans_single", "_n_features_out", "_n_init", "_n_threads", "_random_state", "_sklearn_output_config", "_sklearn_version", "_tol", "algorithm", "bisecting_strategy", "cluster_centers_", "copy_x", "feature_names_in_", "inertia_", "init", "labels_", "max_iter", "n_clusters", "n_features_in_", "n_init", "random_state", "tol", "verbose"], + "sklearn.cluster._dbscan.DBSCAN": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "algorithm", "components_", "core_sample_indices_", "eps", "feature_names_in_", "labels_", "leaf_size", "metric", "metric_params", "min_samples", "n_features_in_", "n_jobs", "p"], + "sklearn.cluster._hdbscan.hdbscan.HDBSCAN": ["_HTMLDocumentationLinkMixin__doc_link_template", "_metric_params", "_min_samples", "_raw_data", "_single_linkage_tree_", "_sklearn_version", "algorithm", "allow_single_cluster", "alpha", "centroids_", "cluster_selection_epsilon", "cluster_selection_method", "copy", "feature_names_in_", "labels_", "leaf_size", "max_cluster_size", "medoids_", "metric", "metric_params", "min_cluster_size", "min_samples", "n_features_in_", "n_jobs", "probabilities_", "store_centers"], + "sklearn.cluster._kmeans.KMeans": ["_HTMLDocumentationLinkMixin__doc_link_template", "_algorithm", "_n_features_out", "_n_init", "_n_threads", "_sklearn_output_config", "_sklearn_version", "_tol", "algorithm", "cluster_centers_", "copy_x", "feature_names_in_", "inertia_", "init", "labels_", "max_iter", "n_clusters", "n_features_in_", "n_init", "n_iter_", "random_state", "tol", "verbose"], + "sklearn.cluster._kmeans.MiniBatchKMeans": ["_HTMLDocumentationLinkMixin__doc_link_template", "_batch_size", "_counts", "_ewa_inertia", "_ewa_inertia_min", "_init_size", "_n_features_out", "_n_init", "_n_since_last_reassign", "_n_threads", "_no_improvement", "_random_state", "_sklearn_output_config", "_sklearn_version", "_tol", "batch_size", "cluster_centers_", "compute_labels", "feature_names_in_", "inertia_", "init", "init_size", "labels_", "max_iter", "max_no_improvement", "n_clusters", "n_features_in_", "n_init", "n_iter_", "n_steps_", "random_state", "reassignment_ratio", "tol", "verbose"], + "sklearn.cluster._mean_shift.MeanShift": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "bandwidth", "bin_seeding", "cluster_all", "cluster_centers_", "feature_names_in_", "labels_", "max_iter", "min_bin_freq", "n_features_in_", "n_iter_", "n_jobs", "seeds"], + "sklearn.cluster._optics.OPTICS": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "algorithm", "cluster_hierarchy_", "cluster_method", "core_distances_", "eps", "feature_names_in_", "labels_", "leaf_size", "max_eps", "memory", "metric", "metric_params", "min_cluster_size", "min_samples", "n_features_in_", "n_jobs", "ordering_", "p", "predecessor_", "predecessor_correction", "reachability_", "xi"], + "sklearn.cluster._spectral.SpectralClustering": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "affinity", "affinity_matrix_", "assign_labels", "coef0", "degree", "eigen_solver", "eigen_tol", "feature_names_in_", "gamma", "kernel_params", "labels_", "n_clusters", "n_components", "n_features_in_", "n_init", "n_jobs", "n_neighbors", "random_state", "verbose"], + "sklearn.compose._column_transformer.ColumnTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_columns", "_remainder", "_sklearn_output_config", "_sklearn_version", "_transformer_to_input_indices", "_transformers_feature_names_out", "feature_names_in_", "n_features_in_", "n_jobs", "output_indices_", "remainder", "sparse_output_", "sparse_threshold", "transformer_weights", "transformers", "transformers_", "verbose", "verbose_feature_names_out"], + "sklearn.compose._target.TransformedTargetRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "_training_dim", "check_inverse", "feature_names_in_", "func", "inverse_func", "n_features_in_", "regressor", "regressor_", "transformer", "transformer_"], + "sklearn.covariance._elliptic_envelope.EllipticEnvelope": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "contamination", "covariance_", "dist_", "feature_names_in_", "location_", "n_features_in_", "offset_", "precision_", "random_state", "raw_covariance_", "raw_location_", "raw_support_", "store_precision", "support_", "support_fraction"], + "sklearn.covariance._empirical_covariance.EmpiricalCovariance": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "covariance_", "feature_names_in_", "location_", "n_features_in_", "precision_", "store_precision"], + "sklearn.covariance._graph_lasso.GraphicalLasso": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "assume_centered", "costs_", "covariance", "covariance_", "enet_tol", "eps", "feature_names_in_", "location_", "max_iter", "mode", "n_features_in_", "n_iter_", "precision_", "store_precision", "tol", "verbose"], + "sklearn.covariance._graph_lasso.GraphicalLassoCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alphas", "assume_centered", "costs_", "covariance_", "cv", "cv_results_", "enet_tol", "eps", "feature_names_in_", "location_", "max_iter", "mode", "n_features_in_", "n_iter_", "n_jobs", "n_refinements", "precision_", "store_precision", "tol", "verbose"], + "sklearn.covariance._robust_covariance.MinCovDet": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "covariance_", "dist_", "feature_names_in_", "location_", "n_features_in_", "precision_", "random_state", "raw_covariance_", "raw_location_", "raw_support_", "store_precision", "support_", "support_fraction"], + "sklearn.covariance._shrunk_covariance.LedoitWolf": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "block_size", "covariance_", "feature_names_in_", "location_", "n_features_in_", "precision_", "shrinkage_", "store_precision"], + "sklearn.covariance._shrunk_covariance.OAS": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "covariance_", "feature_names_in_", "location_", "n_features_in_", "precision_", "shrinkage_", "store_precision"], + "sklearn.covariance._shrunk_covariance.ShrunkCovariance": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "assume_centered", "covariance_", "feature_names_in_", "location_", "n_features_in_", "precision_", "shrinkage", "store_precision"], + "sklearn.cross_decomposition._pls.CCA": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_norm_y_weights", "_predict_1d", "_sklearn_output_config", "_sklearn_version", "_x_mean", "_x_scores", "_x_std", "_y_mean", "_y_scores", "_y_std", "algorithm", "coef_", "copy", "deflation_mode", "feature_names_in_", "intercept_", "max_iter", "mode", "n_components", "n_features_in_", "n_iter_", "scale", "tol", "x_loadings_", "x_rotations_", "x_weights_", "y_loadings_", "y_rotations_", "y_weights_"], + "sklearn.cross_decomposition._pls.PLSCanonical": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_norm_y_weights", "_predict_1d", "_sklearn_output_config", "_sklearn_version", "_x_mean", "_x_scores", "_x_std", "_y_mean", "_y_scores", "_y_std", "algorithm", "coef_", "copy", "deflation_mode", "feature_names_in_", "intercept_", "max_iter", "mode", "n_components", "n_features_in_", "n_iter_", "scale", "tol", "x_loadings_", "x_rotations_", "x_weights_", "y_loadings_", "y_rotations_", "y_weights_"], + "sklearn.cross_decomposition._pls.PLSRegression": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_norm_y_weights", "_predict_1d", "_sklearn_output_config", "_sklearn_version", "_x_mean", "_x_scores", "_x_std", "_y_mean", "_y_scores", "_y_std", "algorithm", "coef_", "copy", "deflation_mode", "feature_names_in_", "intercept_", "max_iter", "mode", "n_components", "n_features_in_", "n_iter_", "scale", "tol", "x_loadings_", "x_rotations_", "x_scores_", "x_weights_", "y_loadings_", "y_rotations_", "y_scores_", "y_weights_"], + "sklearn.cross_decomposition._pls.PLSSVD": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "_x_mean", "_x_std", "_y_mean", "_y_std", "copy", "feature_names_in_", "n_components", "n_features_in_", "scale", "x_weights_", "y_weights_"], + "sklearn.decomposition._dict_learning.DictionaryLearning": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "callback", "code_init", "components_", "dict_init", "error_", "feature_names_in_", "fit_algorithm", "max_iter", "n_components", "n_features_in_", "n_iter_", "n_jobs", "positive_code", "positive_dict", "random_state", "split_sign", "tol", "transform_algorithm", "transform_alpha", "transform_max_iter", "transform_n_nonzero_coefs", "verbose"], + "sklearn.decomposition._dict_learning.MiniBatchDictionaryLearning": ["_A", "_B", "_HTMLDocumentationLinkMixin__doc_link_template", "_batch_size", "_ewa_cost", "_ewa_cost_min", "_fit_algorithm", "_n_components", "_no_improvement", "_random_state", "_sklearn_output_config", "_sklearn_version", "alpha", "batch_size", "callback", "components_", "dict_init", "feature_names_in_", "fit_algorithm", "max_iter", "max_no_improvement", "n_components", "n_features_in_", "n_iter_", "n_jobs", "n_steps_", "positive_code", "positive_dict", "random_state", "shuffle", "split_sign", "tol", "transform_algorithm", "transform_alpha", "transform_max_iter", "transform_n_nonzero_coefs", "verbose"], + "sklearn.decomposition._dict_learning.SparseCoder": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "dictionary", "feature_names_in_", "n_components_", "n_features_in_", "n_jobs", "positive_code", "split_sign", "transform_algorithm", "transform_alpha", "transform_max_iter", "transform_n_nonzero_coefs"], + "sklearn.decomposition._factor_analysis.FactorAnalysis": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "components_", "copy", "feature_names_in_", "iterated_power", "loglike_", "max_iter", "mean_", "n_components", "n_features_in_", "n_iter_", "noise_variance_", "noise_variance_init", "random_state", "rotation", "svd_method", "tol"], + "sklearn.decomposition._fastica.FastICA": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "_unmixing", "algorithm", "components_", "feature_names_in_", "fun", "fun_args", "max_iter", "mean_", "mixing_", "n_components", "n_features_in_", "n_iter_", "random_state", "tol", "w_init", "whiten", "whiten_solver", "whitening_"], + "sklearn.decomposition._incremental_pca.IncrementalPCA": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "batch_size", "batch_size_", "components_", "copy", "explained_variance_", "explained_variance_ratio_", "feature_names_in_", "mean_", "n_components", "n_components_", "n_features_in_", "n_samples_seen_", "noise_variance_", "singular_values_", "var_", "whiten"], + "sklearn.decomposition._kernel_pca.KernelPCA": ["X_fit_", "X_transformed_fit_", "_HTMLDocumentationLinkMixin__doc_link_template", "_centerer", "_sklearn_output_config", "_sklearn_version", "alpha", "coef0", "copy_X", "degree", "dual_coef_", "eigen_solver", "eigenvalues_", "eigenvectors_", "feature_names_in_", "fit_inverse_transform", "gamma", "gamma_", "iterated_power", "kernel", "kernel_params", "max_iter", "n_components", "n_features_in_", "n_jobs", "random_state", "remove_zero_eig", "tol"], + "sklearn.decomposition._lda.LatentDirichletAllocation": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "batch_size", "bound_", "components_", "doc_topic_prior", "doc_topic_prior_", "evaluate_every", "exp_dirichlet_component_", "feature_names_in_", "learning_decay", "learning_method", "learning_offset", "max_doc_update_iter", "max_iter", "mean_change_tol", "n_batch_iter_", "n_components", "n_features_in_", "n_iter_", "n_jobs", "perp_tol", "random_state", "random_state_", "topic_word_prior", "topic_word_prior_", "total_samples", "verbose"], + "sklearn.decomposition._nmf.MiniBatchNMF": ["_HTMLDocumentationLinkMixin__doc_link_template", "_batch_size", "_beta_loss", "_components_denominator", "_components_numerator", "_ewa_cost", "_ewa_cost_min", "_gamma", "_n_components", "_no_improvement", "_rho", "_sklearn_output_config", "_sklearn_version", "_transform_max_iter", "alpha_H", "alpha_W", "batch_size", "beta_loss", "components_", "feature_names_in_", "forget_factor", "fresh_restarts", "fresh_restarts_max_iter", "init", "l1_ratio", "max_iter", "max_no_improvement", "n_components", "n_components_", "n_features_in_", "n_iter_", "n_steps_", "random_state", "reconstruction_err_", "tol", "transform_max_iter", "verbose"], + "sklearn.decomposition._nmf.NMF": ["_HTMLDocumentationLinkMixin__doc_link_template", "_beta_loss", "_n_components", "_sklearn_output_config", "_sklearn_version", "alpha_H", "alpha_W", "beta_loss", "components_", "feature_names_in_", "init", "l1_ratio", "max_iter", "n_components", "n_components_", "n_features_in_", "n_iter_", "random_state", "reconstruction_err_", "shuffle", "solver", "tol", "verbose"], + "sklearn.decomposition._pca.PCA": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_svd_solver", "_sklearn_output_config", "_sklearn_version", "components_", "copy", "explained_variance_", "explained_variance_ratio_", "feature_names_in_", "iterated_power", "mean_", "n_components", "n_components_", "n_features_in_", "n_oversamples", "n_samples_", "noise_variance_", "power_iteration_normalizer", "random_state", "singular_values_", "svd_solver", "tol", "whiten"], + "sklearn.decomposition._sparse_pca.MiniBatchSparsePCA": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "batch_size", "callback", "components_", "feature_names_in_", "max_iter", "max_no_improvement", "mean_", "method", "n_components", "n_components_", "n_features_in_", "n_iter_", "n_jobs", "random_state", "ridge_alpha", "shuffle", "tol", "verbose"], + "sklearn.decomposition._sparse_pca.SparsePCA": ["U_init", "V_init", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "components_", "error_", "feature_names_in_", "max_iter", "mean_", "method", "n_components", "n_components_", "n_features_in_", "n_iter_", "n_jobs", "random_state", "ridge_alpha", "tol", "verbose"], + "sklearn.decomposition._truncated_svd.TruncatedSVD": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "algorithm", "components_", "explained_variance_", "explained_variance_ratio_", "feature_names_in_", "n_components", "n_features_in_", "n_iter", "n_oversamples", "power_iteration_normalizer", "random_state", "singular_values_", "tol"], + "sklearn.discriminant_analysis.LinearDiscriminantAnalysis": ["_HTMLDocumentationLinkMixin__doc_link_template", "_max_components", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "classes_", "coef_", "covariance_", "covariance_estimator", "explained_variance_ratio_", "feature_names_in_", "intercept_", "means_", "n_components", "n_features_in_", "priors", "priors_", "scalings_", "shrinkage", "solver", "store_covariance", "tol", "xbar_"], + "sklearn.discriminant_analysis.QuadraticDiscriminantAnalysis": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "covariance_", "covariance_estimator", "feature_names_in_", "means_", "n_features_in_", "priors", "priors_", "reg_param", "rotations_", "scalings_", "shrinkage", "solver", "store_covariance", "tol"], + "sklearn.dummy.DummyClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "_strategy", "class_prior_", "classes_", "constant", "feature_names_in_", "n_classes_", "n_features_in_", "n_outputs_", "random_state", "sparse_output_", "strategy"], + "sklearn.dummy.DummyRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "constant", "constant_", "feature_names_in_", "n_features_in_", "n_outputs_", "quantile", "strategy"], + "sklearn.ensemble._bagging.BaggingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_max_features", "_max_samples", "_n_samples", "_sample_weight", "_seeds", "_sklearn_version", "bootstrap", "bootstrap_features", "classes_", "estimator", "estimator_", "estimator_params", "estimators_", "estimators_features_", "feature_names_in_", "max_features", "max_samples", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "oob_decision_function_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._bagging.BaggingRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_max_features", "_max_samples", "_n_samples", "_sample_weight", "_seeds", "_sklearn_version", "bootstrap", "bootstrap_features", "estimator", "estimator_", "estimator_params", "estimators_", "estimators_features_", "feature_names_in_", "max_features", "max_samples", "n_estimators", "n_features_in_", "n_jobs", "oob_prediction_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._forest.ExtraTreesClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_samples", "_n_samples_bootstrap", "_sample_weight", "_sklearn_version", "bootstrap", "ccp_alpha", "class_weight", "classes_", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "max_depth", "max_features", "max_leaf_nodes", "max_samples", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "n_outputs_", "oob_decision_function_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._forest.ExtraTreesRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_samples", "_n_samples_bootstrap", "_sample_weight", "_sklearn_version", "bootstrap", "ccp_alpha", "class_weight", "classes_", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "max_depth", "max_features", "max_leaf_nodes", "max_samples", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "n_outputs_", "oob_prediction_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._forest.RandomForestClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_samples", "_n_samples_bootstrap", "_sample_weight", "_sklearn_version", "bootstrap", "ccp_alpha", "class_weight", "classes_", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "max_depth", "max_features", "max_leaf_nodes", "max_samples", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "n_outputs_", "oob_decision_function_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._forest.RandomForestRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_samples", "_n_samples_bootstrap", "_sample_weight", "_sklearn_version", "bootstrap", "ccp_alpha", "class_weight", "classes_", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "max_depth", "max_features", "max_leaf_nodes", "max_samples", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "n_outputs_", "oob_prediction_", "oob_score", "oob_score_", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._forest.RandomTreesEmbedding": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_n_samples", "_n_samples_bootstrap", "_sample_weight", "_sklearn_output_config", "_sklearn_version", "bootstrap", "class_weight", "classes_", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "max_depth", "max_leaf_nodes", "max_samples", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "n_classes_", "n_estimators", "n_features_in_", "n_jobs", "n_outputs_", "one_hot_encoder_", "oob_score", "random_state", "sparse_output", "verbose", "warm_start"], + "sklearn.ensemble._gb.GradientBoostingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_loss", "_rng", "_sklearn_version", "alpha", "ccp_alpha", "classes_", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "init", "init_", "learning_rate", "loss", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "n_classes_", "n_estimators", "n_estimators_", "n_features_in_", "n_iter_no_change", "n_trees_per_iteration_", "oob_improvement_", "oob_score_", "oob_scores_", "random_state", "subsample", "tol", "train_score_", "validation_fraction", "verbose", "warm_start"], + "sklearn.ensemble._gb.GradientBoostingRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_loss", "_rng", "_sklearn_version", "alpha", "ccp_alpha", "criterion", "estimator", "estimator_", "estimator_params", "estimators_", "feature_names_in_", "init", "init_", "learning_rate", "loss", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "n_estimators", "n_estimators_", "n_features_in_", "n_iter_no_change", "n_trees_per_iteration_", "oob_improvement_", "oob_score_", "oob_scores_", "random_state", "subsample", "tol", "train_score_", "validation_fraction", "verbose", "warm_start"], + "sklearn.ensemble._hist_gradient_boosting.binning._BinMapper": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "bin_thresholds_", "feature_names_in_", "is_categorical", "is_categorical_", "known_categories", "missing_values_bin_idx_", "n_bins", "n_bins_non_missing_", "n_features_in_", "n_threads", "random_state", "subsample"], + "sklearn.ensemble._hist_gradient_boosting.gradient_boosting.HistGradientBoostingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_baseline_prediction", "_bin_mapper", "_feature_subsample_rng", "_fitted_with_sw", "_in_fit", "_is_categorical_remapped", "_label_encoder", "_loss", "_n_features", "_predictors", "_preprocessor", "_random_seed", "_scorer", "_sklearn_version", "_use_validation_data", "categorical_features", "class_weight", "classes_", "do_early_stopping_", "early_stopping", "feature_names_in_", "interaction_cst", "is_categorical_", "l2_regularization", "learning_rate", "loss", "max_bins", "max_depth", "max_features", "max_iter", "max_leaf_nodes", "min_samples_leaf", "monotonic_cst", "n_features_in_", "n_iter_no_change", "n_trees_per_iteration_", "random_state", "scoring", "tol", "train_score_", "validation_fraction", "validation_score_", "verbose", "warm_start"], + "sklearn.ensemble._hist_gradient_boosting.gradient_boosting.HistGradientBoostingRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_baseline_prediction", "_bin_mapper", "_feature_subsample_rng", "_fitted_with_sw", "_in_fit", "_is_categorical_remapped", "_loss", "_n_features", "_predictors", "_preprocessor", "_random_seed", "_scorer", "_sklearn_version", "_use_validation_data", "categorical_features", "do_early_stopping_", "early_stopping", "feature_names_in_", "interaction_cst", "is_categorical_", "l2_regularization", "learning_rate", "loss", "max_bins", "max_depth", "max_features", "max_iter", "max_leaf_nodes", "min_samples_leaf", "monotonic_cst", "n_features_in_", "n_iter_no_change", "n_trees_per_iteration_", "quantile", "random_state", "scoring", "tol", "train_score_", "validation_fraction", "validation_score_", "verbose", "warm_start"], + "sklearn.ensemble._iforest.IsolationForest": ["_HTMLDocumentationLinkMixin__doc_link_template", "_average_path_length_per_tree", "_decision_path_lengths", "_max_features", "_max_samples", "_n_samples", "_sample_weight", "_seeds", "_sklearn_version", "bootstrap", "bootstrap_features", "contamination", "estimator", "estimator_", "estimator_params", "estimators_", "estimators_features_", "feature_names_in_", "max_features", "max_samples", "max_samples_", "n_estimators", "n_features_in_", "n_jobs", "offset_", "oob_score", "random_state", "verbose", "warm_start"], + "sklearn.ensemble._stacking.StackingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_label_encoder", "_n_feature_outs", "_sklearn_output_config", "_sklearn_version", "classes_", "cv", "estimators", "estimators_", "feature_names_in_", "final_estimator", "final_estimator_", "n_features_in_", "n_jobs", "named_estimators_", "passthrough", "stack_method", "stack_method_", "verbose"], + "sklearn.ensemble._stacking.StackingRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_feature_outs", "_sklearn_output_config", "_sklearn_version", "cv", "estimators", "estimators_", "feature_names_in_", "final_estimator", "final_estimator_", "n_features_in_", "n_jobs", "named_estimators_", "passthrough", "stack_method", "stack_method_", "verbose"], + "sklearn.ensemble._voting.VotingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "classes_", "estimators", "estimators_", "feature_names_in_", "flatten_transform", "le_", "n_features_in_", "n_jobs", "named_estimators_", "verbose", "voting", "weights"], + "sklearn.ensemble._voting.VotingRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "estimators", "estimators_", "feature_names_in_", "n_features_in_", "n_jobs", "named_estimators_", "verbose", "weights"], + "sklearn.ensemble._weight_boosting.AdaBoostClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "estimator", "estimator_", "estimator_errors_", "estimator_params", "estimator_weights_", "estimators_", "feature_names_in_", "learning_rate", "n_classes_", "n_estimators", "n_features_in_", "random_state"], + "sklearn.ensemble._weight_boosting.AdaBoostRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "estimator", "estimator_", "estimator_errors_", "estimator_params", "estimator_weights_", "estimators_", "feature_names_in_", "learning_rate", "loss", "n_estimators", "n_features_in_", "random_state"], + "sklearn.feature_extraction._dict_vectorizer.DictVectorizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "dtype", "feature_names_", "feature_names_in_", "n_features_in_", "separator", "sort", "sparse", "vocabulary_"], + "sklearn.feature_extraction._hash.FeatureHasher": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alternate_sign", "dtype", "feature_names_in_", "input_type", "n_features", "n_features_in_"], + "sklearn.feature_extraction.image.PatchExtractor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "max_patches", "n_features_in_", "patch_size", "random_state"], + "sklearn.feature_extraction.text.CountVectorizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "_stop_words_id", "analyzer", "binary", "decode_error", "dtype", "encoding", "feature_names_in_", "fixed_vocabulary_", "input", "lowercase", "max_df", "max_features", "min_df", "n_features_in_", "ngram_range", "preprocessor", "stop_words", "strip_accents", "token_pattern", "tokenizer", "vocabulary", "vocabulary_"], + "sklearn.feature_extraction.text.HashingVectorizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "_stop_words_id", "alternate_sign", "analyzer", "binary", "decode_error", "dtype", "encoding", "feature_names_in_", "fixed_vocabulary_", "input", "lowercase", "n_features", "n_features_in_", "ngram_range", "norm", "preprocessor", "stop_words", "strip_accents", "token_pattern", "tokenizer", "vocabulary_"], + "sklearn.feature_extraction.text.TfidfTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "idf_", "n_features_in_", "norm", "smooth_idf", "sublinear_tf", "use_idf"], + "sklearn.feature_extraction.text.TfidfVectorizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "_stop_words_id", "_tfidf", "analyzer", "binary", "decode_error", "dtype", "encoding", "feature_names_in_", "fixed_vocabulary_", "input", "lowercase", "max_df", "max_features", "min_df", "n_features_in_", "ngram_range", "norm", "preprocessor", "smooth_idf", "stop_words", "strip_accents", "sublinear_tf", "token_pattern", "tokenizer", "use_idf", "vocabulary", "vocabulary_"], + "sklearn.feature_selection._from_model.SelectFromModel": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "estimator", "estimator_", "feature_names_in_", "importance_getter", "max_features", "max_features_", "n_features_in_", "norm_order", "prefit", "threshold"], + "sklearn.feature_selection._rfe.RFE": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "estimator", "estimator_", "feature_names_in_", "importance_getter", "n_features_", "n_features_in_", "n_features_to_select", "ranking_", "step", "step_n_features_", "step_ranking_", "step_scores_", "step_support_", "support_", "verbose"], + "sklearn.feature_selection._rfe.RFECV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "cv", "cv_results_", "estimator", "estimator_", "feature_names_in_", "importance_getter", "min_features_to_select", "n_features_", "n_features_in_", "n_features_to_select", "n_jobs", "ranking_", "scoring", "step", "step_n_features_", "step_ranking_", "step_scores_", "step_support_", "support_", "verbose"], + "sklearn.feature_selection._sequential.SequentialFeatureSelector": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "cv", "direction", "estimator", "feature_names_in_", "n_features_in_", "n_features_to_select", "n_features_to_select_", "n_jobs", "scoring", "support_", "tol"], + "sklearn.feature_selection._univariate_selection.GenericUnivariateSelect": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "mode", "n_features_in_", "param", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._univariate_selection.SelectFdr": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "feature_names_in_", "n_features_in_", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._univariate_selection.SelectFpr": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "feature_names_in_", "n_features_in_", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._univariate_selection.SelectFwe": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "alpha", "feature_names_in_", "n_features_in_", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._univariate_selection.SelectKBest": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "k", "n_features_in_", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._univariate_selection.SelectPercentile": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "n_features_in_", "percentile", "pvalues_", "score_func", "scores_"], + "sklearn.feature_selection._variance_threshold.VarianceThreshold": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "n_features_in_", "threshold", "variances_"], + "sklearn.frozen._frozen.FrozenEstimator": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "estimator", "feature_names_in_", "n_features_in_"], + "sklearn.gaussian_process._gpc.GaussianProcessClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "base_estimator_", "classes_", "copy_X_train", "feature_names_in_", "kernel", "log_marginal_likelihood_value_", "max_iter_predict", "multi_class", "n_classes_", "n_features_in_", "n_jobs", "n_restarts_optimizer", "optimizer", "random_state", "warm_start"], + "sklearn.gaussian_process._gpr.GaussianProcessRegressor": ["L_", "X_train_", "_HTMLDocumentationLinkMixin__doc_link_template", "_rng", "_sklearn_version", "_y_train_mean", "_y_train_std", "alpha", "alpha_", "copy_X_train", "feature_names_in_", "kernel", "kernel_", "log_marginal_likelihood_value_", "n_features_in_", "n_restarts_optimizer", "n_targets", "normalize_y", "optimizer", "random_state", "y_train_"], + "sklearn.impute._base.MissingIndicator": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features", "_precomputed", "_sklearn_output_config", "_sklearn_version", "error_on_new", "feature_names_in_", "features", "features_", "missing_values", "n_features_in_", "sparse"], + "sklearn.impute._base.SimpleImputer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fill_dtype", "_fit_dtype", "_sklearn_output_config", "_sklearn_version", "add_indicator", "copy", "feature_names_in_", "fill_value", "indicator_", "keep_empty_features", "missing_values", "n_features_in_", "statistics_", "strategy"], + "sklearn.impute._knn.KNNImputer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_mask_fit_X", "_sklearn_output_config", "_sklearn_version", "_valid_mask", "add_indicator", "copy", "feature_names_in_", "indicator_", "keep_empty_features", "metric", "missing_values", "n_features_in_", "n_neighbors", "weights"], + "sklearn.isotonic.IsotonicRegression": ["X_max_", "X_min_", "X_thresholds_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "f_", "feature_names_in_", "increasing", "increasing_", "n_features_in_", "out_of_bounds", "y_max", "y_min", "y_thresholds_"], + "sklearn.kernel_approximation.AdditiveChi2Sampler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "n_features_in_", "sample_interval", "sample_steps"], + "sklearn.kernel_approximation.Nystroem": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "coef0", "component_indices_", "components_", "degree", "feature_names_in_", "gamma", "kernel", "kernel_params", "n_components", "n_features_in_", "n_jobs", "normalization_", "random_state"], + "sklearn.kernel_approximation.PolynomialCountSketch": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "bitHash_", "coef0", "degree", "feature_names_in_", "gamma", "indexHash_", "n_components", "n_features_in_", "random_state"], + "sklearn.kernel_approximation.RBFSampler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_gamma", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "gamma", "n_components", "n_features_in_", "random_offset_", "random_state", "random_weights_"], + "sklearn.kernel_approximation.SkewedChi2Sampler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "n_components", "n_features_in_", "random_offset_", "random_state", "random_weights_", "skewedness"], + "sklearn.kernel_ridge.KernelRidge": ["X_fit_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef0", "degree", "dual_coef_", "feature_names_in_", "gamma", "kernel", "kernel_params", "n_features_in_"], + "sklearn.linear_model._base.LinearRegression": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "coef_", "copy_X", "feature_names_in_", "fit_intercept", "intercept_", "n_features_in_", "n_jobs", "positive", "rank_", "singular_", "tol"], + "sklearn.linear_model._bayes.ARDRegression": ["X_offset_", "X_scale_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alpha_1", "alpha_2", "coef_", "compute_score", "copy_X", "feature_names_in_", "fit_intercept", "intercept_", "lambda_", "lambda_1", "lambda_2", "max_iter", "n_features_in_", "n_iter_", "scores_", "sigma_", "threshold_lambda", "tol", "verbose"], + "sklearn.linear_model._bayes.BayesianRidge": ["X_offset_", "X_scale_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alpha_1", "alpha_2", "alpha_init", "coef_", "compute_score", "copy_X", "feature_names_in_", "fit_intercept", "intercept_", "lambda_", "lambda_1", "lambda_2", "lambda_init", "max_iter", "n_features_in_", "n_iter_", "scores_", "sigma_", "tol", "verbose"], + "sklearn.linear_model._coordinate_descent.ElasticNet": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "copy_X", "dual_gap_", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "max_iter", "n_features_in_", "n_iter_", "positive", "precompute", "random_state", "selection", "tol", "warm_start"], + "sklearn.linear_model._coordinate_descent.ElasticNetCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alphas", "alphas_", "coef_", "copy_X", "cv", "dual_gap_", "eps", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "l1_ratio_", "max_iter", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "positive", "precompute", "random_state", "selection", "tol", "verbose"], + "sklearn.linear_model._coordinate_descent.Lasso": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "copy_X", "dual_gap_", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "max_iter", "n_features_in_", "n_iter_", "positive", "precompute", "random_state", "selection", "tol", "warm_start"], + "sklearn.linear_model._coordinate_descent.LassoCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alphas", "alphas_", "coef_", "copy_X", "cv", "dual_gap_", "eps", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio_", "max_iter", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "positive", "precompute", "random_state", "selection", "tol", "verbose"], + "sklearn.linear_model._coordinate_descent.MultiTaskElasticNet": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "copy_X", "dual_gap_", "eps_", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "max_iter", "n_features_in_", "n_iter_", "positive", "precompute", "random_state", "selection", "tol", "warm_start"], + "sklearn.linear_model._coordinate_descent.MultiTaskElasticNetCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alphas", "alphas_", "coef_", "copy_X", "cv", "dual_gap_", "eps", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "l1_ratio_", "max_iter", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "positive", "precompute", "random_state", "selection", "tol", "verbose"], + "sklearn.linear_model._coordinate_descent.MultiTaskLasso": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "copy_X", "dual_gap_", "eps_", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "max_iter", "n_features_in_", "n_iter_", "positive", "precompute", "random_state", "selection", "tol", "warm_start"], + "sklearn.linear_model._coordinate_descent.MultiTaskLassoCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alphas", "alphas_", "coef_", "copy_X", "cv", "dual_gap_", "eps", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio_", "max_iter", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "positive", "precompute", "random_state", "selection", "tol", "verbose"], + "sklearn.linear_model._glm.glm.GammaRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_base_loss", "_sklearn_version", "alpha", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "solver", "tol", "verbose", "warm_start"], + "sklearn.linear_model._glm.glm.PoissonRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_base_loss", "_sklearn_version", "alpha", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "solver", "tol", "verbose", "warm_start"], + "sklearn.linear_model._glm.glm.TweedieRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_base_loss", "_sklearn_version", "alpha", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "link", "max_iter", "n_features_in_", "n_iter_", "power", "solver", "tol", "verbose", "warm_start"], + "sklearn.linear_model._huber.HuberRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "epsilon", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "outliers_", "scale_", "tol", "warm_start"], + "sklearn.linear_model._least_angle.Lars": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "active_", "alphas_", "coef_", "coef_path_", "copy_X", "eps", "feature_names_in_", "fit_intercept", "fit_path", "intercept_", "jitter", "n_features_in_", "n_iter_", "n_nonzero_coefs", "precompute", "random_state", "verbose"], + "sklearn.linear_model._least_angle.LarsCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "active_", "alpha_", "alphas_", "coef_", "coef_path_", "copy_X", "cv", "cv_alphas_", "eps", "feature_names_in_", "fit_intercept", "fit_path", "intercept_", "jitter", "max_iter", "max_n_alphas", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "n_nonzero_coefs", "precompute", "random_state", "verbose"], + "sklearn.linear_model._least_angle.LassoLars": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "active_", "alpha", "alphas_", "coef_", "coef_path_", "copy_X", "eps", "feature_names_in_", "fit_intercept", "fit_path", "intercept_", "jitter", "max_iter", "n_features_in_", "n_iter_", "n_nonzero_coefs", "positive", "precompute", "random_state", "verbose"], + "sklearn.linear_model._least_angle.LassoLarsCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "active_", "alpha_", "alphas_", "coef_", "coef_path_", "copy_X", "cv", "cv_alphas_", "eps", "feature_names_in_", "fit_intercept", "fit_path", "intercept_", "jitter", "max_iter", "max_n_alphas", "mse_path_", "n_features_in_", "n_iter_", "n_jobs", "n_nonzero_coefs", "positive", "precompute", "random_state", "verbose"], + "sklearn.linear_model._least_angle.LassoLarsIC": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "active_", "alpha", "alpha_", "alphas_", "coef_", "coef_path_", "copy_X", "criterion", "criterion_", "eps", "feature_names_in_", "fit_intercept", "fit_path", "intercept_", "jitter", "max_iter", "n_features_in_", "n_iter_", "n_nonzero_coefs", "noise_variance", "noise_variance_", "positive", "precompute", "random_state", "verbose"], + "sklearn.linear_model._logistic.LogisticRegression": ["C", "_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_version", "class_weight", "classes_", "coef_", "dual", "feature_names_in_", "fit_intercept", "intercept_", "intercept_scaling", "l1_ratio", "max_iter", "n_features_in_", "n_iter_", "n_jobs", "penalty", "random_state", "solver", "tol", "verbose", "warm_start"], + "sklearn.linear_model._logistic.LogisticRegressionCV": ["C", "C_", "Cs", "Cs_", "_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_version", "class_weight", "classes_", "coef_", "coefs_paths_", "cv", "dual", "feature_names_in_", "fit_intercept", "intercept_", "intercept_scaling", "l1_ratio", "l1_ratio_", "l1_ratios", "l1_ratios_", "max_iter", "n_features_in_", "n_iter_", "n_jobs", "penalty", "random_state", "refit", "scores_", "scoring", "solver", "tol", "use_legacy_attributes", "verbose", "warm_start"], + "sklearn.linear_model._omp.OrthogonalMatchingPursuit": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "n_features_in_", "n_iter_", "n_nonzero_coefs", "n_nonzero_coefs_", "precompute", "tol"], + "sklearn.linear_model._omp.OrthogonalMatchingPursuitCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "coef_", "copy", "cv", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "n_jobs", "n_nonzero_coefs_", "verbose"], + "sklearn.linear_model._passive_aggressive.PassiveAggressiveClassifier": ["C", "_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_expanded_class_weight", "_loss_function_", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "class_weight", "classes_", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "n_jobs", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._passive_aggressive.PassiveAggressiveRegressor": ["C", "_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._perceptron.Perceptron": ["_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_expanded_class_weight", "_loss_function_", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "class_weight", "classes_", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "n_jobs", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._quantile.QuantileRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "n_features_in_", "n_iter_", "quantile", "solver", "solver_options"], + "sklearn.linear_model._ransac.RANSACRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "estimator", "estimator_", "feature_names_in_", "inlier_mask_", "is_data_valid", "is_model_valid", "loss", "max_skips", "max_trials", "min_samples", "n_features_in_", "n_skips_invalid_data_", "n_skips_invalid_model_", "n_skips_no_inliers_", "n_trials_", "random_state", "residual_threshold", "stop_n_inliers", "stop_probability", "stop_score"], + "sklearn.linear_model._ridge.Ridge": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "coef_", "copy_X", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "positive", "random_state", "solver", "solver_", "tol"], + "sklearn.linear_model._ridge.RidgeCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha_", "alpha_per_target", "alphas", "best_score_", "coef_", "cv", "cv_results_", "feature_names_in_", "fit_intercept", "gcv_mode", "intercept_", "n_features_in_", "scoring", "store_cv_results"], + "sklearn.linear_model._ridge.RidgeClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_label_binarizer", "_sklearn_version", "alpha", "class_weight", "classes_", "coef_", "copy_X", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "n_features_in_", "n_iter_", "positive", "random_state", "solver", "solver_", "tol"], + "sklearn.linear_model._ridge.RidgeClassifierCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_label_binarizer", "_sklearn_version", "alpha_", "alpha_per_target", "alphas", "best_score_", "class_weight", "classes_", "coef_", "cv", "cv_results_", "feature_names_in_", "fit_intercept", "gcv_mode", "intercept_", "n_features_in_", "scoring", "store_cv_results"], + "sklearn.linear_model._stochastic_gradient.SGDClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_expanded_class_weight", "_loss_function_", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "class_weight", "classes_", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "n_jobs", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._stochastic_gradient.SGDOneClassSVM": ["_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_loss_function_", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "nu", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._stochastic_gradient.SGDRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_average_coef", "_average_intercept", "_sklearn_version", "_standard_coef", "_standard_intercept", "alpha", "average", "coef_", "early_stopping", "epsilon", "eta0", "feature_names_in_", "fit_intercept", "intercept_", "l1_ratio", "learning_rate", "loss", "max_iter", "n_features_in_", "n_iter_", "n_iter_no_change", "offset_", "penalty", "power_t", "random_state", "shuffle", "t_", "tol", "validation_fraction", "verbose", "warm_start"], + "sklearn.linear_model._theil_sen.TheilSenRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "breakdown_", "coef_", "feature_names_in_", "fit_intercept", "intercept_", "max_iter", "max_subpopulation", "n_features_in_", "n_iter_", "n_jobs", "n_subpopulation_", "n_subsamples", "random_state", "tol", "verbose"], + "sklearn.manifold._classical_mds.ClassicalMDS": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "dissimilarity_matrix_", "eigenvalues_", "embedding_", "feature_names_in_", "metric", "metric_params", "n_components", "n_features_in_"], + "sklearn.manifold._isomap.Isomap": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "dist_matrix_", "eigen_solver", "embedding_", "feature_names_in_", "kernel_pca_", "max_iter", "metric", "metric_params", "n_components", "n_features_in_", "n_jobs", "n_neighbors", "nbrs_", "neighbors_algorithm", "p", "path_method", "radius", "tol"], + "sklearn.manifold._locally_linear.LocallyLinearEmbedding": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "eigen_solver", "embedding_", "feature_names_in_", "hessian_tol", "max_iter", "method", "modified_tol", "n_components", "n_features_in_", "n_jobs", "n_neighbors", "nbrs_", "neighbors_algorithm", "random_state", "reconstruction_error_", "reg", "tol"], + "sklearn.manifold._mds.MDS": ["_HTMLDocumentationLinkMixin__doc_link_template", "_init", "_metric", "_metric_mds", "_sklearn_version", "dissimilarity", "dissimilarity_matrix_", "embedding_", "eps", "feature_names_in_", "init", "max_iter", "metric", "metric_mds", "metric_params", "n_components", "n_features_in_", "n_init", "n_iter_", "n_jobs", "normalized_stress", "random_state", "stress_", "verbose"], + "sklearn.manifold._spectral_embedding.SpectralEmbedding": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "affinity", "affinity_matrix_", "eigen_solver", "eigen_tol", "embedding_", "feature_names_in_", "gamma", "gamma_", "n_components", "n_features_in_", "n_jobs", "n_neighbors", "n_neighbors_", "random_state"], + "sklearn.manifold._t_sne.TSNE": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "angle", "early_exaggeration", "embedding_", "feature_names_in_", "init", "kl_divergence_", "learning_rate", "learning_rate_", "max_iter", "method", "metric", "metric_params", "min_grad_norm", "n_components", "n_features_in_", "n_iter_", "n_iter_without_progress", "n_jobs", "perplexity", "random_state", "verbose"], + "sklearn.mixture._bayesian_mixture.BayesianGaussianMixture": ["_HTMLDocumentationLinkMixin__doc_link_template", "_init_prev_time", "_iter_prev_time", "_sklearn_version", "converged_", "covariance_prior", "covariance_prior_", "covariance_type", "covariances_", "degrees_of_freedom_", "degrees_of_freedom_prior", "degrees_of_freedom_prior_", "feature_names_in_", "init_params", "lower_bound_", "lower_bounds_", "max_iter", "mean_precision_", "mean_precision_prior", "mean_precision_prior_", "mean_prior", "mean_prior_", "means_", "n_components", "n_features_in_", "n_init", "n_iter_", "precisions_", "precisions_cholesky_", "random_state", "reg_covar", "tol", "verbose", "verbose_interval", "warm_start", "weight_concentration_", "weight_concentration_prior", "weight_concentration_prior_", "weight_concentration_prior_type", "weights_"], + "sklearn.mixture._gaussian_mixture.GaussianMixture": ["_HTMLDocumentationLinkMixin__doc_link_template", "_init_prev_time", "_iter_prev_time", "_sklearn_version", "converged_", "covariance_type", "covariances_", "feature_names_in_", "init_params", "lower_bound_", "lower_bounds_", "max_iter", "means_", "means_init", "n_components", "n_features_in_", "n_init", "n_iter_", "precisions_", "precisions_cholesky_", "precisions_init", "random_state", "reg_covar", "tol", "verbose", "verbose_interval", "warm_start", "weights_", "weights_init"], + "sklearn.model_selection._classification_threshold.FixedThresholdClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "estimator", "estimator_", "feature_names_in_", "n_features_in_", "pos_label", "response_method", "threshold"], + "sklearn.model_selection._classification_threshold.TunedThresholdClassifierCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_curve_scorer", "_sklearn_version", "best_score_", "best_threshold_", "cv", "cv_results_", "estimator", "estimator_", "feature_names_in_", "n_features_in_", "n_jobs", "random_state", "refit", "response_method", "scoring", "store_cv_results", "thresholds"], + "sklearn.model_selection._search.GridSearchCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_checked_cv_orig", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_version", "best_estimator_", "best_index_", "best_params_", "best_score_", "cv", "cv_results_", "error_score", "estimator", "feature_names_in_", "multimetric_", "n_features_in_", "n_jobs", "n_splits_", "param_grid", "pre_dispatch", "refit", "refit_time_", "return_train_score", "scorer_", "scoring", "verbose"], + "sklearn.model_selection._search.RandomizedSearchCV": ["_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_checked_cv_orig", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_version", "best_estimator_", "best_index_", "best_params_", "best_score_", "cv", "cv_results_", "error_score", "estimator", "feature_names_in_", "multimetric_", "n_features_in_", "n_iter", "n_jobs", "n_splits_", "param_distributions", "pre_dispatch", "random_state", "refit", "refit_time_", "return_train_score", "scorer_", "scoring", "verbose"], + "sklearn.multiclass.OneVsOneClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "n_jobs", "pairwise_indices_"], + "sklearn.multiclass.OneVsRestClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "estimator", "estimators_", "feature_names_in_", "label_binarizer_", "n_features_in_", "n_jobs", "verbose"], + "sklearn.multiclass.OutputCodeClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "code_book_", "code_size", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "n_jobs", "random_state"], + "sklearn.multioutput.ClassifierChain": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "chain_method", "chain_method_", "classes_", "cv", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "order", "order_", "random_state", "verbose"], + "sklearn.multioutput.MultiOutputClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "n_jobs"], + "sklearn.multioutput.MultiOutputRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "n_jobs"], + "sklearn.multioutput.RegressorChain": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "chain_method_", "cv", "estimator", "estimators_", "feature_names_in_", "n_features_in_", "order", "order_", "random_state", "verbose"], + "sklearn.naive_bayes.BernoulliNB": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "binarize", "class_count_", "class_log_prior_", "class_prior", "classes_", "feature_count_", "feature_log_prob_", "feature_names_in_", "fit_prior", "force_alpha", "n_features_in_"], + "sklearn.naive_bayes.CategoricalNB": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "category_count_", "class_count_", "class_log_prior_", "class_prior", "classes_", "feature_count_", "feature_log_prob_", "feature_names_in_", "fit_prior", "force_alpha", "min_categories", "n_categories_", "n_features_in_"], + "sklearn.naive_bayes.ComplementNB": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "class_count_", "class_log_prior_", "class_prior", "classes_", "feature_all_", "feature_count_", "feature_log_prob_", "feature_names_in_", "fit_prior", "force_alpha", "n_features_in_", "norm"], + "sklearn.naive_bayes.GaussianNB": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "class_count_", "class_prior_", "classes_", "epsilon_", "feature_names_in_", "n_features_in_", "priors", "theta_", "var_", "var_smoothing"], + "sklearn.naive_bayes.MultinomialNB": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "class_count_", "class_log_prior_", "class_prior", "classes_", "feature_count_", "feature_log_prob_", "feature_names_in_", "fit_prior", "force_alpha", "n_features_in_"], + "sklearn.neighbors._classification.KNeighborsClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius", "weights"], + "sklearn.neighbors._classification.RadiusNeighborsClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outlier_label", "outlier_label_", "outputs_2d_", "p", "radius", "weights"], + "sklearn.neighbors._graph.KNeighborsTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "mode", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius"], + "sklearn.neighbors._graph.RadiusNeighborsTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "mode", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius"], + "sklearn.neighbors._kde.KernelDensity": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "atol", "bandwidth", "bandwidth_", "breadth_first", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "kernel", "leaf_size", "metric", "metric_params", "n_features_in_", "n_samples_fit_", "outputs_2d_", "rtol", "tree_"], + "sklearn.neighbors._lof.LocalOutlierFactor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_distances_fit_X_", "_fit_X", "_fit_method", "_lrd", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "contamination", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_neighbors_", "n_samples_fit_", "negative_outlier_factor_", "novelty", "offset_", "outputs_2d_", "p", "radius"], + "sklearn.neighbors._nca.NeighborhoodComponentsAnalysis": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_output_config", "_sklearn_version", "_tree", "_y", "callback", "classes_", "components_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "init", "max_iter", "n_components", "n_features_in_", "n_iter_", "n_samples_fit_", "outputs_2d_", "random_state", "random_state_", "tol", "verbose", "warm_start"], + "sklearn.neighbors._nearest_centroid.NearestCentroid": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "centroids_", "class_prior_", "classes_", "deviations_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "metric", "n_features_in_", "n_samples_fit_", "outputs_2d_", "priors", "shrink_threshold", "within_class_std_dev_"], + "sklearn.neighbors._regression.KNeighborsRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius", "weights"], + "sklearn.neighbors._regression.RadiusNeighborsRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius", "weights"], + "sklearn.neighbors._unsupervised.NearestNeighbors": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius"], + "sklearn.neural_network._multilayer_perceptron.MLPClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_best_coefs", "_best_intercepts", "_coef_indptr", "_intercept_indptr", "_label_binarizer", "_no_improvement_count", "_optimizer", "_random_state", "_sklearn_version", "activation", "alpha", "batch_size", "best_loss_", "best_validation_score_", "beta_1", "beta_2", "classes_", "coefs_", "early_stopping", "epsilon", "feature_names_in_", "hidden_layer_sizes", "intercepts_", "learning_rate", "learning_rate_init", "loss", "loss_", "loss_curve_", "max_fun", "max_iter", "momentum", "n_features_in_", "n_iter_", "n_iter_no_change", "n_layers_", "n_outputs_", "nesterovs_momentum", "out_activation_", "power_t", "random_state", "shuffle", "solver", "t_", "tol", "validation_fraction", "validation_scores_", "verbose", "warm_start"], + "sklearn.neural_network._multilayer_perceptron.MLPRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_best_coefs", "_best_intercepts", "_coef_indptr", "_intercept_indptr", "_no_improvement_count", "_optimizer", "_random_state", "_sklearn_version", "activation", "alpha", "batch_size", "best_loss_", "best_validation_score_", "beta_1", "beta_2", "coefs_", "early_stopping", "epsilon", "feature_names_in_", "hidden_layer_sizes", "intercepts_", "learning_rate", "learning_rate_init", "loss", "loss_", "loss_curve_", "max_fun", "max_iter", "momentum", "n_features_in_", "n_iter_", "n_iter_no_change", "n_layers_", "n_outputs_", "nesterovs_momentum", "out_activation_", "power_t", "random_state", "shuffle", "solver", "t_", "tol", "validation_fraction", "validation_scores_", "verbose", "warm_start"], + "sklearn.neural_network._rbm.BernoulliRBM": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "batch_size", "components_", "feature_names_in_", "h_samples_", "intercept_hidden_", "intercept_visible_", "learning_rate", "n_components", "n_features_in_", "n_iter", "random_state", "random_state_", "verbose"], + "sklearn.pipeline.FeatureUnion": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "_transformers_feature_names_out", "feature_names_in_", "n_features_in_", "n_jobs", "transformer_list", "transformer_weights", "verbose", "verbose_feature_names_out"], + "sklearn.pipeline.Pipeline": ["_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_version", "feature_names_in_", "memory", "n_features_in_", "steps", "transform_input", "verbose"], + "sklearn.preprocessing._data.Binarizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "copy", "feature_names_in_", "n_features_in_", "threshold"], + "sklearn.preprocessing._data.KernelCenterer": ["K_fit_all_", "K_fit_rows_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "feature_names_in_", "n_features_in_"], + "sklearn.preprocessing._data.MaxAbsScaler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "clip", "copy", "feature_names_in_", "max_abs_", "n_features_in_", "n_samples_seen_", "scale_"], + "sklearn.preprocessing._data.MinMaxScaler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "clip", "copy", "data_max_", "data_min_", "data_range_", "feature_names_in_", "feature_range", "min_", "n_features_in_", "n_samples_seen_", "scale_"], + "sklearn.preprocessing._data.Normalizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "copy", "feature_names_in_", "n_features_in_", "norm"], + "sklearn.preprocessing._data.PowerTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_scaler", "_sklearn_output_config", "_sklearn_version", "copy", "feature_names_in_", "lambdas_", "method", "n_features_in_", "standardize"], + "sklearn.preprocessing._data.QuantileTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "copy", "feature_names_in_", "ignore_implicit_zeros", "n_features_in_", "n_quantiles", "n_quantiles_", "output_distribution", "quantiles_", "random_state", "references_", "subsample"], + "sklearn.preprocessing._data.RobustScaler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "center_", "copy", "feature_names_in_", "n_features_in_", "quantile_range", "scale_", "unit_variance", "with_centering", "with_scaling"], + "sklearn.preprocessing._data.StandardScaler": ["_HTMLDocumentationLinkMixin__doc_link_template", "_callback_fit_ctx", "_skl_callbacks", "_skl_callbacks_to_teardown", "_sklearn_output_config", "_sklearn_version", "copy", "feature_names_in_", "mean_", "n_features_in_", "n_samples_seen_", "scale_", "var_", "with_mean", "with_std"], + "sklearn.preprocessing._discretization.KBinsDiscretizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_encoder", "_sklearn_output_config", "_sklearn_version", "bin_edges_", "dtype", "encode", "feature_names_in_", "n_bins", "n_bins_", "n_features_in_", "quantile_method", "random_state", "strategy", "subsample"], + "sklearn.preprocessing._encoders.OneHotEncoder": ["_HTMLDocumentationLinkMixin__doc_link_template", "_default_to_infrequent_mappings", "_drop_idx_after_grouping", "_infrequent_enabled", "_infrequent_indices", "_n_features_outs", "_sklearn_output_config", "_sklearn_version", "categories", "categories_", "drop", "drop_idx_", "dtype", "feature_name_combiner", "feature_names_in_", "handle_unknown", "max_categories", "min_frequency", "n_features_in_", "sparse_output"], + "sklearn.preprocessing._encoders.OrdinalEncoder": ["_HTMLDocumentationLinkMixin__doc_link_template", "_default_to_infrequent_mappings", "_infrequent_enabled", "_infrequent_indices", "_missing_indices", "_sklearn_output_config", "_sklearn_version", "categories", "categories_", "dtype", "encoded_missing_value", "feature_names_in_", "handle_unknown", "max_categories", "min_frequency", "n_features_in_", "unknown_value"], + "sklearn.preprocessing._function_transformer.FunctionTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "accept_sparse", "check_inverse", "feature_names_in_", "feature_names_out", "func", "inv_kw_args", "inverse_func", "kw_args", "n_features_in_", "validate"], + "sklearn.preprocessing._label.LabelBinarizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "classes_", "feature_names_in_", "n_features_in_", "neg_label", "pos_label", "sparse_input_", "sparse_output", "y_type_"], + "sklearn.preprocessing._label.LabelEncoder": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "classes_", "feature_names_in_", "n_features_in_"], + "sklearn.preprocessing._label.MultiLabelBinarizer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_cached_dict", "_sklearn_output_config", "_sklearn_version", "classes", "classes_", "feature_names_in_", "n_features_in_", "sparse_output"], + "sklearn.preprocessing._polynomial.PolynomialFeatures": ["_HTMLDocumentationLinkMixin__doc_link_template", "_max_degree", "_min_degree", "_n_out_full", "_sklearn_output_config", "_sklearn_version", "degree", "feature_names_in_", "include_bias", "interaction_only", "n_features_in_", "n_output_features_", "order"], + "sklearn.preprocessing._polynomial.SplineTransformer": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_output_config", "_sklearn_version", "bsplines_", "degree", "extrapolation", "feature_names_in_", "handle_missing", "include_bias", "knots", "n_features_in_", "n_features_out_", "n_knots", "order", "sparse_output"], + "sklearn.preprocessing._target_encoder.TargetEncoder": ["_HTMLDocumentationLinkMixin__doc_link_template", "_default_to_infrequent_mappings", "_infrequent_enabled", "_infrequent_indices", "_sklearn_output_config", "_sklearn_version", "categories", "categories_", "classes_", "cv", "encodings_", "feature_names_in_", "n_features_in_", "random_state", "shuffle", "smooth", "target_mean_", "target_type", "target_type_"], + "sklearn.random_projection.GaussianRandomProjection": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "components_", "compute_inverse_components", "eps", "feature_names_in_", "inverse_components_", "n_components", "n_components_", "n_features_in_", "random_state"], + "sklearn.random_projection.SparseRandomProjection": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "components_", "compute_inverse_components", "dense_output", "density", "density_", "eps", "feature_names_in_", "inverse_components_", "n_components", "n_components_", "n_features_in_", "random_state"], + "sklearn.semi_supervised._label_propagation.LabelPropagation": ["X_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "classes_", "feature_names_in_", "gamma", "kernel", "label_distributions_", "max_iter", "n_features_in_", "n_iter_", "n_jobs", "n_neighbors", "nn_fit", "tol", "transduction_"], + "sklearn.semi_supervised._label_propagation.LabelSpreading": ["X_", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "alpha", "classes_", "feature_names_in_", "gamma", "kernel", "label_distributions_", "max_iter", "n_features_in_", "n_iter_", "n_jobs", "n_neighbors", "nn_fit", "tol", "transduction_"], + "sklearn.semi_supervised._self_training.SelfTrainingClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "classes_", "criterion", "estimator", "estimator_", "feature_names_in_", "k_best", "labeled_iter_", "max_iter", "n_features_in_", "n_iter_", "termination_condition_", "threshold", "transduction_", "verbose"], + "sklearn.svm._classes.LinearSVC": ["C", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "class_weight", "classes_", "coef_", "dual", "feature_names_in_", "fit_intercept", "intercept_", "intercept_scaling", "loss", "max_iter", "multi_class", "n_features_in_", "n_iter_", "penalty", "random_state", "tol", "verbose"], + "sklearn.svm._classes.LinearSVR": ["C", "_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "coef_", "dual", "epsilon", "feature_names_in_", "fit_intercept", "intercept_", "intercept_scaling", "loss", "max_iter", "n_features_in_", "n_iter_", "random_state", "tol", "verbose"], + "sklearn.svm._classes.NuSVC": ["C", "_BaseLibSVM__Xfit", "_HTMLDocumentationLinkMixin__doc_link_template", "_dual_coef_", "_effective_probability", "_gamma", "_intercept_", "_n_support", "_num_iter", "_probA", "_probB", "_sklearn_version", "_sparse", "break_ties", "cache_size", "class_weight", "class_weight_", "classes_", "coef0", "decision_function_shape", "degree", "dual_coef_", "epsilon", "feature_names_in_", "fit_status_", "gamma", "intercept_", "kernel", "max_iter", "n_features_in_", "n_iter_", "nu", "probability", "random_state", "shape_fit_", "shrinking", "support_", "support_vectors_", "tol", "verbose"], + "sklearn.svm._classes.NuSVR": ["C", "_BaseLibSVM__Xfit", "_HTMLDocumentationLinkMixin__doc_link_template", "_dual_coef_", "_effective_probability", "_gamma", "_intercept_", "_n_support", "_num_iter", "_probA", "_probB", "_sklearn_version", "_sparse", "cache_size", "class_weight", "coef0", "degree", "dual_coef_", "epsilon", "feature_names_in_", "fit_status_", "gamma", "intercept_", "kernel", "max_iter", "n_features_in_", "n_iter_", "nu", "probability", "random_state", "shape_fit_", "shrinking", "support_", "support_vectors_", "tol", "verbose"], + "sklearn.svm._classes.OneClassSVM": ["C", "_BaseLibSVM__Xfit", "_HTMLDocumentationLinkMixin__doc_link_template", "_dual_coef_", "_effective_probability", "_gamma", "_intercept_", "_n_support", "_num_iter", "_probA", "_probB", "_sklearn_version", "_sparse", "cache_size", "class_weight", "coef0", "degree", "dual_coef_", "epsilon", "feature_names_in_", "fit_status_", "gamma", "intercept_", "kernel", "max_iter", "n_features_in_", "n_iter_", "nu", "offset_", "probability", "random_state", "shape_fit_", "shrinking", "support_", "support_vectors_", "tol", "verbose"], + "sklearn.svm._classes.SVC": ["C", "_BaseLibSVM__Xfit", "_HTMLDocumentationLinkMixin__doc_link_template", "_dual_coef_", "_effective_probability", "_gamma", "_intercept_", "_n_support", "_num_iter", "_probA", "_probB", "_sklearn_version", "_sparse", "break_ties", "cache_size", "class_weight", "class_weight_", "classes_", "coef0", "decision_function_shape", "degree", "dual_coef_", "epsilon", "feature_names_in_", "fit_status_", "gamma", "intercept_", "kernel", "max_iter", "n_features_in_", "n_iter_", "nu", "probability", "random_state", "shape_fit_", "shrinking", "support_", "support_vectors_", "tol", "verbose"], + "sklearn.svm._classes.SVR": ["C", "_BaseLibSVM__Xfit", "_HTMLDocumentationLinkMixin__doc_link_template", "_dual_coef_", "_effective_probability", "_gamma", "_intercept_", "_n_support", "_num_iter", "_probA", "_probB", "_sklearn_version", "_sparse", "cache_size", "class_weight", "coef0", "degree", "dual_coef_", "epsilon", "feature_names_in_", "fit_status_", "gamma", "intercept_", "kernel", "max_iter", "n_features_in_", "n_iter_", "nu", "probability", "random_state", "shape_fit_", "shrinking", "support_", "support_vectors_", "tol", "verbose"], + "sklearn.tree._classes.DecisionTreeClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "ccp_alpha", "class_weight", "classes_", "criterion", "feature_names_in_", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_features_in_", "n_outputs_", "random_state", "splitter", "tree_"], + "sklearn.tree._classes.DecisionTreeRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "ccp_alpha", "class_weight", "classes_", "criterion", "feature_names_in_", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_features_in_", "n_outputs_", "random_state", "splitter", "tree_"], + "sklearn.tree._classes.ExtraTreeClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "ccp_alpha", "class_weight", "classes_", "criterion", "feature_names_in_", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_features_in_", "n_outputs_", "random_state", "splitter", "tree_"], + "sklearn.tree._classes.ExtraTreeRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_sklearn_version", "ccp_alpha", "class_weight", "classes_", "criterion", "feature_names_in_", "max_depth", "max_features", "max_features_", "max_leaf_nodes", "min_impurity_decrease", "min_samples_leaf", "min_samples_split", "min_weight_fraction_leaf", "monotonic_cst", "n_classes_", "n_features_in_", "n_outputs_", "random_state", "splitter", "tree_"] +} diff --git a/src/mlbundle/_sklearn/structure.py b/src/mlbundle/_sklearn/structure.py index edd6f2c..4efff29 100644 --- a/src/mlbundle/_sklearn/structure.py +++ b/src/mlbundle/_sklearn/structure.py @@ -1,8 +1,10 @@ """Unmodified native structure, with tree index validation before native traversal.""" +import sys from typing import Any, cast import numpy as np +import skops.io as sio from pydantic import JsonValue from sklearn.base import BaseEstimator, is_classifier from sklearn.multioutput import RegressorChain @@ -13,6 +15,12 @@ from mlbundle.errors import InvalidModelError, UnsupportedModelError from mlbundle.limits import MAX_CLASSES, MAX_FEATURES, MAX_OUTPUTS, STRUCTURE_BYTES +from .consistency import validate_consistency +from .fitted import validate_fitted +from .labels import validate_classes as _validate_classes +from .payload import validate_payload +from .prediction_state import prediction_state + _ARRAYS = ( "children_left", "children_right", @@ -22,6 +30,7 @@ "impurity", "n_node_samples", "weighted_n_node_samples", + "missing_go_to_left", ) @@ -36,6 +45,8 @@ def validate_tree(tree: Tree) -> None: or np.any(tree.n_classes > MAX_CLASSES) or tree.value.shape != (count, tree.n_outputs, int(max(tree.n_classes))) or any(len(getattr(tree, name)) != count for name in _ARRAYS) + or np.any(tree.missing_go_to_left > 1) + or tree.capacity != count ): raise InvalidModelError("Invalid sklearn tree dimensions.") # Visit each node exactly once: reject cycles, aliases, unreachable nodes and @@ -69,6 +80,10 @@ def validate_tree_storage(model: object) -> None: continue seen.add(id(value)) if isinstance(value, Tree): + if type(value) is not Tree: + raise UnsupportedModelError( + reason="Only exact native Tree storage is supported." + ) validate_tree(value) elif isinstance(value, dict): pending.extend(value.values()) @@ -98,7 +113,10 @@ def _width(model: Any) -> int | None: def _validate_owner(model: Any) -> None: + _exact_class(model) + validate_consistency(model) width = _width(model) + validate_fitted(model, width) if hasattr(model, "tree_") and ( not isinstance(model.tree_, Tree) or model.tree_.n_features != width ): @@ -113,9 +131,7 @@ def _validate_owner(model: Any) -> None: raise InvalidModelError( "Sklearn tree and owning estimator output counts disagree." ) - if is_classifier(model) and ( - hasattr(model, "tree_") or hasattr(model, "estimators_") - ): + if is_classifier(model) and hasattr(model, "classes_"): _validate_classes(model) if width is None or not hasattr(model, "estimators_"): return @@ -143,30 +159,10 @@ def _validate_owner(model: Any) -> None: ) -def _validate_classes(model: Any) -> None: - classes = model.classes_ if isinstance(model.classes_, list) else [model.classes_] - if not classes or any(np.asarray(labels).ndim != 1 for labels in classes): - raise InvalidModelError("Invalid classifier label dimensions.") - counts = np.asarray([len(labels) for labels in classes]) - if hasattr(model, "n_outputs_") and model.n_outputs_ != len(classes): - raise InvalidModelError("Classifier labels and native output counts disagree.") - if hasattr(model, "n_classes_"): - native = np.asarray(model.n_classes_) - if ( - native.dtype.kind not in "iu" - or native.ndim != (0 if len(classes) == 1 else 1) - or not np.array_equal(np.atleast_1d(native), counts) - ): - raise InvalidModelError( - "Classifier labels and native class counts disagree." - ) - if hasattr(model, "tree_") and not np.array_equal(model.tree_.n_classes, counts): - raise InvalidModelError("Classifier labels and tree class counts disagree.") - - def _tree(tree: Tree) -> dict[str, object]: return { "node_count": tree.node_count, + "max_depth": tree.max_depth, "n_features": tree.n_features, "n_outputs": tree.n_outputs, "n_classes": tree.n_classes, @@ -208,5 +204,23 @@ def extract_structure(model: Any) -> dict[str, JsonValue] | None: return None result["family"] = "feature_importances" return cast( - dict[str, JsonValue], canonical_value(result, max_bytes=STRUCTURE_BYTES) + dict[str, JsonValue], + canonical_value( + { + **result, + "state_sha256": validate_payload(sio.dumps(model)), + "prediction_state": prediction_state(model), + }, + max_bytes=STRUCTURE_BYTES, + ), ) + + +def _exact_class(model: Any) -> None: + native_module = sys.modules.get(type(model).__module__) + if native_module is None or vars(native_module).get( + type(model).__name__ + ) is not type(model): + raise UnsupportedModelError( + reason="Only exact supported sklearn classes are admitted." + ) diff --git a/src/mlbundle/_xgboost/__init__.py b/src/mlbundle/_xgboost/__init__.py index ea9c05e..5c88cf8 100644 --- a/src/mlbundle/_xgboost/__init__.py +++ b/src/mlbundle/_xgboost/__init__.py @@ -26,6 +26,7 @@ validate_payload, ) from .prediction import Predictor +from .state import attributes from .structure import extract_structure CLASSES = {"XGBClassifier": xgb.XGBClassifier, "XGBRegressor": xgb.XGBRegressor} @@ -72,15 +73,39 @@ def _validate(self, model: Any) -> tuple[dict[str, Any], int | None]: raise UnsupportedModelError( reason="Only XGBClassifier and XGBRegressor are supported." ) + allowed = set(vars(type(model)())) | { + "_Booster", + "n_classes_", + "evals_result_", + "kwargs", + } + if set(vars(model)) - allowed: + raise InvalidModelError("Unknown XGBoost wrapper state.") if not model.__sklearn_is_fitted__(): raise UnsupportedModelError(reason="XGBoost estimator must be fitted.") if callable(model.objective): raise UnsupportedModelError( reason="Custom XGBoost objectives are unsupported." ) + attributes( + model.get_booster().attributes(), model.get_booster().num_boosted_rounds() + ) learner = configuration(model.get_booster()) kind, width, classes = dimensions(learner) tree_model(learner) + if model.objective not in ( + None, + learner["objective"]["name"], + ) or model.booster not in (None, "gbtree", "dart"): + raise InvalidModelError( + "XGBoost wrapper and native objective/booster disagree." + ) + if model.feature_types is not None and canonical_dumps( + model.feature_types + ) != canonical_dumps(model.get_booster().feature_types): + raise InvalidModelError( + "XGBoost wrapper and native feature types disagree." + ) if (type(model) is xgb.XGBClassifier) != (kind == ModelKind.CLASSIFIER): raise UnsupportedModelError( reason="XGBoost wrapper kind and native objective disagree." @@ -198,7 +223,14 @@ def extract_claims(self, model: Any) -> ModelClaims: kind=ModelKind.CLASSIFIER if classes is not None else ModelKind.REGRESSOR, - hyperparameters=parameters(learner, _settings(model)), + hyperparameters={ + **parameters(learner, _settings(model)), + "attributes": { + key: value + for key, value in attributes(booster.attributes()).items() + if key != ATTRIBUTE + }, + }, n_features=model.n_features_in_, n_classes=None if classes is None else [classes], n_outputs=1, diff --git a/src/mlbundle/_xgboost/arrays.py b/src/mlbundle/_xgboost/arrays.py new file mode 100644 index 0000000..2b9dce4 --- /dev/null +++ b/src/mlbundle/_xgboost/arrays.py @@ -0,0 +1,47 @@ +"""Native numeric views and tree index checks before XGBoost parsing.""" + +from typing import Any + +import numpy as np + +from mlbundle.errors import InvalidModelError + +from .ubjson import Buffer + + +def array(value: Any) -> np.ndarray: + if isinstance(value, Buffer): + formats = { + "i": "i1", + "U": "u1", + "I": ">i2", + "l": ">i4", + "L": ">i8", + "d": ">f4", + "D": ">f8", + } + return np.frombuffer(value.data, dtype=formats[value.marker]) + return np.asarray(value) + + +def tree_indices(tree: dict[str, Any], nodes: int, width: int) -> None: + left, right, features = ( + array(tree[key]) for key in ("left_children", "right_children", "split_indices") + ) + if any(array.dtype.kind not in "iu" for array in (left, right, features)): + raise InvalidModelError("Invalid XGBoost tree index types.") + seen = bytearray(nodes) + pending = [(0, 0)] + while pending: + node, depth = pending.pop() + if node < 0 or node >= nodes or seen[node] or depth > 14: + raise InvalidModelError("Invalid XGBoost tree reachability or depth.") + seen[node] = 1 + a, b = int(left[node]), int(right[node]) + if a == b == -1: + continue + if a < 0 or b < 0 or not 0 <= features[node] < width: + raise InvalidModelError("Invalid XGBoost tree child or feature indices.") + pending.extend(((a, depth + 1), (b, depth + 1))) + if not all(seen): + raise InvalidModelError("Unreachable XGBoost tree nodes.") diff --git a/src/mlbundle/_xgboost/categories.py b/src/mlbundle/_xgboost/categories.py new file mode 100644 index 0000000..f17f986 --- /dev/null +++ b/src/mlbundle/_xgboost/categories.py @@ -0,0 +1,109 @@ +"""Native categorical split bounds before XGBoost allocates split bitsets.""" + +from typing import Any + +import numpy as np + +from mlbundle.errors import InvalidModelError + +from .arrays import array as _array + +MAX_CATEGORY = 1_000_000 + + +def validate_encodings(cats: dict[str, Any], width: int) -> None: + encodings = cats["enc"] + segments, sorted_indices = ( + _array(cats[key]) for key in ("feature_segments", "sorted_idx") + ) + if not encodings: + if segments.size or sorted_indices.size: + raise InvalidModelError("Invalid XGBoost empty category encoding.") + return + if ( + len(encodings) != width + or segments.shape != (width + 1,) + or segments.dtype.kind not in "iu" + or sorted_indices.ndim != 1 + or sorted_indices.dtype.kind not in "iu" + or segments[0] != 0 + or segments[-1] != len(sorted_indices) + or np.any(segments[1:] < segments[:-1]) + ): + raise InvalidModelError("Invalid XGBoost category feature segments.") + for index, encoding in enumerate(encodings): + count = _encoding_count(encoding) + if count > MAX_CATEGORY + 1 or segments[index + 1] - segments[index] != count: + raise InvalidModelError("Invalid XGBoost category encoding dimensions.") + order = sorted_indices[int(segments[index]) : int(segments[index + 1])] + if not np.array_equal(np.sort(order), np.arange(count)): + raise InvalidModelError("Invalid XGBoost category sort indices.") + + +def validate_categories(tree: dict[str, Any], nodes: int) -> int: + default, split = (_array(tree[key]) for key in ("default_left", "split_type")) + if any( + value.dtype.kind not in "iu" or np.any(value > 1) or np.any(value < 0) + for value in (default, split) + ): + raise InvalidModelError("Invalid XGBoost default direction or split type.") + categories, indices, segments, sizes = ( + _array(tree[key]) + for key in ( + "categories", + "categories_nodes", + "categories_segments", + "categories_sizes", + ) + ) + if any( + value.ndim != 1 or (value.size and value.dtype.kind not in "iu") + for value in (categories, indices, segments, sizes) + ): + raise InvalidModelError("Invalid XGBoost categorical array types.") + if ( + indices.shape != segments.shape + or indices.shape != sizes.shape + or len(set(indices.tolist())) != len(indices) + or np.any(indices < 0) + or np.any(indices >= nodes) + or np.any(categories < 0) + or np.any(categories > MAX_CATEGORY) + or not np.array_equal(np.sort(indices), np.flatnonzero(split)) + ): + raise InvalidModelError("Invalid XGBoost categorical node storage.") + end = storage = 0 + for start, size in zip(segments, sizes, strict=True): + if start != end or size < 1 or size > len(categories) - end: + raise InvalidModelError("Invalid XGBoost categorical segments.") + stop = end + int(size) + storage += (int(max(categories[end:stop])) // 32 + 1) * 4 + end = stop + if end != len(categories): + raise InvalidModelError("Unreferenced XGBoost categorical values.") + return storage + + +def _encoding_count(encoding: dict[str, Any]) -> int: + values = _array(encoding["values"]) + if values.ndim != 1 or (values.size and values.dtype.kind not in "biuf"): + raise InvalidModelError("Invalid XGBoost category encoding values.") + count = len(values) + if "offsets" in encoding: + offsets = _array(encoding["offsets"]) + if offsets.size: + if ( + offsets.ndim != 1 + or offsets.dtype.kind not in "iu" + or offsets[0] != 0 + or offsets[-1] != len(values) + or np.any(offsets[1:] < offsets[:-1]) + or values.dtype.kind not in "iu" + or np.any(values < 0) + or np.any(values > 255) + ): + raise InvalidModelError("Invalid XGBoost category string offsets.") + elif values.size: + raise InvalidModelError("Invalid XGBoost empty category string offsets.") + count = max(0, len(offsets) - 1) + return count diff --git a/src/mlbundle/_xgboost/native.py b/src/mlbundle/_xgboost/native.py index 1417e97..5db8f0e 100644 --- a/src/mlbundle/_xgboost/native.py +++ b/src/mlbundle/_xgboost/native.py @@ -9,7 +9,10 @@ from mlbundle.errors import InvalidModelError, UnsupportedModelError from mlbundle.limits import MAX_CLASSES, MAX_FEATURES, ArchiveLimits -from .ubjson import Buffer, decode +from .arrays import array as _array, tree_indices as _tree_indices +from .categories import validate_categories, validate_encodings +from .state import attributes, native_keys +from .ubjson import decode ATTRIBUTE = "mlbundle" MAX_TREES = 100_000 @@ -42,6 +45,12 @@ def dimensions(learner: dict[str, Any]) -> tuple[ModelKind, int, int | None]: reason="XGBoost requires bounded features/classes and a single target." ) objective = learner["objective"]["name"] + if ( + objective == "multi:softprob" + and int(learner["objective"]["softmax_multiclass_param"]["num_class"]) + != classes + ): + raise InvalidModelError("XGBoost objective and learner class counts disagree.") if objective in CLASSIFIERS: if (objective == "binary:logistic" and classes != 0) or ( objective == "multi:softprob" and classes < 2 @@ -90,9 +99,14 @@ def validate_payload(data: bytes) -> None: raise InvalidModelError("XGBoost payload exceeds the byte limit.") if not data.startswith(b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{"): raise InvalidModelError("Expected a native XGBoost UBJSON model.") - learner = decode(data)["learner"] + document = decode(data) + native_keys(document) + learner = document["learner"] dimensions(learner) model = tree_model(learner)["model"] + validate_encodings( + model["cats"], int(learner["learner_model_param"]["num_feature"]) + ) count = int(model["gbtree_model_param"]["num_trees"]) trees = model["trees"] if ( @@ -101,25 +115,8 @@ def validate_payload(data: bytes) -> None: or len(trees) != count ): raise InvalidModelError("Invalid or oversized XGBoost tree count.") - groups = _array(model["tree_info"]) - group_count = max(1, int(learner["learner_model_param"]["num_class"])) - if ( - groups.shape != (count,) - or groups.dtype.kind not in "iu" - or np.any(groups < 0) - or np.any(groups >= group_count) - ): - raise InvalidModelError("Invalid XGBoost tree output-group indices.") - pointers = _array(model["iteration_indptr"]) - if ( - pointers.ndim != 1 - or not 2 <= len(pointers) <= count + 1 - or pointers.dtype.kind not in "iu" - or pointers[0] != 0 - or pointers[-1] != count - or np.any(pointers[1:] <= pointers[:-1]) - ): - raise InvalidModelError("Invalid XGBoost iteration pointers.") + _iterations(model, count, learner) + categorical_bytes = 0 for tree in trees: nodes = int(tree["tree_param"]["num_nodes"]) if ( @@ -140,48 +137,53 @@ def validate_payload(data: bytes) -> None: "loss_changes", "sum_hessian", "base_weights", + "split_type", ): values = tree[key] length = len(values) if isinstance(values, list) else values.count if length != nodes: raise InvalidModelError("Invalid XGBoost node array dimensions.") _tree_indices(tree, nodes, int(learner["learner_model_param"]["num_feature"])) + categorical_bytes += validate_categories(tree, nodes) + if categorical_bytes > ArchiveLimits().max_member_bytes: + raise InvalidModelError( + "XGBoost categorical bitset bytes exceed the limit." + ) -def _array(value: Any) -> np.ndarray: - if isinstance(value, Buffer): - formats = { - "i": "i1", - "U": "u1", - "I": ">i2", - "l": ">i4", - "L": ">i8", - "d": ">f4", - "D": ">f8", - } - return np.frombuffer(value.data, dtype=formats[value.marker]) - return np.asarray(value) - - -def _tree_indices(tree: dict[str, Any], nodes: int, width: int) -> None: - left, right, features = ( - _array(tree[key]) - for key in ("left_children", "right_children", "split_indices") - ) - if any(array.dtype.kind not in "iu" for array in (left, right, features)): - raise InvalidModelError("Invalid XGBoost tree index types.") - seen = bytearray(nodes) - pending = [(0, 0)] - while pending: - node, depth = pending.pop() - if node < 0 or node >= nodes or seen[node] or depth > 14: - raise InvalidModelError("Invalid XGBoost tree reachability or depth.") - seen[node] = 1 - a, b = int(left[node]), int(right[node]) - if a == b == -1: - continue - if a < 0 or b < 0 or not 0 <= features[node] < width: - raise InvalidModelError("Invalid XGBoost tree child or feature indices.") - pending.extend(((a, depth + 1), (b, depth + 1))) - if not all(seen): - raise InvalidModelError("Unreachable XGBoost tree nodes.") +def _iterations(model: dict[str, Any], count: int, learner: dict[str, Any]) -> None: + groups = _array(model["tree_info"]) + group_count = max(1, int(learner["learner_model_param"]["num_class"])) + if ( + groups.shape != (count,) + or groups.dtype.kind not in "iu" + or np.any(groups < 0) + or np.any(groups >= group_count) + ): + raise InvalidModelError("Invalid XGBoost tree output-group indices.") + pointers = _array(model["iteration_indptr"]) + if ( + pointers.ndim != 1 + or not 2 <= len(pointers) <= count + 1 + or pointers.dtype.kind not in "iu" + or pointers[0] != 0 + or pointers[-1] != count + or np.any(pointers[1:] <= pointers[:-1]) + ): + raise InvalidModelError("Invalid XGBoost iteration pointers.") + parallel = int(model["gbtree_model_param"]["num_parallel_tree"]) + if not 1 <= parallel <= MAX_TREES or np.any( + np.diff(pointers) != parallel * group_count + ): + raise InvalidModelError( + "Invalid XGBoost parallel-tree or iteration dimensions." + ) + attributes(learner["attributes"], len(pointers) - 1) + weights = _array(model.get("weight_drop", [])) + if ( + weights.shape not in ((0,), (count,)) + or weights.dtype.kind not in "iuf" + or not np.isfinite(weights).all() + or np.any(weights < 0) + ): + raise InvalidModelError("Invalid XGBoost dropout weights.") diff --git a/src/mlbundle/_xgboost/prediction.py b/src/mlbundle/_xgboost/prediction.py index 38b094e..664bdec 100644 --- a/src/mlbundle/_xgboost/prediction.py +++ b/src/mlbundle/_xgboost/prediction.py @@ -4,10 +4,11 @@ import numpy as np +from mlbundle._prediction_input import tabular from mlbundle.adapters import ProbabilityOutput, RegressionOutput from mlbundle.claims import ModelClaims from mlbundle.contract import ModelKind -from mlbundle.errors import InvalidModelError +from mlbundle.errors import InvalidModelError, MLBundleError class Predictor: @@ -17,25 +18,17 @@ def __init__(self, model: Any, claims: ModelClaims) -> None: def _call(self, data: object, *, probabilities: bool) -> tuple[np.ndarray, int]: try: - shape = getattr(data, "shape", None) - if shape is None: - data = np.asarray(data) - shape = data.shape - if len(shape) != 2 or shape[1] != self.claims.n_features: - raise ValueError( - "Prediction input must have the model's feature width." - ) - rows = int(shape[0]) + data, rows = tabular( + data, self.claims.n_features, categorical=self.model.enable_categorical + ) raw = ( self.model.predict_proba(data) if probabilities else self.model.predict(data) ) return np.asarray(raw, dtype=float), rows - except ValueError as error: - if str(error) == "Prediction input must have the model's feature width.": - raise - raise InvalidModelError("Invalid XGBoost prediction input.") from None + except MLBundleError: + raise except Exception: raise InvalidModelError("XGBoost prediction failed.") from None diff --git a/src/mlbundle/_xgboost/state.py b/src/mlbundle/_xgboost/state.py new file mode 100644 index 0000000..cb2e0e4 --- /dev/null +++ b/src/mlbundle/_xgboost/state.py @@ -0,0 +1,147 @@ +"""Closed booster attributes and complete native prediction-state projections.""" + +import math +from typing import Any + +import numpy as np + +from mlbundle.canonical import canonical_loads +from mlbundle.errors import InvalidModelError + +from .ubjson import Buffer + +ATTRIBUTES = frozenset({"mlbundle", "best_iteration", "best_score", "scikit_learn"}) +TREE_FIELDS = frozenset( + { + "base_weights", + "categories", + "categories_nodes", + "categories_segments", + "categories_sizes", + "default_left", + "id", + "left_children", + "loss_changes", + "parents", + "right_children", + "split_conditions", + "split_indices", + "split_type", + "sum_hessian", + "tree_param", + } +) +OBJECTIVE_FIELDS = { + "binary:logistic": ("reg_loss_param", "scale_pos_weight"), + "multi:softprob": ("softmax_multiclass_param", "num_class"), + "reg:squarederror": ("reg_loss_param", "scale_pos_weight"), + "reg:gamma": ("reg_loss_param", "scale_pos_weight"), + "reg:logistic": ("reg_loss_param", "scale_pos_weight"), + "reg:absoluteerror": None, + "reg:squaredlogerror": None, + "reg:pseudohubererror": ("pseudo_huber_param", "huber_slope"), + "reg:tweedie": ("tweedie_regression_param", "tweedie_variance_power"), + "count:poisson": ("poisson_regression_param", "max_delta_step"), +} + + +def keys(value: Any, allowed: set[str] | frozenset[str]) -> None: + if not isinstance(value, dict) or set(value) != allowed: + raise InvalidModelError("Unknown or missing XGBoost native state.") + + +def attributes(value: Any, rounds: int | None = None) -> dict[str, str]: + if ( + not isinstance(value, dict) + or set(value) - ATTRIBUTES + or any(type(item) is not str for item in value.values()) + ): + raise InvalidModelError("Unknown XGBoost booster attribute.") + if "best_iteration" in value: + text = value["best_iteration"] + if ( + not text.isascii() + or not text.isdecimal() + or len(text) > 6 + or (rounds is not None and not 0 <= int(text) < rounds) + ): + raise InvalidModelError("Invalid XGBoost best iteration.") + if "best_score" in value and ( + len(value["best_score"]) > 256 or not math.isfinite(float(value["best_score"])) + ): + raise InvalidModelError("Invalid XGBoost best score.") + if "scikit_learn" in value: + record = canonical_loads(value["scikit_learn"].encode()) + if ( + not isinstance(record, dict) + or set(record) != {"_estimator_type"} + or record["_estimator_type"] not in ("classifier", "regressor") + ): + raise InvalidModelError("Invalid XGBoost sklearn identity attribute.") + return value + + +def native_keys(document: dict[str, Any]) -> None: + keys(document, {"learner", "version"}) + learner = document["learner"] + keys( + learner, + { + "attributes", + "feature_names", + "feature_types", + "gradient_booster", + "learner_model_param", + "objective", + }, + ) + keys( + learner["learner_model_param"], + {"base_score", "boost_from_average", "num_class", "num_feature", "num_target"}, + ) + objective = learner["objective"] + if isinstance(objective, dict) and objective.get("name") in OBJECTIVE_FIELDS: + fields = OBJECTIVE_FIELDS[objective["name"]] + keys(objective, {"name"} | ({fields[0]} if fields else set())) + if fields: + keys(objective[fields[0]], {fields[1]}) + booster = learner["gradient_booster"] + keys(booster, {"name", "model"}) + native = booster["model"] + keys( + native, + {"cats", "gbtree_model_param", "iteration_indptr", "tree_info", "trees"} + | ({"weight_drop"} if "weight_drop" in native else set()), + ) + keys(native["gbtree_model_param"], {"num_parallel_tree", "num_trees"}) + keys(native["cats"], {"enc", "feature_segments", "sorted_idx"}) + for encoding in native["cats"]["enc"]: + if set(encoding) == {"offsets", "values"}: + keys(encoding, {"offsets", "values"}) + else: + keys(encoding, {"type", "values"}) + for tree in native["trees"]: + keys(tree, TREE_FIELDS) + keys( + tree["tree_param"], + {"num_deleted", "num_feature", "num_nodes", "size_leaf_vector"}, + ) + + +def projection(value: Any) -> Any: + if isinstance(value, Buffer): + formats = { + "i": "i1", + "U": "u1", + "I": ">i2", + "l": ">i4", + "L": ">i8", + "d": ">f4", + "D": ">f8", + } + return np.frombuffer(value.data, dtype=formats[value.marker]) + if isinstance(value, dict): + return {key: projection(child) for key, child in value.items()} + if isinstance(value, list): + return [projection(child) for child in value] + return value diff --git a/src/mlbundle/_xgboost/structure.py b/src/mlbundle/_xgboost/structure.py index 0fba759..566c20c 100644 --- a/src/mlbundle/_xgboost/structure.py +++ b/src/mlbundle/_xgboost/structure.py @@ -8,7 +8,9 @@ from mlbundle.errors import LimitExceededError from mlbundle.limits import STRUCTURE_BYTES -from .native import MAX_TREES, configuration, tree_model +from .native import MAX_TREES, configuration, tree_model, validate_payload +from .state import projection +from .ubjson import decode def extract_structure(model: Any) -> dict[str, JsonValue]: @@ -17,6 +19,9 @@ def extract_structure(model: Any) -> dict[str, JsonValue]: count = int(native["gbtree_model_param"]["num_trees"]) if not 1 <= count <= MAX_TREES: raise LimitExceededError("XGBoost tree count exceeds the limit.") + data = bytes(booster.save_raw(raw_format="ubj")) + validate_payload(data) + native_model = decode(data)["learner"]["gradient_booster"]["model"] dumps = booster.get_dump(dump_format="json", with_stats=True) if ( len(dumps) != count @@ -28,6 +33,7 @@ def extract_structure(model: Any) -> dict[str, JsonValue]: canonical_value( { "family": "xgboost", + "native_model": projection(native_model), "trees": [ canonical_loads(tree.encode(), max_bytes=STRUCTURE_BYTES) for tree in dumps diff --git a/src/mlbundle/_xgboost/ubjson.py b/src/mlbundle/_xgboost/ubjson.py index 3c63a83..0f3f6b3 100644 --- a/src/mlbundle/_xgboost/ubjson.py +++ b/src/mlbundle/_xgboost/ubjson.py @@ -20,6 +20,7 @@ class Decoder: def __init__(self, data: bytes) -> None: self.data = memoryview(data) self.position = 0 + self.values = 0 def take(self, count: int) -> memoryview: if count < 0 or count > len(self.data) - self.position: @@ -45,6 +46,9 @@ def string(self) -> str: return bytes(self.take(self.integer())).decode("utf-8") def value(self, depth: int = 0, marker: str | None = None) -> Any: + self.values += 1 + if self.values > 1_000_000: + raise InvalidModelError("UBJSON value count exceeds the limit.") if depth > 32: raise InvalidModelError("UBJSON nesting exceeds the limit.") marker = self.marker() if marker is None else marker diff --git a/src/mlbundle/canonical.py b/src/mlbundle/canonical.py index e5ad881..638037f 100644 --- a/src/mlbundle/canonical.py +++ b/src/mlbundle/canonical.py @@ -6,6 +6,7 @@ from pydantic import JsonValue +from ._canonical_work import bound_work from .errors import InvalidJSONError, LimitExceededError from .limits import JSON_DEPTH, MANIFEST_BYTES @@ -65,6 +66,7 @@ def _normalize(value: object, depth: int, max_depth: int) -> JsonValue: def canonical_dumps( value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH ) -> bytes: + bound_work(value, max_bytes, max_depth) try: encoded = json.dumps( _normalize(value, 0, max_depth), diff --git a/src/mlbundle/claims.py b/src/mlbundle/claims.py index a5de810..9511888 100644 --- a/src/mlbundle/claims.py +++ b/src/mlbundle/claims.py @@ -39,6 +39,7 @@ class EstimatorInfo(ContractModel): n_features: int = Field(gt=0, le=MAX_FEATURES) n_classes: ClassCounts | None n_outputs: int = Field(gt=0, le=MAX_OUTPUTS) + state_sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] | None = None @field_validator("hyperparameters") @classmethod @@ -63,6 +64,10 @@ def validate_labels(info: EstimatorInfo, labels: ClassLabels | None) -> None: return if labels is None or info.n_classes != [len(output) for output in labels]: raise ValueError("Class labels must match the output class counts.") + validate_label_sets(labels) + + +def validate_label_sets(labels: ClassLabels) -> None: for output in labels: encoded = [canonical_dumps(label) for label in output] if len(encoded) != len(set(encoded)): diff --git a/src/mlbundle/errors.py b/src/mlbundle/errors.py index 5208727..5d312c7 100644 --- a/src/mlbundle/errors.py +++ b/src/mlbundle/errors.py @@ -66,6 +66,10 @@ class InvalidModelError(MLBundleError): """Native model content is malformed or violates the adapter contract.""" +class PredictionInputError(InvalidModelError, ValueError): + """Bounded invalid-input error retaining the shape-error ValueError contract.""" + + class InvalidSchemaError(MLBundleError): """Training inputs or schema declarations violate the schema contract.""" diff --git a/tests/conftest.py b/tests/conftest.py index fc8dccc..506b20a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,7 @@ def manifest_data() -> dict: "n_features": 2, "n_classes": [2], "n_outputs": 1, + "state_sha256": None, }, "features": { "names": ["a", "b"], diff --git a/tests/sklearn_helpers.py b/tests/sklearn_helpers.py new file mode 100644 index 0000000..95c892a --- /dev/null +++ b/tests/sklearn_helpers.py @@ -0,0 +1,37 @@ +"""Bundle and skops-schema rewrites for sklearn validation features.""" + +import io +import json +from zipfile import ZipFile + +from mlbundle import MemberName +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +def rewrite(path, payload): + manifest, members = read_container(path) + del members[MemberName.MANIFEST] + members[MemberName.SKOPS] = payload + return write_container(manifest, members) + + +def unpack(data): + with ZipFile(io.BytesIO(data)) as archive: + return {name: archive.read(name) for name in archive.namelist()} + + +def pack(entries): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + for name, value in entries.items(): + archive.writestr(name, value) + return buffer.getvalue() + + +def alter_schema(data, change): + entries = unpack(data) + schema = json.loads(entries["schema.json"]) + change(schema, entries) + entries["schema.json"] = json.dumps(schema).encode() + return pack(entries) diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 190fdf4..5ea5297 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -145,3 +145,23 @@ def test_tag_depth_and_malformed_unicode_escapes(): canonical_dumps([math.inf], max_depth=1) with pytest.raises(InvalidJSONError): canonical_loads(b'"\\uXXXX"') + + +def test_shared_json_expansion_is_bounded_before_normalization(monkeypatch): + from mlbundle import canonical + + value = [0] + for _ in range(25): + value = [value, value] + monkeypatch.setattr( + canonical, + "_normalize", + lambda *args: pytest.fail("Expansion must be rejected before normalization"), + ) + with pytest.raises(LimitExceededError, match="Expanded JSON"): + canonical_dumps(value, max_bytes=1024) + + +def test_shared_json_within_budget_is_preserved(): + child = [1, 2] + assert canonical_dumps([child, child]) == b"[[1,2],[1,2]]" diff --git a/tests/test_errors.py b/tests/test_errors.py index 886f185..415b894 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -13,6 +13,7 @@ ("UnsupportedFormatVersionError", ()), ("InvalidManifestError", ()), ("InvalidModelError", ("Invalid native model.",)), + ("PredictionInputError", ("Invalid input width.",)), ("InvalidSchemaError", ("Invalid schema.",)), ("InvalidSamplesError", ("Invalid samples.",)), ("InvalidJSONError", ()), diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 74f86ab..7b03b64 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -65,6 +65,8 @@ def test_unknown_fields(manifest_data, section): ("library", "pytorch"), ("serialization", "unknown"), ("serialization", "onnx"), + ("state_sha256", "short"), + ("state_sha256", True), ], ) def test_bad_model_fields(manifest_data, field, value): diff --git a/tests/test_prediction_errors.py b/tests/test_prediction_errors.py new file mode 100644 index 0000000..10cb7ae --- /dev/null +++ b/tests/test_prediction_errors.py @@ -0,0 +1,84 @@ +"""All adapters bound invalid inputs and remaining native prediction failures.""" + +from types import SimpleNamespace + +import pytest +import xgboost as xgb +from sklearn.linear_model import LinearRegression, LogisticRegression + +import mlbundle +from mlbundle import MLBundleError +from mlbundle._onnx import Adapter as OnnxAdapter +from mlbundle._sklearn import Adapter as SklearnAdapter +from mlbundle._xgboost import Adapter as XgbAdapter +from onnx_helpers import classifier, regressor + + +@pytest.fixture(params=["sklearn", "xgboost", "onnx"]) +def predictors(request): + x = [[0, 0], [1, 0], [0, 1], [1, 1]] + y = [0, 0, 1, 1] + if request.param == "sklearn": + adapter = SklearnAdapter() + return adapter.predictor(LinearRegression().fit(x, y)), adapter.predictor( + LogisticRegression().fit(x, y) + ) + if request.param == "xgboost": + adapter = XgbAdapter() + return adapter.predictor( + xgb.XGBRegressor(n_estimators=2, n_jobs=1).fit(x, y) + ), adapter.predictor(xgb.XGBClassifier(n_estimators=2, n_jobs=1).fit(x, y)) + adapter = OnnxAdapter() + return adapter.predictor( + adapter.deserialize(regressor().SerializeToString()) + ), adapter.predictor(adapter.deserialize(classifier().SerializeToString())) + + +@pytest.mark.parametrize( + "data", + [ + [["private-cell", 0]], + [[float("inf"), 0]], + [[float("-inf"), 0]], + [[1e1000, 0]], + [[0]], + [0, 0], + [[0, 0], [0]], + [[complex(1), 0]], + ], +) +def test_prediction_input_errors_are_bounded(data, predictors): + regression, classification = predictors + for method in [regression.predict, classification.predict_proba]: + with pytest.raises(MLBundleError) as error: + method(data) + assert len(str(error.value)) < 160 + assert "private-cell" not in str(error.value) + + +def test_native_prediction_errors_are_bounded(predictors, monkeypatch): + def fail(*args, **kwargs): + raise ValueError("private-cell " + "x" * 10000) + + regression, classification = predictors + for predictor, name in [(regression, "predict"), (classification, "predict_proba")]: + if hasattr(predictor, "session"): + predictor.session = SimpleNamespace(run=fail) + else: + monkeypatch.setattr(predictor.model, name, fail) + with pytest.raises(MLBundleError) as error: + getattr(predictor, name)([[0, 0]]) + assert "private-cell" not in str(error.value) and len(str(error.value)) < 160 + + +def test_sklearn_name_errors_do_not_echo_names(tmp_path): + import pandas as pd + + path = tmp_path / "named.mlbundle" + model = LogisticRegression().fit( + pd.DataFrame([[0], [1]], columns=["private-feature"]), [0, 1] + ) + mlbundle.export(model, path) + with pytest.raises(MLBundleError) as error: + mlbundle.load(path).predict_proba(pd.DataFrame([[0]], columns=["private-cell"])) + assert "private" not in str(error.value) diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 0bf16de..4c4b39b 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -247,42 +247,6 @@ def test_prediction_validation(invalid, monkeypatch): prediction.predict_proba([[0]]) -def test_parameter_projection(tmp_path): - from sklearn.pipeline import Pipeline - from sklearn.preprocessing import FunctionTransformer - - from mlbundle._sklearn.parameters import _parameter - - state = np.random.RandomState(7) - model = DecisionTreeClassifier(random_state=state).fit([[0], [1]], [0, 1]) - path = tmp_path / "state.mlbundle" - mlbundle.export(model, path) - assert ( - "$random_state" - in mlbundle.load(path).manifest.model.hyperparameters["random_state"] - ) - assert _parameter((np.float32(1.5), np.array([1, 2]), None), frozenset()) == [ - 1.5, - [1, 2], - None, - ] - for value in [float, lambda x: x, {1: "bad"}]: - with pytest.raises(UnsupportedModelError): - _parameter(value, frozenset()) - cyclic = [] - cyclic.append(cyclic) - with pytest.raises(UnsupportedModelError, match="Cyclic"): - _parameter(cyclic, frozenset()) - model = Pipeline( - [("identity", FunctionTransformer()), ("tree", DecisionTreeClassifier())] - ).fit([[0], [1]], [0, 1]) - path = tmp_path / "pipeline.mlbundle" - mlbundle.export(model, path) - params = mlbundle.load(path).manifest.model.hyperparameters - assert params["tree"]["$estimator"]["class"] == "DecisionTreeClassifier" - assert "tree__max_depth" in params - - @pytest.mark.parametrize("model", [DecisionTreeClassifier(), DecisionTreeRegressor()]) def test_sparse_predictions(model): from scipy.sparse import csr_matrix diff --git a/tests/test_sklearn_families.py b/tests/test_sklearn_families.py new file mode 100644 index 0000000..3ad1eef --- /dev/null +++ b/tests/test_sklearn_families.py @@ -0,0 +1,146 @@ +"""Other previously admitted prediction families retain legitimate round trips.""" + +import numpy as np +import pytest +from sklearn.cross_decomposition import PLSRegression +from sklearn.discriminant_analysis import ( + LinearDiscriminantAnalysis, + QuadraticDiscriminantAnalysis, +) +from sklearn.ensemble import ( + AdaBoostClassifier, + AdaBoostRegressor, + BaggingClassifier, + BaggingRegressor, +) +from sklearn.linear_model import ( + ARDRegression, + BayesianRidge, + ElasticNet, + GammaRegressor, + HuberRegressor, + Lasso, + PoissonRegressor, + Ridge, + SGDClassifier, + SGDRegressor, +) +from sklearn.multioutput import RegressorChain +from sklearn.naive_bayes import ( + BernoulliNB, + CategoricalNB, + ComplementNB, + GaussianNB, + MultinomialNB, +) +from sklearn.neighbors import KNeighborsClassifier, KNeighborsRegressor, NearestCentroid +from sklearn.neural_network import MLPClassifier, MLPRegressor +from sklearn.svm import SVC, SVR, LinearSVR, NuSVR +from sklearn.tree import ExtraTreeClassifier, ExtraTreeRegressor + +import mlbundle + +pytestmark = [ + pytest.mark.filterwarnings("ignore:The `probability` parameter:FutureWarning"), + pytest.mark.filterwarnings("ignore:.*:sklearn.exceptions.ConvergenceWarning"), +] + + +@pytest.mark.parametrize( + "model", + [ + ExtraTreeClassifier(random_state=0), + ExtraTreeRegressor(random_state=0), + AdaBoostClassifier(n_estimators=3, random_state=0), + AdaBoostRegressor(n_estimators=3, random_state=0), + BaggingClassifier(n_estimators=3, random_state=0), + BaggingRegressor(n_estimators=3, random_state=0), + BayesianRidge(), + ARDRegression(), + ElasticNet(), + Lasso(), + HuberRegressor(), + PoissonRegressor(), + GammaRegressor(), + SGDClassifier(loss="log_loss", random_state=0), + SGDRegressor(random_state=0), + LinearSVR(random_state=0), + SVR(), + NuSVR(), + SVC(kernel="linear", probability=True, random_state=0), + PLSRegression(n_components=1), + GaussianNB(), + MultinomialNB(), + ComplementNB(), + BernoulliNB(), + CategoricalNB(), + KNeighborsClassifier(algorithm="brute", n_neighbors=2), + KNeighborsRegressor(algorithm="brute", n_neighbors=2), + NearestCentroid(), + LinearDiscriminantAnalysis(), + QuadraticDiscriminantAnalysis(reg_param=0.1), + MLPClassifier( + hidden_layer_sizes=(3,), solver="lbfgs", random_state=0, max_iter=300 + ), + MLPRegressor( + hidden_layer_sizes=(3,), solver="lbfgs", random_state=0, max_iter=300 + ), + ], +) +def test_additional_family_roundtrip(model, tmp_path): + x = np.asarray( + [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0], [0, 2], [2, 2], [1, 2]], dtype=float + ) + y = np.asarray([0, 0, 1, 1, 1, 0, 1, 0]) + from sklearn.base import is_classifier + + target = y if is_classifier(model) else y + 0.1 + model.fit(x, target) + path = tmp_path / "family.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + method = "predict_proba" if is_classifier(model) else "predict" + expected = np.asarray(getattr(model, method)(x)).squeeze() + assert np.array_equal(np.asarray(getattr(loaded, method)(x)).squeeze(), expected) + assert ( + loaded.structure is not None or loaded.manifest.model.state_sha256 is not None + ) + + +def test_regressor_chain_roundtrip(tmp_path): + x = np.asarray([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float) + y = np.column_stack([x[:, 0], x[:, 1]]) + model = RegressorChain(Ridge()).fit(x, y) + path = tmp_path / "chain.mlbundle" + mlbundle.export(model, path) + assert np.array_equal(mlbundle.load(path).predict(x), model.predict(x)) + + +def test_sparse_fitted_neighbor_state_remains_outside_trust_boundary(tmp_path): + from scipy.sparse import csr_matrix + + x = csr_matrix([[0, 0], [1, 0], [0, 1], [1, 1]]) + model = KNeighborsRegressor(algorithm="brute", n_neighbors=1).fit(x, [0, 1, 2, 3]) + path = tmp_path / "sparse.mlbundle" + with pytest.raises(mlbundle.UnsupportedModelError, match="csr_matrix"): + mlbundle.export(model, path) + + +def test_fitted_categorical_pipeline_roundtrip(tmp_path): + from sklearn.linear_model import LogisticRegression + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import OneHotEncoder + + rows = [["a"], ["b"], ["a"], ["b"]] + model = Pipeline( + [ + ("encode", OneHotEncoder(sparse_output=False, dtype="float64")), + ("model", LogisticRegression()), + ] + ).fit(rows, [0, 1, 0, 1]) + path = tmp_path / "categories.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + with pytest.raises(mlbundle.MLBundleError): + loaded.predict_proba([[float("inf")]]) diff --git a/tests/test_sklearn_fitted_state.py b/tests/test_sklearn_fitted_state.py new file mode 100644 index 0000000..69b1908 --- /dev/null +++ b/tests/test_sklearn_fitted_state.py @@ -0,0 +1,131 @@ +"""Fitted counts, label mappings, architecture and class identity are consistent.""" + +import numpy as np +import pytest +import skops.io as sio +from sklearn.dummy import DummyClassifier, DummyRegressor +from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor +from sklearn.naive_bayes import GaussianNB +from sklearn.neighbors import KNeighborsRegressor +from sklearn.neural_network import MLPClassifier, MLPRegressor +from sklearn.svm import SVC +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import InvalidModelError, UnsupportedModelError +from mlbundle._sklearn import Adapter + +pytestmark = [ + pytest.mark.filterwarnings("ignore:The `probability` parameter:FutureWarning"), + pytest.mark.filterwarnings("ignore:.*:sklearn.exceptions.ConvergenceWarning"), +] + + +@pytest.mark.parametrize( + "model", + [ + MLPClassifier(hidden_layer_sizes=[3], solver="lbfgs", random_state=0), + MLPRegressor(hidden_layer_sizes=[3, 2], solver="lbfgs", random_state=0), + ], +) +def test_fitted_mlp_architecture_roundtrip(model, tmp_path): + x = [[0, 0], [1, 0], [0, 1], [1, 1], [2, 1], [1, 2]] + model.fit(x, [0, 0, 1, 1, 2, 2]) + path = tmp_path / "mlp.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + method = "predict_proba" if isinstance(model, MLPClassifier) else "predict" + assert np.array_equal(getattr(loaded, method)(x), getattr(model, method)(x)) + + +def test_mlp_public_labels_must_match_prediction_labels(): + model = MLPClassifier(solver="lbfgs", hidden_layer_sizes=(2,), random_state=0).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + model.classes_ = model.classes_[::-1].copy() + with pytest.raises(InvalidModelError, match="binarizer"): + Adapter().deserialize(sio.dumps(model)) + + +@pytest.mark.parametrize( + "attribute,value", + [("n_layers_", 1000000000), ("out_activation_", "identity"), ("n_outputs_", 2)], +) +def test_mlp_fitted_output_state_is_consistent(attribute, value): + model = MLPClassifier(solver="lbfgs", hidden_layer_sizes=(2,), random_state=0).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + setattr(model, attribute, value) + with pytest.raises(InvalidModelError, match="MLP"): + Adapter().deserialize(sio.dumps(model)) + + +def test_neighbor_sample_count_cannot_drive_work_from_a_declaration(): + model = KNeighborsRegressor(algorithm="brute", n_neighbors=1).fit( + [[0], [1]], [0, 1] + ) + model.n_samples_fit_ = 1000000000 + with pytest.raises(InvalidModelError, match="Neighbor fitted dimensions"): + Adapter().deserialize(sio.dumps(model)) + + +def test_classifier_labels_cannot_overstate_fitted_statistics(): + model = GaussianNB().fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + model.classes_ = np.array([0, 1, 2]) + with pytest.raises(InvalidModelError, match="statistics"): + Adapter().deserialize(sio.dumps(model)) + + +@pytest.mark.parametrize( + "attribute,value", + [ + ("_n_support", np.array([-1, 1], dtype=np.int32)), + ("support_", np.array([1000000000] * 2)), + ("_sparse", True), + ("_probA", np.array([0.0, 0.0])), + ], +) +def test_svm_fitted_shapes_and_indices_are_checked(attribute, value): + model = SVC(kernel="linear", probability=True).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + setattr(model, attribute, value) + with pytest.raises(InvalidModelError, match="SVM"): + Adapter().deserialize(sio.dumps(model)) + + +def test_nominal_sklearn_class_impersonation_is_rejected(tmp_path): + class RenamedTree(DecisionTreeClassifier): + pass + + RenamedTree.__name__ = "DecisionTreeClassifier" + RenamedTree.__module__ = "sklearn.tree._classes" + model = RenamedTree().fit([[0], [1]], [0, 1]) + with pytest.raises(UnsupportedModelError, match="exact"): + mlbundle.export(model, tmp_path / "bad.mlbundle") + + +@pytest.mark.parametrize("classifier", [False, True]) +def test_initializer_fitted_dimensions_are_checked(classifier): + model = ( + GradientBoostingClassifier(n_estimators=2, init=DummyClassifier()) + if classifier + else GradientBoostingRegressor(n_estimators=2, init=DummyRegressor()) + ).fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + if classifier: + model.init_.class_prior_ = np.array([0.3, 0.3, 0.4]) + else: + model.init_.constant_ = np.array([[0.0, 1.0]]) + with pytest.raises(InvalidModelError, match=r"priors|constant"): + Adapter().deserialize(sio.dumps(model)) + + +def test_initializer_input_width_matches_parent(): + from sklearn.linear_model import LogisticRegression + + model = GradientBoostingClassifier(n_estimators=2, init=LogisticRegression()).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + model.init_.n_features_in_ = 2 + with pytest.raises(InvalidModelError, match="initializer input width"): + Adapter().deserialize(sio.dumps(model)) diff --git a/tests/test_sklearn_inference_state.py b/tests/test_sklearn_inference_state.py new file mode 100644 index 0000000..ace287d --- /dev/null +++ b/tests/test_sklearn_inference_state.py @@ -0,0 +1,154 @@ +"""Prediction-relevant fitted state must match claims and native structure.""" + +import numpy as np +import pytest +import skops.io as sio +from sklearn.cross_decomposition import PLSRegression +from sklearn.ensemble import ( + AdaBoostClassifier, + BaggingClassifier, + GradientBoostingClassifier, + GradientBoostingRegressor, +) +from sklearn.linear_model import LogisticRegression, PoissonRegressor +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import ClaimMismatchError, InvalidModelError +from mlbundle._sklearn import Adapter +from sklearn_helpers import rewrite + +pytestmark = pytest.mark.filterwarnings( + "ignore:The `probability` parameter:FutureWarning" +) + + +def test_missing_route_is_verified(tmp_path): + model = DecisionTreeClassifier(max_depth=1).fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + path = tmp_path / "tree.mlbundle" + mlbundle.export(model, path) + before = model.predict_proba([[np.nan]]) + state = model.tree_.__getstate__() + state["nodes"]["missing_go_to_left"][0] ^= 1 + model.tree_.__setstate__(state) + assert not np.array_equal(before, model.predict_proba([[np.nan]])) + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize("classifier", [False, True]) +def test_gradient_boosting_initializer_is_verified(classifier, tmp_path): + model = ( + GradientBoostingClassifier(n_estimators=2) + if classifier + else GradientBoostingRegressor(n_estimators=2) + ).fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + path = tmp_path / "gb.mlbundle" + mlbundle.export(model, path) + if classifier: + model.init_.class_prior_ = np.array([0.9, 0.1]) + else: + model.init_.constant_ += 10 + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize("attribute", ["n_trees_per_iteration_", "n_estimators_"]) +def test_gradient_boosting_stage_dimensions_are_consistent(attribute): + model = GradientBoostingClassifier(n_estimators=2).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + setattr(model, attribute, 3) + with pytest.raises(InvalidModelError, match="stage arrangement"): + Adapter().deserialize(sio.dumps(model)) + + +@pytest.mark.parametrize( + "model", [GradientBoostingClassifier(n_estimators=2), PoissonRegressor()] +) +def test_private_loss_cannot_disagree_with_declared_parameters(model): + from sklearn._loss.link import HalfLogitLink + + model.fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + loss = model._loss if hasattr(model, "_loss") else model._base_loss + loss.link = HalfLogitLink() + with pytest.raises(InvalidModelError, match="loss"): + Adapter().deserialize(sio.dumps(model)) + + +@pytest.mark.parametrize( + "model,attribute", + [ + (AdaBoostClassifier(n_estimators=3), "estimator_weights_"), + ( + BaggingClassifier(n_estimators=3, max_features=1, random_state=7), + "estimators_features_", + ), + ], +) +def test_ensemble_weights_and_feature_assignment_are_verified( + model, attribute, tmp_path +): + x = [[0, 0], [1, 0], [0, 1], [1, 1], [2, 0], [0, 2]] + y = [0, 0, 1, 1, 1, 0] + if attribute == "estimator_weights_": + x, y = [[index] for index in range(8)], [0, 0, 1, 1, 0, 0, 1, 1] + model.fit(x, y) + path = tmp_path / "ensemble.mlbundle" + mlbundle.export(model, path) + if attribute == "estimator_weights_": + before = model.predict_proba(x) + model.estimator_weights_[0] += 1 + assert not np.array_equal(before, model.predict_proba(x)) + else: + model.estimators_features_[0][0] ^= 1 + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +def test_centered_linear_prediction_state_is_verified(tmp_path): + model = PLSRegression(n_components=1).fit( + [[0, 0], [1, 2], [2, 1], [3, 3]], [0, 1, 2, 3] + ) + path = tmp_path / "pls.mlbundle" + mlbundle.export(model, path) + model._x_mean += 1 + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +def test_pipeline_transform_state_is_claimed(tmp_path): + model = Pipeline( + [("scale", StandardScaler()), ("model", LogisticRegression())] + ).fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + path = tmp_path / "pipeline.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert loaded.structure is None + model.steps[0][1].mean_ += 1 + with pytest.raises(ClaimMismatchError, match="state_sha256"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize("attribute", ["_dual_coef_", "_intercept_"]) +def test_svm_displayed_coefficients_must_match_inference(attribute): + model = SVC(kernel="linear", probability=True).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + setattr(model, attribute, getattr(model, attribute) + 1) + with pytest.raises(InvalidModelError, match="coefficients"): + Adapter().deserialize(sio.dumps(model)) + + +def test_svm_calibration_state_is_verified(tmp_path): + model = SVC(kernel="linear", probability=True, random_state=0).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + path = tmp_path / "svm.mlbundle" + mlbundle.export(model, path) + model._probA += 1 + with pytest.raises(ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, sio.dumps(model))) diff --git a/tests/test_sklearn_parameters.py b/tests/test_sklearn_parameters.py new file mode 100644 index 0000000..a898c94 --- /dev/null +++ b/tests/test_sklearn_parameters.py @@ -0,0 +1,56 @@ +"""Faithful native parameter projections and bounded recursive work.""" + +import numpy as np +import pytest +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import UnsupportedModelError + + +def test_parameter_projection(tmp_path): + from sklearn.pipeline import Pipeline + from sklearn.preprocessing import FunctionTransformer + + from mlbundle._sklearn.parameters import _parameter + + state = np.random.RandomState(7) + model = DecisionTreeClassifier(random_state=state).fit([[0], [1]], [0, 1]) + path = tmp_path / "state.mlbundle" + mlbundle.export(model, path) + assert ( + "$random_state" + in mlbundle.load(path).manifest.model.hyperparameters["random_state"] + ) + assert _parameter((np.float32(1.5), np.array([1, 2]), None), frozenset()) == [ + 1.5, + [1, 2], + None, + ] + for value in [float, lambda x: x, {1: "bad"}]: + with pytest.raises(UnsupportedModelError): + _parameter(value, frozenset()) + cyclic = [] + cyclic.append(cyclic) + with pytest.raises(UnsupportedModelError, match="Cyclic"): + _parameter(cyclic, frozenset()) + model = Pipeline( + [("identity", FunctionTransformer()), ("tree", DecisionTreeClassifier())] + ).fit([[0], [1]], [0, 1]) + path = tmp_path / "pipeline.mlbundle" + mlbundle.export(model, path) + params = mlbundle.load(path).manifest.model.hyperparameters + assert params["tree"]["$estimator"]["class"] == "DecisionTreeClassifier" + assert "tree__max_depth" in params + + +def test_parameter_work_is_bounded(monkeypatch): + from importlib import import_module + + project = import_module("mlbundle._sklearn.parameters") + + monkeypatch.setattr(project, "MAX_VISITS", 10, raising=False) + shared = [1] + value = [shared] * 20 + with pytest.raises(mlbundle.LimitExceededError, match="parameter work"): + project._parameter(value, frozenset()) diff --git a/tests/test_skops_payload.py b/tests/test_skops_payload.py index 25c1161..a62bc53 100644 --- a/tests/test_skops_payload.py +++ b/tests/test_skops_payload.py @@ -58,7 +58,15 @@ def test_real_skops_and_supported_array_headers(): validate_payload(sio.dumps(model)) for version in [(1, 0), (2, 0)]: validate_payload( - _archive([("schema.json", b"{}"), ("array.npy", _array(version=version))]) + _archive( + [ + ( + "schema.json", + b'{"__loader__":"NdArrayNode","type":"numpy","file":"array.npy"}', + ), + ("array.npy", _array(version=version)), + ] + ) ) diff --git a/tests/test_skops_resources.py b/tests/test_skops_resources.py new file mode 100644 index 0000000..b665969 --- /dev/null +++ b/tests/test_skops_resources.py @@ -0,0 +1,180 @@ +"""Preflight work and storage bounds precede skops audit and NumPy allocation.""" + +import io +import json +from zipfile import ZipFile + +import numpy as np +import pytest +import skops.io as sio +from scipy.sparse import csr_matrix +from sklearn.linear_model import LinearRegression + +from mlbundle import InvalidModelError +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.payload import validate_payload +from sklearn_helpers import alter_schema, pack, unpack + + +def _never(*args, **kwargs): + pytest.fail("Expensive audit, construction or allocation must not run") + + +def test_deep_shared_reference_work_is_bounded_before_audit(monkeypatch): + model = LinearRegression().fit([[0], [1]], [0, 1]) + + def change(schema, entries): + attrs = schema["content"]["content"] + for index in range(18): + attrs[f"extra_{index}"] = { + "__class__": "list", + "__module__": "builtins", + "__loader__": "ListNode", + "__id__": 10000 + index, + "content": [] + if index == 0 + else [ + { + "__class__": "list", + "__module__": "builtins", + "__loader__": "CachedNode", + "__id__": 9999 + index, + } + ] + * 2, + } + + data = alter_schema(sio.dumps(model), change) + monkeypatch.setattr(sio, "get_untrusted_types", _never) + monkeypatch.setattr(sio, "loads", _never) + with pytest.raises(InvalidModelError, match="expanded node visits"): + Adapter().deserialize(data) + + +def test_small_shared_references_and_cycles(): + data = sio.dumps([[1], [1]]) + assert validate_payload(data) + cycle = { + "__class__": "list", + "__module__": "builtins", + "__loader__": "ListNode", + "__id__": 1, + "content": [ + { + "__class__": "list", + "__module__": "builtins", + "__loader__": "CachedNode", + "__id__": 1, + } + ], + } + with pytest.raises(InvalidModelError, match="Cyclic"): + validate_payload(pack({"schema.json": json.dumps(cycle).encode()})) + + +def _malformed_sparse(elements): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + for name, value in { + "format": np.array(b"csr"), + "shape": np.array([1, elements]), + "indptr": np.array([0, elements]), + "indices": np.array([], dtype=np.int32), + }.items(): + content = io.BytesIO() + np.save(content, value, allow_pickle=False) + archive.writestr(name + ".npy", content.getvalue()) + content = io.BytesIO() + np.lib.format.write_array_header_1_0( + content, {"descr": " Date: Mon, 12 Oct 2026 03:23:18 +0200 Subject: [PATCH 08/15] feat: add CLI and finalize v0.1 docs (#9) --- .github/workflows/ci.yml | 37 ++++ AGENTS.md | 36 ++-- README.md | 418 +++++++++++++++++++++----------------- SECURITY.md | 20 +- docs/format-adapters.md | 2 +- docs/format-onnx.md | 7 +- docs/format-schema.md | 6 +- docs/format-state.md | 15 +- docs/format.md | 16 +- pyproject.toml | 11 +- scripts/package_smoke.py | 114 +++++++++++ src/mlbundle/cli.py | 174 ++++++++++++++++ tests/test_cli.py | 270 ++++++++++++++++++++++++ tests/test_readme.py | 49 +++++ tests/test_schema.py | 13 -- tests/test_skops_state.py | 20 ++ tests/test_xgboost.py | 19 -- uv.lock | 4 +- 18 files changed, 971 insertions(+), 260 deletions(-) create mode 100644 scripts/package_smoke.py create mode 100644 src/mlbundle/cli.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_readme.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcee882..1299357 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,43 @@ permissions: contents: read jobs: + base-install: + name: Base wheel smoke (${{ matrix.os }}, Python 3.14) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Install UV CLI + run: python -m pip install uv==0.12.19 + - name: Build wheel and source archive + run: uv build + - name: Install fixture producer from lockfile + run: uv sync --locked --group dev --all-extras + - name: Produce bundle and check artifact contents + shell: bash + run: | + uv run python scripts/package_smoke.py create "$RUNNER_TEMP/smoke.mlbundle" + uv run python scripts/package_smoke.py contents dist + - name: Install base wheel in a clean environment and check lazy imports + shell: bash + run: | + uv venv --python 3.14 "$RUNNER_TEMP/mlbundle-base" + if [[ "$RUNNER_OS" == "Windows" ]]; then + smoke_python="$RUNNER_TEMP/mlbundle-base/Scripts/python.exe" + else + smoke_python="$RUNNER_TEMP/mlbundle-base/bin/python" + fi + uv pip install --python "$smoke_python" dist/*.whl + "$smoke_python" -I scripts/package_smoke.py base "$RUNNER_TEMP/smoke.mlbundle" + lint: name: Lint runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 4d22c7f..b84cd5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,11 @@ a trained model, a rich manifest, a locally inferred schema and model structure in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. -Phases 1–5 implement the version 1 format contract, strict manifest models, -canonical JSON, integrity helpers, environment capture, adapter/result contracts, -secure container reading/writing, public metadata-only `inspect`, export/load, -the scikit-learn, XGBoost and ONNX adapters, local schema inference, training -dtypes and explicit samples. The CLI remains unimplemented. -Rules for later phases require code and test proof before claiming implementation. `docs/format.md` owns the wire contract. +The pre-release v0.1 library implements strict format 1 manifests, secure container +I/O, inspect/export/load, sklearn/XGBoost/ONNX adapters, local schema, training +dtypes, explicit samples and prediction-state verification. The CLI provides +metadata-only `inspect` and full `verify`. Format 1 is not yet frozen. +Implementation claims require code and test proof. `docs/format.md` owns the wire contract. ## What makes mlbundle special? @@ -40,11 +39,11 @@ rows only when explicitly requested: samples are real user data. ### 4. Native formats with a narrow support boundary -Version 0.1 supports three libraries: scikit-learn via `skops.io.dump` and -`skops.io.load`, XGBoost via native `save_model("model.ubj")`, and the user's ONNX +Version 0.1 supports three libraries: scikit-learn via `skops.io.dumps/loads`, +XGBoost via native in-memory UBJSON, and the user's ONNX file unchanged. Do not convert models. Adapters import their library lazily; their dependencies belong in the optional extras `mlbundle[sklearn]`, -`mlbundle[xgboost]` and `mlbundle[onnx]`, declared in Phase 1. +`mlbundle[xgboost]` and `mlbundle[onnx]`. The design takes ideas from MLflow's versioned manifest and native model storage, not its code. MLflow is Apache-2.0; mlbundle is MIT. @@ -123,7 +122,7 @@ still preserve validation, security and reproducibility. ### Repository rules -- No source file may exceed 300 lines. Split by responsibility before the limit. +- No source, test or documentation file may exceed 300 lines. Split by responsibility before the limit. - Add no runtime dependency without a concrete reason. The sole core dependency is Pydantic v2 for strict manifest validation; model libraries belong in extras. - Keep a feature's tests in one file covering its success case and every error @@ -146,7 +145,7 @@ still preserve validation, security and reproducibility. ## Development Work from the repository root using `uv` on Python 3.14. The optional `schema` -extra uses mlschema 0.2.1 on 3.14 only. Keep `uv_build` as the build backend. +extra admits mlschema >=0.2.1,<0.3 on 3.14 only. Keep `uv_build` as the build backend. ```shell uv python install 3.14 @@ -176,7 +175,7 @@ current directory: `uv run ruff check` and `uv run ruff format --check`. `uv run pytest` runs `tests/`, measures branch coverage and enforces the family's 80% minimum, writing terminal and XML reports. Tests use the installed package; its version comes from package metadata. CI checks lint and types and runs tests -on Ubuntu and Windows. The local build must produce a wheel and source archive. +and a clean base-wheel smoke check on Ubuntu and Windows. The local build must produce a wheel and source archive. Start with the smallest proof of a behavior change, then broaden when needed. If a check cannot run, report the exact command and blocker. Never claim a test, @@ -253,16 +252,16 @@ receives the strict `ExportDeclarations` label record and leaves the user's object unchanged. Classifier claims may use null labels only for a native model without labels; `declarations.py` then resolves count-validated supplied/generated labels. ONNX can use that declaration path without changing its original bytes; -its kind/count inference remains ONNX-owned. Follow `adapters.py`; adding `_onnx` -needs no library-specific shared pipeline edit. +its kind/count inference remains ONNX-owned. Keep native behavior in adapters; follow `adapters.py`. ## Where code lives -- `src/mlbundle/__init__.py` — public contract models, enums, errors and package version. -- `src/mlbundle/_base.py` — strict frozen model configuration and string bounds. +- `src/mlbundle/__init__.py` — public inspect/export/load, contract models, enums, errors and package version. +- `src/mlbundle/cli.py` — argparse inspect/verify console entry with safe bounded display. +- `src/mlbundle/_base.py` and `errors.py` — strict frozen model configuration, string bounds and bounded errors. - `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. - `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. -- `src/mlbundle/canonical.py` — deterministic JSON encoding and bounded strict decoding. +- `src/mlbundle/canonical.py` and `_canonical_work.py` — deterministic JSON and bounded decoding/expansion. - `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. - `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. - `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. @@ -275,7 +274,7 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/manifest.py` — manifest sections, cross-field validation and JSON boundaries. - `src/mlbundle/integrity.py` — member SHA-256/size records and verification. - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. -- `src/mlbundle/adapters.py` — adapter/predictor protocols and prediction output types. +- `src/mlbundle/adapters.py` and `_prediction_input.py` — adapter/predictor protocols, output types and shared input checks. - `src/mlbundle/registry.py` — closed lazy resolution of implemented `_sklearn`, `_xgboost` and `_onnx` adapters. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. @@ -286,6 +285,7 @@ needs no library-specific shared pipeline edit. - `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. - `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. +- `scripts/package_smoke.py` — fixture, isolated install and artifact-content checks. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. - `.github/workflows/ci.yml` — lint, types and cross-platform tests. - `.pre-commit-config.yaml` — Ruff, Pyright and basic file hygiene. diff --git a/README.md b/README.md index efe755f..85876e4 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,66 @@ # mlbundle -mlbundle is a Python library for packaging a trained model, a rich manifest, -locally inferred schema and structure in one compressed `.mlbundle` file. The same package -writes bundles on the training machine and reads them with validation in a runtime. - -**Status: pre-alpha, Phase 6.** The [version 1 format contract](docs/format.md), -strict manifest models, canonical JSON, integrity helpers, environment capture, -adapter/result contracts, secure container reading/writing and public `inspect` -and public sklearn/XGBoost/ONNX `export`/`load` are implemented and tested. -Local schema inference, training dtypes and explicit samples are implemented. -Reader hardening covers complete fitted state, unknown state rejection, bounded -shared references/sparse storage and bounded prediction errors. -The CLI is still missing. Nothing has been -published to PyPI. - -## Why it exists - -[MLSuite](https://github.com/UlloaSP/mlsuite) turns trained models into reviewable -applications: it generates forms, runs predictions and stores feedback. Its -current workflow uploads separate joblib files for a model and its training -DataFrame. Loading those files can execute arbitrary code, the artifacts do not -record the training environment, and training data leaves the user's machine -just to infer a schema. - -mlbundle computes the schema locally with -[mlschema](https://github.com/UlloaSP/mlschema), records the model and training -environment, and carries everything the runtime needs in one file. Training rows -are excluded unless the user explicitly asks to include samples. - -The first version supports scikit-learn through skops, XGBoost through its -native UBJSON format, and existing ONNX files without conversion. Model and schema -dependencies are optional extras (`sklearn`, `xgboost`, `onnx`, `schema`, `all`), with lazy -adapter resolution. The design takes ideas from MLflow's versioned manifest and -native model formats; it copies no MLflow code. mlbundle is MIT licensed. - -## Security model - -Bundles are untrusted input. The container reader rejects unsafe ZIP members, -enforces resource limits, validates the manifest and format version, and verifies -member hashes. Sklearn loading uses skops with a fixed trusted-types list owned -by mlbundle, with no pickle, joblib or cloudpickle path. Model-derived metadata -and visualisation structures are claims: `load` recomputes them from the native -model and rejects mismatches. `export` checks that they survive a native round -trip before writing. A metadata-only `inspect` -operation works without deserializing the model or importing model libraries. - -Hashes detect corruption; they do not authenticate the author or prevent -deliberate tampering. Signing is outside the project's scope. These guarantees -cover container validation and all three adapters' claim recomputation; native parsers and -prediction still require a consuming runtime's isolation and resource limits. -See [SECURITY.md](SECURITY.md) for the threat model and vulnerability reporting. +Package a trained model, its manifest, locally inferred schema and model +structure in one compressed `.mlbundle` file. Use the same Python library to +export on the training machine and validate/load in the consuming application. + +mlbundle replaces three gaps in separate model/DataFrame uploads: + +- **Pickle code execution:** closed adapters use skops, native XGBoost UBJSON + or an existing ONNX file, with no pickle/joblib/cloudpickle loading path. +- **Missing training versions:** the manifest records Python, OS, architecture + and installed package versions alongside model and feature metadata. +- **Training data leaving the machine:** schema inference runs locally; + training rows are included only when explicitly requested as samples. + +**Pre-release v0.1.0; format version 1 is not yet frozen.** Nothing is published +on PyPI. Requires **Python >=3.14**. The tested baseline is CPython 3.14 on +Windows and Linux; the schema extra currently works only on Python 3.14. + +## Install and try scikit-learn + +Install from Git with +`python -m pip install "mlbundle[sklearn] @ git+https://github.com/UlloaSP/mlbundle.git@develop"`. +Replace `[sklearn]` with `[xgboost]`, `[onnx]`, `[schema]` or `[all]` for the +corresponding dependencies. Combine extras as `[sklearn,schema]` for local +DataFrame inference. Omit the brackets for a base install that can inspect +every bundle without model libraries. Load/verify needs the matching model extra. ```python from sklearn.tree import DecisionTreeClassifier from mlbundle import export, inspect, load rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -labels = ["cold", "warm", "cold", "warm"] -model = DecisionTreeClassifier(random_state=7).fit(rows, labels) +model = DecisionTreeClassifier(random_state=7).fit( + rows, ["cold", "warm", "cold", "warm"] +) export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) bundle = load("trained.mlbundle") assert isinstance(bundle.native_model, DecisionTreeClassifier) assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() -print(bundle.class_labels) # [["cold", "warm"]] +assert bundle.class_labels == [["cold", "warm"]] print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] -print(inspect("trained.mlbundle").manifest.model.library) # sklearn +assert inspect("trained.mlbundle").manifest.model.library == "sklearn" ``` -Inspection validates format and integrity. It cannot attest that model-derived -claims or structure match the model. `load` supplies that guarantee and returns -the native model, predictor and metadata. Model-exposed feature names take -precedence over `feature_names=`; otherwise those names are declarations with -origin `declared`, or omitted names become `feature_1` through `feature_N`. -`target_name=` and `metadata=` record author declarations. Existing destinations -require `overwrite=True`. Paths, bytes and binary streams are accepted by `load`. - -Supported sklearn models must be fitted classifiers with `predict_proba` or -regressors, with all stored types trusted and all claims surviving skops. -DecisionTree, RandomForest, ExtraTrees, GradientBoosting, LogisticRegression, -LinearRegression, Ridge and scaler/classifier pipelines are covered by round-trip -tests. Multi-output labels and probabilities retain output-major order. Pipeline -structure is null because v1 does not map transformed features. Custom types, -user functions, HistGradientBoosting and XGBoost steps inside sklearn pipelines -are rejected. [Prediction-state rules](docs/format-state.md) also bind opaque -pipelines and reject extra attributes/files before native loading. - -For local schema inference, install `mlbundle[sklearn,schema]` on Python 3.14. -Use the training frame with string column names in model input order: +`export` returns a metadata view and refuses an existing destination unless +`overwrite=True`. `load` accepts a path, bytes or a binary stream. Classifiers +use `bundle.predict_proba`; regressors use `bundle.predict`. Results are plain +lists. For multiple targets, classifier labels/probabilities are output-major, +while regression values are row-major. `native_model` exposes the loaded native +object for explanations and native inference. Do not mutate it and continue +treating the original metadata as verified. -```python -import pandas as pd -from sklearn.tree import DecisionTreeClassifier -from mlbundle import export, inspect, load +Native feature names take precedence over `feature_names=`. Otherwise supplied +names have origin `declared`; omitted names become `feature_1` through +`feature_N` with origin `generated`. DataFrame names have origin `dataframe` +when the model has none. `target_name=` and `metadata=` are author declarations. -training = pd.DataFrame( - { - "temperature": [0.0, 1.0, 0.1, 0.9], - "humidity": [1.0, 0.0, 0.9, 0.1], - "target": ["cold", "warm", "cold", "warm"], - } -) -features = training.drop(columns="target") -model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) -export(model, "schema.mlbundle", training_data=training, target_name="target") -view = inspect("schema.mlbundle") -assert view.manifest.features.dtypes == ["float64", "float64"] -assert view.schema["reports"][0]["labels"] == ["cold", "warm"] -assert view.samples is None -assert load("schema.mlbundle").schema == view.schema -``` +## XGBoost -Without `target_name`, the frame must contain only features. With a frame, a -named target must exist and is excluded from inference and samples. Native -names take precedence and must match frame columns in order; a model without -names uses origin `dataframe`. `feature_names=` with a frame must agree exactly. -One-hot binary columns such as `color__red`/`color__blue` share a category field -using the same `__` convention as MLSuite. Schema is a declaration; load verifies -its mappings and reports against the model's validated metadata. - -What leaves the machine: column names, dtype strings, inferred kinds, -nullability, numeric steps from dtypes, category values (including unused ones), -one-hot suffixes and pair-axis metadata, plus class labels and model metadata. -Inference adds no numeric/date ranges, means, arbitrary text values or example -rows. Category names can be sensitive. Native models and structure may themselves -retain training information; omitting samples is not protection against memorization. - -`export(..., training_data=training, target_name="target", sample_rows=2)` explicitly -includes the first two feature rows. **Samples are real data.** This option is -off by default, requires a positive integer within the frame, and excludes the -target and index. Missing values become null, timestamps ISO strings and NumPy/ -categorical scalars JSON values. Both members have a 16 MiB ceiling. See -[the schema and sample contract](docs/format-schema.md) for exact shapes, -conversion, reports per output and MLSuite's series presentation mapping. - -XGBoost supports fitted `XGBClassifier` and `XGBRegressor` tree wrappers, with -native UBJSON stored and loaded entirely in memory. Train classifiers on encoded -indices and supply original labels in probability-column order: +Install the `xgboost` extra. Train on encoded indices and supply the original +labels in probability-column order, nested even for one output: ```python from xgboost import XGBClassifier @@ -157,86 +81,214 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() assert bundle.class_labels == [["cold", "warm"]] ``` -`class_labels=` is output-major even for one target. XGBoost binds it to a native -copy and load verifies the manifest against that binding; omitted labels are the -model's `0..N-1` integers. Native `predict` retains those encoded indices. Sklearn -and regressors reject this option. XGBoost keeps the recoverable native config -projection, not lost training parameters such as max depth or learning rate. -Binary logistic and multiclass softprob classifiers and the closed regression -objectives in [docs/format.md](docs/format.md) are covered. Native gbtree/dropout -is supported; gblinear, RF wrappers, raw boosters, ranking, survival, custom -objectives and multi-target models are rejected. +The adapter binds labels and prediction settings to a native **copy**, leaving +your model unchanged. Load checks agreement with that binding, not the historical +truth of the label mapping. Without `class_labels=`, labels are encoded indices +or an existing mlbundle binding. Native `predict` still returns encoded indices. +Sklearn and regressors reject `class_labels=`. + +Only recoverable native configuration is recorded. Training settings such as +learning rate, max depth, seed and thread count do not survive native XGBoost +I/O and are not reconstructed from the manifest. -Existing self-contained ONNX files travel unchanged, without a converter. Export -also accepts bytes or a ModelProto (serialized once). This executable example -creates a small probability graph; production callers can pass their existing file: +## Existing ONNX files + +Install the `onnx` extra. This example assumes your existing `trained.onnx` is +a self-contained tabular classifier with two numeric features and two probability +columns. Labels on a label-free graph are count-checked declarations: ```python from pathlib import Path -from onnx import TensorProto, helper from mlbundle import export, load -graph = helper.make_graph( - [helper.make_node("Softmax", ["features"], ["probabilities"], axis=1)], - "classifier", - [helper.make_tensor_value_info("features", TensorProto.FLOAT, [None, 2])], - [helper.make_tensor_value_info("probabilities", TensorProto.FLOAT, [None, 2])], -) -model = helper.make_model( - graph, opset_imports=[helper.make_opsetid("", 21)], ir_version=10 -) -original = model.SerializeToString() -Path("trained.onnx").write_bytes(original) +original = Path("trained.onnx").read_bytes() export("trained.onnx", "onnx.mlbundle", class_labels=[["cold", "warm"]]) bundle = load("onnx.mlbundle") assert bundle.native_model.data == original +assert bundle.native_model.session.get_providers() == ["CPUExecutionProvider"] assert bundle.class_labels == [["cold", "warm"]] -assert bundle.predict_proba([[0.0, 0.0]]) == [[0.5, 0.5]] +print(bundle.predict_proba([[0.0, 0.0]])) ``` -ONNX native results contain the original `data` and a CPU `session` for native -inference. Numeric matrix inputs and one-input-per-column mixed numeric graphs -are supported. Classifiers return tensor or ZipMap probabilities; regressors -return one value per row. Kind is inferred from supported output layouts. Native -labels are verified; `class_labels=` on label-free graphs is a count-checked -declaration, and omitted labels become encoded indices. No labels are bound into -the unchanged file. Ambiguous layouts, external data, custom domains/functions -and training/runtime configuration are rejected before session creation. -ONNX has no visualization structure. See [ONNX support](docs/format-onnx.md) for -exact shapes, claim fields and native dependency limits. +Paths and bytes are preserved unchanged; a ModelProto is serialized once. +There is no conversion. `native_model` contains the original `data` and CPU +`session`. Native graph labels are verified; supplied labels must agree with +them. Without native/supplied labels, encoded indices are generated. ONNX has +no visualization structure. See [the exact ONNX contract](docs/format-onnx.md). -## Development +## Local schema and explicit samples + +Install a model extra plus `schema` on Python 3.14. Supply a nonempty pandas +DataFrame with string feature names in model input order. A named target must +exist and is excluded; without `target_name`, the frame contains features only. + +```python +import pandas as pd +from sklearn.tree import DecisionTreeClassifier +from mlbundle import export, inspect, load + +training = pd.DataFrame( + { + "temperature": [0.0, 1.0, 0.1, 0.9], + "humidity": [1.0, 0.0, 0.9, 0.1], + "target": ["cold", "warm", "cold", "warm"], + } +) +features = training.drop(columns="target") +model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) +export(model, "schema.mlbundle", training_data=training, target_name="target") +view = inspect("schema.mlbundle") +assert view.manifest.features.dtypes == ["float64", "float64"] +assert view.schema["reports"][0]["labels"] == ["cold", "warm"] +assert view.samples is None +assert load("schema.mlbundle").schema == view.schema -Use Python 3.14 and [uv](https://docs.astral.sh/uv/). The Python minimum follows -the development baseline; source code must avoid syntax that requires Python 3.14 so the -supported range can be widened later. Pydantic v2 is the sole core runtime -dependency, for strict manifest validation. Model libraries are optional; -mlschema is the lazy `schema` extra, included in `all`. Its current version -supports Python 3.14 only; mlbundle's own Python range has no upper bound. +# Samples are real data: explicitly include the first two feature rows. +export( + model, + "sampled.mlbundle", + training_data=training, + target_name="target", + sample_rows=2, +) +assert inspect("sampled.mlbundle").samples == features.head(2).to_dict("records") +``` + +`schema.json` contains a schema version, locally inferred fields and one report +per target output. `color__red`/`color__blue` binary columns can become one +category field using mlschema's `__` convention. Frame names must match native +names in order; `feature_names=` cannot rename/reorder a supplied frame. +Training dtypes are declarations, separate from verified model-exposed dtypes. +Inspect/load validate schema mappings, report kinds/labels and sample row keys +without pandas or mlschema. + +**What data-derived information leaves the machine:** column names, dtypes, +field kinds, nullability, dtype-based numeric steps, categorical values (including +unused categories), one-hot suffixes, pair-axis metadata and class labels. +Inference adds no numeric/date ranges, means, counts, arbitrary text row values +or example rows. Categories and names can be sensitive. Models and structure +can themselves retain training information; omitting samples does not prevent +memorization. Explicit `sample_rows=N` adds exactly the first N feature rows, +without target/index; N must be positive and no larger than the frame. +See [schema and samples](docs/format-schema.md) for conversions and limits. + +## Inspect, load and the CLI + +`inspect` validates archive layout, limits, manifest, hashes and schema/sample +declarations **without importing any model library**. It cannot attest that +metadata or structure matches the model. `load` additionally deserializes through +the closed adapter and recomputes native claims and structure before returning +the model and predictor. Export checks the same native round trip before writing. + +After the sklearn example above, these commands work: ```shell -uv python install 3.14 -uv sync --locked --all-extras -uv run pre-commit install -uv run ruff check . -uv run ruff format --check . -uv run pyright -uv run pytest -uv build +mlbundle inspect trained.mlbundle +mlbundle inspect trained.mlbundle --json +mlbundle verify trained.mlbundle ``` -The default dev group installs all adapter and schema extras. Base consumers can use -`uv sync --no-dev` without installing model libraries. The committed `uv.lock` -fixes the development versions of scikit-learn, skops, XGBoost, ONNX and ONNX -Runtime to the target MLSuite runtime baseline; all three adapters are exercised -by the round-trip tests. The schema extra pins mlschema 0.2.1 from PyPI and brings -pandas transitively; neither is a core dependency. Import and inspect require -neither; load works without them, although native sklearn imports an installed -pandas opportunistically. `uv run pytest` -produces terminal and XML coverage reports and enforces an 80% minimum. CI runs -lint, type checks, and Python 3.14 tests on Ubuntu and Windows. `uv build` creates -local wheel and source archives; it does not publish them. +Inspection prints identity, model/counts, feature origin/names, output-major +labels, member sizes/hashes, training environment and optional-member presence. +The manifest has no self-integrity record. Human display values are escaped and +truncated to 512 characters; `--json` prints the complete validated manifest with +ASCII JSON escaping and the format's size limits. This escapes terminal controls +without changing the decoded JSON. `verify` prints one valid-bundle line or an +error, and requires the matching extra. Errors use one bounded stderr line +without bundle content or traceback. Exit codes are **0** success, **1** bundle +or dependency rejection (`MLBundleError`), **2** usage or I/O failure. + +## Supported models and limits + +Support requires a faithful round trip through the closed adapter. A catalog +entry alone does not guarantee that every fitted configuration exports. + +- **Sklearn:** fitted classifiers exposing `predict_proba` and regressors with + recoverable feature/output counts. Tested families include DecisionTree, + ExtraTree, RandomForest, ExtraTrees, GradientBoosting, AdaBoost and Bagging; + LogisticRegression, LinearRegression, Ridge, Bayesian/ARD, ElasticNet/Lasso, + Huber, Poisson/Gamma, SGD; SVC with probabilities, SVR/NuSVR/LinearSVR; + PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, + NearestCentroid, MLP, MultiOutput wrappers and RegressorChain. Scaler and + dense OneHotEncoder pipelines round-trip; pipeline structure stays null + because format 1 does not map transformed features. See the + [tested configurations](tests/test_sklearn_families.py) and + [core round trips/rejections](tests/test_sklearn.py). +- **Sklearn rejections:** custom estimators/subclasses, callable/type parameters + (including FunctionTransformer user functions), classifiers without + probabilities, multilabel/ClassifierChain layouts, XGBoost pipeline steps, + and untrusted nested storage such as fitted sparse neighbor arrays. + HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. +- **Sklearn versions:** the closed class/attribute catalog is audited/tested + against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. + Unknown classes/attributes fail export/load. The embedded sklearn version is + committed too: loading with a different sklearn version fails native state + comparison. Use the same sklearn version for export and load. Other versions + may work when both ends match and their state satisfies the catalog, but are + not an advertised compatibility range. There is no manifest-driven restoration + or permissive fallback. See [state ownership](docs/format-state.md). +- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native `gbtree` + storage (including XGBoost 3.4.1 `dart` dropout normalized to gbtree). + Classifiers: `binary:logistic`, `multi:softprob`. Regressors: + `reg:squarederror`, `reg:absoluteerror`, `reg:pseudohubererror`, + `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, `reg:gamma`, + `reg:tweedie`. Raw boosters, RF wrappers, subclasses, gblinear, legacy dart + storage, ranking/survival/custom objectives and multiple targets are rejected. + At most 100,000 trees and depth 14; exact native state and complete dumps are + required. See [the XGBoost mapping](docs/format-adapters.md#implemented-xgboost-mapping). +- **ONNX:** one numeric matrix input, or multiple width-one numeric inputs; + rank two, fixed feature width, float32/float64/int32/int64. Batch is dynamic + or one. One target: float regression `[batch]`/`[batch,1]`, or tensor/ZipMap + probabilities with an optional int64/string label output. Classification must + be inferable from supported graph evidence, including terminal Softmax or + native classifier labels. Ambiguous shapes, images/string inputs, fixed + batches above one, multiple targets, external tensors, custom domains/local + functions and training/runtime configuration are rejected before a CPU session. + Tested with ONNX 1.23.0/Runtime 1.30.0; actual operators/opsets must pass both. + +Archive ceilings: **5 members**, **512 MiB** archive/compressed/member bytes, +**1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. +Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. +Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/ONNX: one). +Callers may lower `ArchiveLimits`, never raise these ceilings. Native work has +additional [state](docs/format-state.md) and [ONNX](docs/format-onnx.md) bounds; +these byte budgets do not cap total process memory, prediction rows or CPU. + +## Bundle contents and security + +Every bundle contains `manifest.json` and exactly one of `model.skops`, +`model.ubj`, `model.onnx`. Optional members are `schema.json`, `structure.json` +(required when the adapter extracts structure) and explicitly requested +`samples.json`. No training DataFrame or arbitrary files are included. +The manifest records identity/time/writer, model parameters/counts/state, +ordered features/dtypes/origin, target labels/name, environment, member +SHA-256/sizes, references and free-form user metadata. The normative +[format specification](docs/format.md) owns these records and verification rules. + +Treat bundles as untrusted. The reader validates ZIP layout, allowed members, +limits, strict JSON/manifest and hashes; adapters use closed trust/loading paths +and recompute claims. **Hashes detect corruption, not deliberate tampering**; +there is no signing or proof of authorship, training history, quality or fairness. +Schema, samples and training-environment declarations are not authenticated. +Native parsers and inference remain dependency boundaries. Consumers should +isolate the loading/prediction process, enforce memory/CPU/row limits and maintain +dependencies. See [SECURITY.md](SECURITY.md) for guarantees, limits and reporting. + +## Development +From the repository root, use `uv python install 3.14`, +`uv sync --locked --all-extras` and `uv run pre-commit install`. +The default dev group installs every extra; base users can use `uv sync --no-dev`. +Pydantic v2 is the sole core runtime dependency; model/schema imports stay lazy. +The schema extra admits mlschema `>=0.2.1,<0.3` for the 0.2 inferred vocabulary, +with a Python 3.14 marker because 0.2.1 excludes 3.15. `uv.lock` fixes the tested +development versions. Dependency edits require `uv sync` and the updated lockfile. + +Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, +`uv run pytest` and `uv build`. Tests execute every fenced README example, +write terminal/XML coverage and enforce 80% branch-inclusive coverage. +CI preserves lint/types and Ubuntu/Windows tests and checks a clean base wheel +installation on both OSes. `uv build` creates a local wheel and source archive. Read [AGENTS.md](AGENTS.md) before contributing. Publishing, releases, version -tags, sklearn-to-ONNX conversion, per-model environments, signing and additional -model libraries are outside this project's scope. +tags, conversions, per-model environments, signing and other model libraries +remain outside scope. The project is MIT licensed. diff --git a/SECURITY.md b/SECURITY.md index ea4b0a2..7625af4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,9 +1,9 @@ # Security policy -mlbundle is pre-alpha. Phases 1–5 implement strict manifest validation, -contract helpers, secure container reading/writing, metadata-only `inspect`, -public export/load, the sklearn, XGBoost and ONNX adapters, local schema inference -and explicitly requested sample rows. +mlbundle v0.1 is pre-release; format version 1 is not yet frozen. It implements +strict manifest validation, secure container reading/writing, metadata-only +`inspect`, export/load, sklearn, XGBoost and ONNX adapters, local schema inference, +explicit samples, prediction-state verification and the inspect/verify CLI. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. The writer publishes complete validated files atomically where hard links work; @@ -94,6 +94,12 @@ contain the native model for runtime explanations; mutating it invalidates the relationship to the verified metadata. Pipeline structure is null because v1 does not describe its input transformations. +The closed sklearn class/attribute catalog is audited against 1.9.1 with skops +0.16.0. Other versions/configurations can be rejected for unknown state. The +embedded sklearn version is committed too; different export/load sklearn +versions fail native state comparison. Dependency version floors are not a +cross-version compatibility guarantee. See [state ownership](docs/format-state.md). + ## Implemented XGBoost dependency boundary XGBoost uses native UBJSON only, in memory, with exact XGBClassifier/XGBRegressor @@ -187,6 +193,12 @@ ONNX original labels remain declarations. Consumers must treat schema and sample values as untrusted display/input data, never executable instructions. See [the exact data-derived contract](docs/format-schema.md) and review what is shared. +The CLI uses the same inspect/load boundary. Human summary values escape +terminal controls and are truncated; JSON output is lossless ASCII-escaped +manifest data within format limits. Errors are one bounded stderr line with a +failure category, not bundle values. Inspect still does not attest model claims; +verify requires the matching extra and performs full loading and comparison. + ## Reporting a vulnerability Use [GitHub private vulnerability reporting for UlloaSP/mlbundle](https://github.com/UlloaSP/mlbundle/security/advisories/new). diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 31fdfc0..34b99bf 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -122,7 +122,7 @@ Only native `gbtree` storage is admitted. In XGBoost 3.4.1, the deprecated `dart option uses `gbtree` storage with dropout; that representation is supported and all native dump statistics are retained. Legacy native `dart` storage is rejected. `gblinear` is rejected: its multi-class coefficient/importance dimensions and -deprecated format are outside this phase's tree contract. There is no silently +deprecated format are outside the version 1 tree contract. There is no silently omitted structure for an admitted XGBoost model. Serialization is exclusively `bytes(booster.save_raw(raw_format="ubj"))` and diff --git a/docs/format-onnx.md b/docs/format-onnx.md index 4c40e6c..da3897d 100644 --- a/docs/format-onnx.md +++ b/docs/format-onnx.md @@ -1,7 +1,7 @@ # Version 1 ONNX mapping -Normative section of [the format contract](format.md). Phase 4b implements this -mapping. ONNX is opaque for visualization: extraction returns null, and load +Normative section of [the format contract](format.md). ONNX is opaque for +visualization: extraction returns null, and load rejects any `structure.json` member with `ClaimMismatchError`. ## Original model and native result @@ -89,8 +89,7 @@ label tensors). Map output dtype is exactly `sequence>`, `sequence>` or the corresponding float64 spelling; shape is null. `metadata_props` maps each unique model-level metadata key to its original string value, without interpreting it as configuration. The existing 1 MiB/depth-16 -hyperparameter ceiling applies. This completes the previously unimplemented ONNX -projection; sklearn/XGBoost claim semantics and the manifest schema are unchanged. +hyperparameter ceiling applies; no training parameters are inferred from it. `environment.onnx` retains exactly the existing required opsets (unique, sorted by original domain), IR version and producer name/version. These fields, graph diff --git a/docs/format-schema.md b/docs/format-schema.md index 1f30740..89754b5 100644 --- a/docs/format-schema.md +++ b/docs/format-schema.md @@ -1,7 +1,7 @@ # Version 1 schema and samples -Normative section of [the format contract](format.md). Phase 5 implements local -mlschema 0.2.1 inference. Schema and samples are declarations from data the +Normative section of [the format contract](format.md). Local inference uses +the mlschema 0.2 vocabulary, tested with 0.2.1. Schema and samples come from data the reader never sees. Integrity does not authenticate that data or its history. ## Export inputs @@ -31,7 +31,7 @@ field's `field1`/`field2` into `columns` with those IDs and removes nested `mappedTo`; this is presentation mapping and needs no DataFrame. Consumers may format typed class labels for display and select reports by output index. -`mlbundle[schema]` optionally installs mlschema 0.2.1 from PyPI, transitively +`mlbundle[schema]` optionally installs mlschema >=0.2.1,<0.3 from PyPI, transitively pandas; it is included in `all`. The dependency is marked for Python 3.14 because that mlschema version requires `>=3.14,<3.15`. mlbundle's own `>=3.14` range and sole core dependency (Pydantic) are unchanged. Training export with missing diff --git a/docs/format-state.md b/docs/format-state.md index 93d2f37..bcaea8c 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -1,8 +1,8 @@ # Version 1 prediction state and bounded native work -Normative section of [the format contract](format.md). Phase 6 completes the -unreleased version 1 contract; it does not increment the format version. Earlier -unreleased bundles lacking the new state records fail full claim comparison. +Normative section of [the format contract](format.md). Version 1 is pre-release +and not yet frozen. Bundles lacking the required prediction-state records +fail full claim comparison. ## Sklearn state ownership @@ -16,6 +16,15 @@ attributes, nested estimator attributes, node keys, loaders and unreferenced fil are rejected. The supplemental skops trusted list remains Tree only. A catalog entry does not extend skops' trust or guarantee that every configuration exports. +The audited/tested baseline is scikit-learn 1.9.1 with skops 0.16.0; the +dependency floor is not a promise of compatibility with every later version. +There is no version whitelist. Other versions can introduce unknown classes or +state fields and fail export/load. `_sklearn_version` also participates in the +commitment: a different installed sklearn version changes reserialized state, +so full loading rejects it as `InvalidModelError`. Export and load should use +the same sklearn version. Other matching-version configurations must satisfy +all catalog, trust, dimensions and round-trip checks; they are not a tested range. + Object state must be a dictionary with one string key type per attribute; skops may otherwise truncate attributes silently. Stateless admitted objects may use the native null state. Tree constructor state has exactly `node_count`, diff --git a/docs/format.md b/docs/format.md index 2db16be..a75dc7a 100644 --- a/docs/format.md +++ b/docs/format.md @@ -2,10 +2,10 @@ This document defines the wire contract for `format_name = "mlbundle"` and `format_version = "1"`. Format versions are independent of package versions. -"Must" denotes a compatibility or security requirement. Phases 1–5 implement -the manifest, encoding, limits, secure container, metadata-only inspection, -public export/load and the sklearn, XGBoost and ONNX adapters. -Local schema inference and explicit samples are implemented; the CLI is missing. +"Must" denotes a compatibility or security requirement. This pre-release +contract is implemented by the manifest, secure container, inspect/export/load, +sklearn/XGBoost/ONNX adapters, local schema, samples and prediction-state checks. +Version 1 is not yet frozen. The inspect/verify CLI uses these public APIs. The normative container rules are in [Container and resource limits](format-container.md). The normative structure, adapter and result rules are in [Adapters and structure](format-adapters.md). @@ -36,7 +36,7 @@ For version 1, structure extraction is mandatory for supported scikit-learn models exposing `tree_`, tree ensembles with constituent `tree_` estimators, models exposing `coef_`/`intercept_`, and models exposing `feature_importances_`; and for supported XGBoost tree boosters. A member is required whenever the -adapter's extraction returns an object. Other supported sklearn/XGBoost families +adapter's extraction returns an object. Other supported sklearn families return null; ONNX is opaque for visualization in version 1 and returns null. Do not manufacture a visualization for an opaque model. The loaded reader must extract structure even when the member is absent: non-null extraction with a @@ -273,9 +273,9 @@ encoding or mandatory structure shapes requires a new format version and an explicit reader implementation. Tightening caller-selected resource limits does not change the format. Corrections to documentation that preserve wire behavior and implementation changes behind these contracts do not require a version bump. -Phase 5 completes the unreleased v1 schema vocabulary and adds `declared` origin. -Phase 6 completes unreleased v1 prediction-state records, missing tree routing, -native XGBoost routing/dropout and closed state/work rules without a version bump. +The unreleased version 1 includes schema vocabulary, `declared` origin, +prediction-state records, missing tree routing, native XGBoost routing/dropout +and closed state/work rules. It remains subject to change before format freeze. Hashes detect corruption, not deliberate tampering: an attacker can replace a model and update every record. Claim recomputation prevents a bundle from showing diff --git a/pyproject.toml b/pyproject.toml index 64f2653..e0eb851 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,22 +35,27 @@ classifiers = [ requires-python = ">=3.14" dependencies = ["pydantic>=2.13.4,<3"] +[project.scripts] +mlbundle = "mlbundle.cli:main" + [project.optional-dependencies] # sklearn's floor provides get_tags; other floors match the runtime baseline. sklearn = ["scikit-learn>=1.6", "skops>=0.16.0"] xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] -schema = ["mlschema==0.2.1; python_version == '3.14'"] +# The reader validates the 0.2 inferred vocabulary; 0.2.1 requires Python <3.15. +schema = ["mlschema>=0.2.1,<0.3; python_version == '3.14'"] all = [ "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", "onnx>=1.23.0", "onnxruntime>=1.30.0", - "mlschema==0.2.1; python_version == '3.14'", + "mlschema>=0.2.1,<0.3; python_version == '3.14'", ] [project.urls] Homepage = "https://github.com/UlloaSP/mlbundle" Repository = "https://github.com/UlloaSP/mlbundle" Issues = "https://github.com/UlloaSP/mlbundle/issues" +Documentation = "https://github.com/UlloaSP/mlbundle/blob/develop/README.md" [dependency-groups] dev = [ @@ -148,6 +153,8 @@ ignore = ["E501", "PLR0913", "PLR2004", "RET504"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["ARG", "PLR2004", "PLR0124", "PLC0415", "S101", "DTZ"] +# The base/artifact checks must run without importing the sklearn fixture producer. +"scripts/package_smoke.py" = ["PLC0415"] [tool.ruff.lint.isort] known-first-party = ["mlbundle"] diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py new file mode 100644 index 0000000..c64f764 --- /dev/null +++ b/scripts/package_smoke.py @@ -0,0 +1,114 @@ +"""Wheel checks run in separate environments, shared by CI and local verification.""" + +import argparse +import json +import subprocess +import sys +from importlib.util import find_spec +from pathlib import Path +from tarfile import open as open_tar +from zipfile import ZipFile + +_OPTIONAL = { + "sklearn", + "skops", + "xgboost", + "onnx", + "onnxruntime", + "numpy", + "scipy", + "pandas", + "mlschema", +} + + +def _console(*args: str) -> str: + executable = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" + ) + result = subprocess.run( + [executable, *args], capture_output=True, text=True, check=True + ) + assert result.stderr == "", result.stderr + return result.stdout + + +def _sklearn(path: Path) -> None: + from sklearn.tree import DecisionTreeRegressor + + import mlbundle + + model = DecisionTreeRegressor(random_state=0).fit([[0, 1], [1, 0]], [0, 1]) + mlbundle.export(model, path, feature_names=["temperature", "humidity"]) + bundle = mlbundle.load(path) + assert isinstance(bundle.native_model, DecisionTreeRegressor) + assert bundle.predict([[0, 1], [1, 0]]) == [0.0, 1.0] + sys.stdout.write("sklearn wheel: export/load/predict OK\n") + + +def _base(path: Path) -> None: + assert all(find_spec(name) is None for name in _OPTIONAL) + + import mlbundle + + assert mlbundle.__version__ == "0.1.0" + view = mlbundle.inspect(path) + assert view.manifest.model.library == "sklearn" + assert "cannot attest" in _console("--help") + assert "model claims NOT verified" in _console("inspect", str(path)) + assert json.loads( + _console("inspect", str(path), "--json") + ) == view.manifest.model_dump(mode="json", by_alias=True) + try: + mlbundle.load(path) + except mlbundle.MissingOptionalDependencyError as error: + assert error.extra == "sklearn" + sys.stdout.write(f"load: {type(error).__name__}: {error}\n") + else: + raise AssertionError("base installation unexpectedly loaded sklearn") + assert not _OPTIONAL & sys.modules.keys() + sys.stdout.write("base wheel: import/help/inspect OK; optional libraries absent\n") + + +def _contents(directory: Path) -> None: + (wheel,) = directory.glob("mlbundle-*.whl") + (sdist,) = directory.glob("mlbundle-*.tar.gz") + with ZipFile(wheel) as archive: + wheel_names = archive.namelist() + assert "mlbundle/py.typed" in wheel_names + assert "mlbundle/_sklearn/state_fields.json" in wheel_names + assert any(name.endswith(".dist-info/licenses/LICENSE") for name in wheel_names) + assert any(name.endswith(".dist-info/entry_points.txt") for name in wheel_names) + assert all(name.startswith(("mlbundle/", "mlbundle-")) for name in wheel_names) + with open_tar(sdist) as archive: + sdist_names = archive.getnames() + assert any(name.endswith("/src/mlbundle/py.typed") for name in sdist_names) + assert any(name.endswith("/LICENSE") for name in sdist_names) + for name in [*wheel_names, *sdist_names]: + assert not set(Path(name).parts) & {"tests", ".venv", ".git", "__pycache__"}, ( + name + ) + assert not name.endswith((".pyc", ".mlbundle", "coverage.xml")), name + sys.stdout.write( + f"artifacts: {wheel.name}, {sdist.name}; py.typed/catalog/license present; no tests or stray files\n" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["create", "base", "sklearn", "contents"]) + parser.add_argument("path", type=Path) + args = parser.parse_args() + if args.action == "contents": + _contents(args.path) + elif args.action == "base": + _base(args.path) + else: + if args.action == "sklearn": + absent = _OPTIONAL - {"sklearn", "skops", "numpy", "scipy"} + assert all(find_spec(name) is None for name in absent) + _sklearn(args.path) + + +if __name__ == "__main__": + main() diff --git a/src/mlbundle/cli.py b/src/mlbundle/cli.py new file mode 100644 index 0000000..6ed5c10 --- /dev/null +++ b/src/mlbundle/cli.py @@ -0,0 +1,174 @@ +"""Small inspection and verification CLI with bounded, terminal-safe output.""" + +import argparse +import io +import json +import sys +import warnings +from collections.abc import Sequence +from typing import TextIO + +from . import inspect, load +from .contract import MemberName +from .errors import MissingOptionalDependencyError, MLBundleError +from .results import MetadataView + +_DISPLAY_LIMIT = 512 +_NOTICE = ( + "inspect validates metadata and integrity; it cannot attest that metadata " + "matches the model. Only verify loads the model and checks that agreement." +) + + +class _Parser(argparse.ArgumentParser): + def error(self, _message: str) -> None: + # argparse's default echoes arbitrary argv and prints several lines. + self.exit(_write_error("mlbundle: invalid arguments; use --help.", 2)) + + def print_help(self, file: TextIO | None = None) -> None: + if file is None or file is sys.stdout: + _write_output(self.format_help().rstrip("\n")) + else: + super().print_help(file) + + +def _display(value: object) -> str: + # ASCII JSON escapes terminal controls, bidi controls and line separators. + rendered = json.dumps(value, ensure_ascii=True) + if len(rendered) > _DISPLAY_LIMIT: + return rendered[: _DISPLAY_LIMIT - 3] + "..." + return rendered + + +def _summary(view: MetadataView) -> str: + manifest = view.manifest + model = manifest.model + environment = manifest.environment + lines = [ + "Metadata and integrity validated; model claims NOT verified.", + f"Format: mlbundle {manifest.format_version}", + f"Bundle id: {manifest.bundle_id}", + f"Created: {manifest.created_at.isoformat()}", + f"Writer: {_display(manifest.writer_version)}", + f"Model: {model.library.value} {_display(model.estimator_class)} ({model.kind.value})", + f"Counts: features={model.n_features}, outputs={model.n_outputs}, classes={_display(model.n_classes)}", + f"Features ({manifest.features.origin.value}): {_display(manifest.features.names)}", + f"Class labels (output-major): {_display(manifest.target.class_labels)}", + "Members:", + ] + for member in view.members: + record = manifest.integrity.get(member) + if record is None: + lines.append(f" {member.value}: validated (no self-integrity record)") + else: + lines.append( + f" {member.value}: {record.size} bytes sha256={record.sha256}" + ) + lines.extend( + [ + "Training: " + + _display( + { + "python": environment.python_version, + "implementation": environment.python_implementation, + "os": environment.os, + "os_version": environment.os_version, + "architecture": environment.architecture, + } + ), + f"Packages: {_display(environment.packages)}", + f"ONNX environment: {_display(None if environment.onnx is None else environment.onnx.model_dump(mode='json'))}", + "Present: " + + ", ".join( + f"{name}={'yes' if MemberName(name + '.json') in view.members else 'no'}" + for name in ("schema", "structure", "samples") + ), + f"User metadata: {_display(manifest.user_metadata)}", + ] + ) + return "\n".join(lines) + + +def _parser() -> argparse.ArgumentParser: + parser = _Parser(prog="mlbundle", description=_NOTICE) + commands = parser.add_subparsers(dest="command", required=True) + inspection = commands.add_parser( + "inspect", help="validate metadata without loading a model", description=_NOTICE + ) + inspection.add_argument("file", help="bundle path") + inspection.add_argument( + "--json", + action="store_true", + help="print the full validated manifest as escaped JSON", + ) + verification = commands.add_parser( + "verify", + help="load and verify native model claims and structure", + description=_NOTICE + " verify requires the matching library extra.", + ) + verification.add_argument("file", help="bundle path") + parser.epilog = ( + "Exit codes: 0 success; 1 bundle/dependency rejection; 2 usage or I/O error." + ) + return parser + + +def _write_output(output: str) -> None: + try: + sys.stdout.write(output + "\n") + sys.stdout.flush() + except OSError: + # Prevent a second failed flush, with unbounded text/exit 120, at shutdown. + sys.stdout = io.StringIO() + raise + + +def _write_error(message: str, code: int) -> int: + try: + sys.stderr.write(message + "\n") + sys.stderr.flush() + except OSError: + sys.stderr = io.StringIO() + return 2 + return code + + +def main(argv: Sequence[str] | None = None) -> int: + try: + args = _parser().parse_args(argv) + if args.command == "inspect": + view = inspect(args.file) + output = ( + json.dumps( + view.manifest.model_dump(mode="json", by_alias=True), + ensure_ascii=True, + indent=2, + ) + if args.json + else _summary(view) + ) + else: + # Version warnings may include native content; CLI errors are bounded. + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + bundle = load(args.file) + output = ( + f"Valid bundle: {bundle.library.value} {_display(bundle.class_name)}; " + "verified format, integrity, schema/samples and native model claims/structure." + ) + _write_output(output) + except MissingOptionalDependencyError as error: + return _write_error( + f"mlbundle: missing optional dependency; install mlbundle[{error.extra}].", + 1, + ) + except MLBundleError as error: + # Report the failure category, never exception text derived from a bundle. + return _write_error(f"mlbundle: {type(error).__name__}: bundle rejected.", 1) + except OSError as error: + return _write_error(f"mlbundle: {type(error).__name__}: I/O failed.", 2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..20747f8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,270 @@ +"""Real CLI reads, verification, safe display, errors and lazy inspection.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from sklearn.tree import DecisionTreeClassifier +from xgboost import XGBClassifier + +import mlbundle +from mlbundle import MemberName +from mlbundle.cli import main +from mlbundle.container import read_container +from mlbundle.writing import write_container +from onnx_helpers import softmax + +_CONSOLE = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" +) + + +@pytest.fixture(params=["sklearn", "xgboost", "onnx"]) +def bundle_path(request, tmp_path): + rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] + model = { + "sklearn": lambda: DecisionTreeClassifier(random_state=0).fit( + rows, [0, 1, 0, 1] + ), + "xgboost": lambda: XGBClassifier(n_estimators=1, n_jobs=1).fit( + rows, [0, 1, 0, 1] + ), + "onnx": softmax, + }[request.param]() + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path, metadata={"name": "CLI fixture"}) + return path + + +def test_inspect_and_verify(bundle_path, capsys): + view = mlbundle.inspect(bundle_path) + assert main(["inspect", str(bundle_path)]) == 0 + output = capsys.readouterr() + assert output.err == "" + for text in ( + "model claims NOT verified", + "Format: mlbundle 1", + str(view.manifest.bundle_id), + "Writer:", + view.manifest.model.library.value, + "Counts: features=2, outputs=1, classes=[2]", + "Features (generated):", + "Class labels (output-major):", + "Training:", + "Packages:", + "schema=no", + "samples=no", + ): + assert text in output.out + for member, record in view.manifest.integrity.items(): + assert ( + f"{member.value}: {record.size} bytes sha256={record.sha256}" in output.out + ) + assert main(["verify", str(bundle_path)]) == 0 + output = capsys.readouterr() + assert output.err == "" and len(output.out.splitlines()) == 1 + assert ( + "Valid bundle:" in output.out and "native model claims/structure" in output.out + ) + + +def test_console_entry_and_json(bundle_path): + result = subprocess.run( + [_CONSOLE, "inspect", str(bundle_path), "--json"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 and result.stderr == "" + assert ( + mlbundle.Manifest.from_bytes(result.stdout.encode()) + == mlbundle.inspect(bundle_path).manifest + ) + + +@pytest.mark.parametrize( + "args", + [[], ["unknown"], ["inspect"], ["verify", "file", "--json"], ["\x1b[31m\n" * 1000]], +) +def test_usage_error_is_one_safe_line(args, capsys): + with pytest.raises(SystemExit) as error: + main(args) + assert error.value.code == 2 + output = capsys.readouterr() + assert ( + output.out == "" and output.err == "mlbundle: invalid arguments; use --help.\n" + ) + + +@pytest.mark.parametrize( + "args", [["--help"], ["inspect", "--help"], ["verify", "--help"]] +) +def test_help_explains_inspection_limits(args, capsys): + with pytest.raises(SystemExit) as error: + main(args) + assert error.value.code == 0 + text = capsys.readouterr().out + assert "cannot attest" in text and "Only verify" in text + + +@pytest.mark.parametrize("command", ["inspect", "verify"]) +def test_io_and_rejected_bundle(command, tmp_path, capsys): + assert main([command, str(tmp_path / "missing")]) == 2 + output = capsys.readouterr() + assert ( + output.out == "" and output.err == "mlbundle: FileNotFoundError: I/O failed.\n" + ) + path = tmp_path / "invalid.mlbundle" + path.write_bytes(b"secret\x1b[31m\n" * 1000) + assert main([command, str(path)]) == 1 + output = capsys.readouterr() + assert ( + output.out == "" + and output.err == "mlbundle: InvalidContainerError: bundle rejected.\n" + ) + + +def test_verify_rejects_lie_that_inspect_accepts(bundle_path, capsys): + manifest, members = read_container(bundle_path) + parts = manifest.model_dump(mode="json") + parts["model"]["estimator_class"] = "secret\x1b[31m" + del members[MemberName.MANIFEST] + bundle_path.write_bytes(write_container(parts, members)) + assert main(["inspect", str(bundle_path)]) == 0 + capsys.readouterr() + assert main(["verify", str(bundle_path)]) == 1 + output = capsys.readouterr() + assert ( + output.out == "" + and output.err == "mlbundle: ClaimMismatchError: bundle rejected.\n" + ) + + +def test_missing_extra_in_subprocess(bundle_path): + script = """ +import sys +class WithoutModels: + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in {'sklearn', 'skops', 'xgboost', 'onnx', 'onnxruntime'}: + raise ModuleNotFoundError(name=fullname) +sys.meta_path.insert(0, WithoutModels()) +from mlbundle.cli import main +sys.exit(main(['verify', sys.argv[1]])) +""" + result = subprocess.run( + [sys.executable, "-I", "-c", script, str(bundle_path)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 and result.stdout == "" + assert ( + result.stderr + == f"mlbundle: missing optional dependency; install mlbundle[{mlbundle.inspect(bundle_path).manifest.model.library.value}].\n" + ) + + +@pytest.mark.parametrize("json_output", [False, True]) +def test_inspect_never_imports_model_libraries(bundle_path, json_output): + script = """ +import sys +blocked = {'sklearn', 'skops', 'numpy', 'scipy', 'pandas', 'mlschema', 'xgboost', 'onnx', 'onnxruntime'} +class BlockModels: + def find_spec(self, fullname, path=None, target=None): + if fullname.split('.')[0] in blocked: + raise AssertionError(fullname) +sys.meta_path.insert(0, BlockModels()) +from mlbundle.cli import main +assert main(['inspect', *sys.argv[1:]]) == 0 +assert not blocked & sys.modules.keys() +""" + result = subprocess.run( + [ + sys.executable, + "-I", + "-c", + script, + str(bundle_path), + *(["--json"] if json_output else []), + ], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + +def test_terminal_controls_and_display_bounds(tmp_path, capsys): + label = "label\n\x1b[31m\r\x7f\x9b\u202e\u2028" + model = DecisionTreeClassifier().fit([[0], [1]], [label, "other"]) + path = tmp_path / "controls.mlbundle" + metadata = {"\x1b[31m": "x" * 10_000 + "\n\r\x9b"} + mlbundle.export(model, path, feature_names=[label], metadata=metadata) + assert main(["inspect", str(path)]) == 0 + output = capsys.readouterr().out + assert max(map(len, output.splitlines())) < 600 + assert "..." in output and "\\u001b" in output and "\\u202e" in output + assert all(ord(char) >= 32 and ord(char) < 127 for char in output if char != "\n") + assert main(["inspect", str(path), "--json"]) == 0 + output = capsys.readouterr().out + assert json.loads(output)["user_metadata"] == metadata + assert json.loads(output)["target"]["class_labels"] == [[label, "other"]] + assert all(32 <= ord(char) < 127 for char in output if char != "\n") + + +@pytest.mark.parametrize("help_output", [False, True]) +def test_closed_output_pipe_is_one_io_error(bundle_path, help_output): + args = ["--help"] if help_output else ["inspect", str(bundle_path)] + with subprocess.Popen( + [_CONSOLE, *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) as process: + assert process.stdout is not None and process.stderr is not None + process.stdout.close() + assert process.wait(timeout=30) == 2 + error = process.stderr.read() + assert error in { + "mlbundle: OSError: I/O failed.\n", + "mlbundle: BrokenPipeError: I/O failed.\n", + } + + +@pytest.mark.parametrize("failure", ["usage", "io", "bundle"]) +def test_closed_error_pipe_exits_two(failure, tmp_path): + path = tmp_path / "invalid.mlbundle" + path.write_bytes(b"invalid bundle") + args = { + "usage": ["unknown"], + "io": ["inspect", str(tmp_path / "missing")], + "bundle": ["verify", str(path)], + }[failure] + with subprocess.Popen( + [_CONSOLE, *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as process: + assert process.stdout is not None and process.stderr is not None + process.stderr.close() + assert process.wait(timeout=30) == 2 + assert process.stdout.read() == b"" + + +def test_summary_includes_schema_and_samples(tmp_path, capsys): + import pandas as pd + + frame = pd.DataFrame({"a": [0, 1]}) + path = tmp_path / "schema.mlbundle" + mlbundle.export( + DecisionTreeClassifier().fit(frame, [0, 1]), + path, + training_data=frame, + sample_rows=1, + ) + assert main(["inspect", str(path)]) == 0 + output = capsys.readouterr().out + assert "Features (model):" in output + assert "schema=yes, structure=yes, samples=yes" in output diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..f3e2906 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,49 @@ +"""Execute the actual fenced README examples, including the console commands.""" + +import re +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest + +from onnx_helpers import softmax + +README = Path(__file__).resolve().parents[1] / "README.md" +BLOCKS = re.findall( + r"```(\w+)\n(.*?)\n```", README.read_text(encoding="utf-8"), re.DOTALL +) +PYTHON = [code for language, code in BLOCKS if language == "python"] + + +@pytest.mark.parametrize( + "code", PYTHON, ids=["sklearn", "xgboost", "onnx", "schema-samples"] +) +def test_python_example(code, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + if 'Path("trained.onnx")' in code: + # The user's prerequisite file: a self-contained, two-column classifier. + Path("trained.onnx").write_bytes(softmax().SerializeToString()) + exec(compile(code, str(README), "exec"), {}) + + +def test_console_examples(tmp_path, monkeypatch): + assert len(PYTHON) == 4 + assert all(language in {"python", "shell"} for language, _ in BLOCKS) + monkeypatch.chdir(tmp_path) + exec(compile(PYTHON[0], str(README), "exec"), {}) + executable = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" + ) + for language, code in BLOCKS: + if language != "shell": + continue + for line in code.splitlines(): + command, *args = shlex.split(line) + assert command == "mlbundle" + result = subprocess.run( + [executable, *args], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + assert result.stdout and not result.stderr diff --git a/tests/test_schema.py b/tests/test_schema.py index 4d0d760..d3f16ed 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -274,16 +274,3 @@ def test_schema_reference_presence(missing, frame, tmp_path): for reader in (mlbundle.inspect, mlbundle.load): with pytest.raises(mlbundle.InvalidContainerError): reader(bundle) - - -def test_readme_training_example(tmp_path, monkeypatch): - import re - from pathlib import Path - - readme = Path(__file__).resolve().parents[1] / "README.md" - blocks = re.findall( - r"```python\n(.*?)\n```", readme.read_text(encoding="utf-8"), re.DOTALL - ) - example = next(block for block in blocks if "training_data=training" in block) - monkeypatch.chdir(tmp_path) - exec(compile(example, str(readme), "exec"), {}) diff --git a/tests/test_skops_state.py b/tests/test_skops_state.py index ba203d0..4fdb029 100644 --- a/tests/test_skops_state.py +++ b/tests/test_skops_state.py @@ -1,6 +1,8 @@ """Unknown estimator/nested state and unreferenced files are rejected on both paths.""" import io +import json +import warnings from types import SimpleNamespace import numpy as np @@ -77,3 +79,21 @@ def change(schema, entries): with pytest.raises(InvalidModelError, match="node state"): Adapter().deserialize(alter_schema(sio.dumps(model), change)) + + +@pytest.mark.parametrize("version", ["1.8.0", "9.0.0"]) +def test_different_sklearn_version_fails_native_state_comparison(version): + model = LinearRegression().fit([[0], [1]], [0, 1]) + + def change(schema, entries): + schema["content"]["content"]["_sklearn_version"]["content"] = json.dumps( + version + ) + + payload = alter_schema(sio.dumps(model), change) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with pytest.raises( + InvalidModelError, match="does not survive native construction" + ): + Adapter().deserialize(payload) diff --git a/tests/test_xgboost.py b/tests/test_xgboost.py index cf0f964..89d8d15 100644 --- a/tests/test_xgboost.py +++ b/tests/test_xgboost.py @@ -155,22 +155,3 @@ def test_prediction_settings_and_dropout(xgb_data, tmp_path): assert np.array_equal( loaded.structure["feature_importances"], model.feature_importances_ ) - - -def test_readme_example(tmp_path): - rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] - model = xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit( - rows, [0, 1, 0, 1] - ) - path = tmp_path / "xgboost.mlbundle" - mlbundle.export( - model, - path, - feature_names=["temperature", "humidity"], - class_labels=[["cold", "warm"]], - ) - bundle = mlbundle.load(path) - assert isinstance(bundle.native_model, xgb.XGBClassifier) - assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() - assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() - assert bundle.class_labels == [["cold", "warm"]] diff --git a/uv.lock b/uv.lock index 5b4c7a4..b2885cc 100644 --- a/uv.lock +++ b/uv.lock @@ -245,8 +245,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'all'", specifier = "==0.2.1" }, - { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'schema'", specifier = "==0.2.1" }, + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'all'", specifier = ">=0.2.1,<0.3" }, + { name = "mlschema", marker = "python_full_version == '3.14.*' and extra == 'schema'", specifier = ">=0.2.1,<0.3" }, { name = "onnx", marker = "extra == 'all'", specifier = ">=1.23.0" }, { name = "onnx", marker = "extra == 'onnx'", specifier = ">=1.23.0" }, { name = "onnxruntime", marker = "extra == 'all'", specifier = ">=1.30.0" }, From 3e92ab53e29b23d9fe9d9dc05b28513d96eaa288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 13:38:11 +0200 Subject: [PATCH 09/15] fix: support real ONNX and MLP models (#11) Derive ONNX wire counts from byte bounds and reuse one validated description per operation. Batch wire checks without dropping descriptor or scalar validation. Retain Adam/SGD optimizer state through closed skops trust and loader catalogs, committing every attribute to the fitted-state digest. --- AGENTS.md | 10 +- README.md | 8 +- SECURITY.md | 28 +++- docs/format-adapters.md | 10 +- docs/format-onnx.md | 32 ++++- docs/format-state.md | 19 ++- docs/format.md | 2 +- src/mlbundle/_onnx/__init__.py | 32 ++++- src/mlbundle/_onnx/_wire_runs.py | 103 ++++++++++++++ src/mlbundle/_onnx/description.py | 21 ++- src/mlbundle/_onnx/protobuf.py | 26 +++- src/mlbundle/_onnx/validation.py | 2 +- src/mlbundle/_sklearn/__init__.py | 5 +- src/mlbundle/_sklearn/nodes.py | 5 + src/mlbundle/_sklearn/state_fields.json | 2 + src/mlbundle/_sklearn/trusted.py | 9 ++ src/mlbundle/environment.py | 4 +- tests/test_onnx_compatibility.py | 158 +++++++++++++++++++++ tests/test_onnx_wire.py | 107 ++++++++++++++ tests/test_sklearn_mlp.py | 181 ++++++++++++++++++++++++ 20 files changed, 723 insertions(+), 41 deletions(-) create mode 100644 src/mlbundle/_onnx/_wire_runs.py create mode 100644 src/mlbundle/_sklearn/trusted.py create mode 100644 tests/test_onnx_compatibility.py create mode 100644 tests/test_onnx_wire.py create mode 100644 tests/test_sklearn_mlp.py diff --git a/AGENTS.md b/AGENTS.md index b84cd5d..c62e8db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ still preserve validation, security and reproducibility. - Never use pickle, joblib or cloudpickle, even as an option or fallback. - Never import or instantiate from strings in the manifest. The class to instantiate must come from a closed list inside mlbundle. -- The skops trusted-types list lives in mlbundle. Never read it from a bundle. +- The skops trusted-types list (Tree, AdamOptimizer, SGDOptimizer) lives in mlbundle; never read it from a bundle. Optimizers admit ObjectNode/CachedNode only. - Export must fail on the user's machine when the model contains custom types, listing unsupported types from `skops.io.get_untrusted_types`. Do not defer discovery to upload time or offer to trust those types. @@ -212,9 +212,9 @@ labels use encoded indices; sklearn rejects this option because it owns classes_ Local `training_data=` inference uses mlschema with `__`, excludes a named target, and records training dtypes. Only explicit `sample_rows=N` includes real rows. Schema/report and sample validation is shared by export, inspect and load. -ONNX preserves path/bytes unchanged (ModelProto is serialized once), rejects -external tensors, custom domains, functions and training/runtime configuration -before a CPU session, and verifies graph signatures, metadata and environment +ONNX preserves path/bytes unchanged (ModelProto is serialized once), validates +bytes once per export/load with bounded scalar scans, and rejects external tensors, +custom domains, functions and training/runtime configuration before a CPU session. It verifies graph signatures, metadata and environment claims. `native_model` is an OnnxModel with original bytes and a CPU session. Kind is inferred from supported outputs; native labels are verified, absent labels use count-checked declarations. Structure is null. See the normative @@ -266,7 +266,7 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. - `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. - `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. -- `src/mlbundle/_sklearn/` — skops trust/loading, parameter and claim extraction, native structure, nested payload bounds and validated prediction. +- `src/mlbundle/_sklearn/` — skops trust/loading (including MLP adam/sgd/lbfgs state), parameter and claim extraction, native structure, nested payload bounds and validated prediction. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. - `src/mlbundle/declarations.py` — export-time class labels and resolution for native models without labels. diff --git a/README.md b/README.md index 85876e4..debfc5b 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,9 @@ entry alone does not guarantee that every fitted configuration exports. LogisticRegression, LinearRegression, Ridge, Bayesian/ARD, ElasticNet/Lasso, Huber, Poisson/Gamma, SGD; SVC with probabilities, SVR/NuSVR/LinearSVR; PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, - NearestCentroid, MLP, MultiOutput wrappers and RegressorChain. Scaler and + NearestCentroid, MLPClassifier/MLPRegressor (adam, sgd, lbfgs), MultiOutput + wrappers and RegressorChain. MLPs retain optimizer state, work alone/in pipelines + and have null structure with complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). Scaler and dense OneHotEncoder pipelines round-trip; pipeline structure stays null because format 1 does not map transformed features. See the [tested configurations](tests/test_sklearn_families.py) and @@ -245,6 +247,10 @@ entry alone does not guarantee that every fitted configuration exports. batches above one, multiple targets, external tensors, custom domains/local functions and training/runtime configuration are rejected before a CPU session. Tested with ONNX 1.23.0/Runtime 1.30.0; actual operators/opsets must pass both. + Large unpacked TreeEnsembleClassifier/ZipMap exports and multiline producer + versions work. Wire validation runs once per export/load; the field/scalar + ceiling follows the 512 MiB member bound, producer strings allow 65,536 characters. + See [the ONNX bounds](docs/format-onnx.md). Archive ceilings: **5 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. diff --git a/SECURITY.md b/SECURITY.md index 7625af4..9ef3acd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,12 +57,21 @@ serialization path. Adapters must use lazy imports for optional dependencies. ## Implemented sklearn trust boundary Export and load call skops' `get_untrusted_types` and reject all types outside -the fixed supplemental list `["sklearn.tree._tree.Tree"]`. Load passes exactly -that list to `skops.io.loads`, alongside skops' built-in trusted defaults. This -entry is native numeric tree storage required by DecisionTree, RandomForest, -ExtraTrees and GradientBoosting. It does not add arbitrary functions or I/O +the fixed supplemental list `sklearn.tree._tree.Tree`, +`sklearn.neural_network._stochastic_optimizers.AdamOptimizer` and +`sklearn.neural_network._stochastic_optimizers.SGDOptimizer`. Load passes exactly +that list to `skops.io.loads`, alongside skops' built-in trusted defaults. These +are native numeric tree storage and fitted MLP optimizer records. They add no arbitrary functions or I/O constructors. The bundle cannot provide additional trusted types. +The optimizer path was checked against skops 0.16.0/sklearn 1.9.1: ObjectNode +uses ordinary `object.__new__` and `__dict__.update`, bypassing constructors. +Neither optimizer defines allocation, setstate or reduce hooks. Only ObjectNode +and shared CachedNode references are admitted for them; alternative constructor +loaders fail before skops. Their closed attributes and numeric buffers use the +existing pre-load resource bounds and full state commitment. MLP weights and all +fitted state remain committed, with no MLP visualization member. + Skops 0.16.0 deliberately distrusts tree storage because unchecked child and feature indices can crash native inference. mlbundle checks loaded tree dimensions, indices, reachability, depth and owning-estimator dimensions before computing @@ -132,7 +141,13 @@ resource boundaries in [docs/format.md](docs/format.md). Original self-contained ONNX bytes are parsed and loaded in memory. Before any ONNX Runtime session, a descriptor-driven protobuf scan bounds nesting and field -counts, validates lengths and rejects unknown/duplicate singular fields. A full +counts, validates lengths and rejects unknown/duplicate singular fields. The +count ceiling is 536,870,912 (formerly 1,000,000), derived from the 512 MiB byte +ceiling: each header/packed scalar costs at least one distinct byte. It permits +large unpacked ensembles and does not bound decoded protobuf object memory. +Repeated scalars use bounded 64 KiB NumPy/C regex scans with the same wire checks; every +header/scalar still counts. One description is retained per export/load by +immutable bytes identity, never globally or from bundle metadata. A full message traversal rejects every external tensor reference, including initializers, attributes, sparse tensors, nested graphs and functions; checker and runtime never receive external-data models. Tensor dimensions/storage and graph ranks/nodes are @@ -152,6 +167,9 @@ Graph signatures, metadata, kind/counts, model dtypes, producer/opsets/IR and na labels are recomputed and canonically compared. A label-free graph's original class mapping remains an author declaration, checked only against native counts. ONNX structure is null; an added visualization member is a claim mismatch. +Producer strings allow 65,536 characters each; bound failures identify a closed +field and its limit without exposing author text. Other string/projection bounds +remain as specified in the ONNX contract. The protobuf parser, ONNX checker and ONNX Runtime execute native code. Resource checks reduce malformed inputs; they do not establish memory safety or cap native diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 34b99bf..8ea69fa 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -83,13 +83,19 @@ including a present JSON null. Malformed native payloads raise `InvalidModelErro an `MLBundleError` subclass, without native exception text. The sklearn adapter uses only `skops.io.dumps/loads`. Its fixed supplemental -trusted list is exactly `["sklearn.tree._tree.Tree"]`, for numeric node storage -in the supported tree families; it adds no callable, arbitrary class or I/O type. +trusted list is exactly `sklearn.tree._tree.Tree`, +`sklearn.neural_network._stochastic_optimizers.AdamOptimizer` and +`sklearn.neural_network._stochastic_optimizers.SGDOptimizer`: numeric tree storage +and the fitted MLP solver records. It adds no callable, arbitrary class or I/O type. Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected at export and independently at load. XGBoost estimators inside sklearn pipelines are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). The native XGBoost adapter owns standalone XGBoost loading. +MLPClassifier/MLPRegressor support adam, sgd and lbfgs, alone or in a Pipeline. +MLP structure is null; layer weights and complete fitted/optimizer state are +bound by the manifest state commitment. Optimizers permit ObjectNode only +(plus shared CachedNode references), never a constructor/reduce loader. The nested skops archive is bounded before parsing; every schema-referenced numeric array header is checked regardless of filename suffix. Headers must match the stored byte sizes and cannot use object-array loading or zero-itemsize diff --git a/docs/format-onnx.md b/docs/format-onnx.md index da3897d..d6a8074 100644 --- a/docs/format-onnx.md +++ b/docs/format-onnx.md @@ -98,15 +98,36 @@ the writer's extraction code and compared by exact canonical bytes. Historical environment, training dtypes, supplied matrix feature names, target name and label-free mappings remain declarations. +Producer name/version each allow 0–65,536 characters, including multiline build +information ([CatBoost writes its build record](https://github.com/catboost/catboost/blob/master/catboost/libs/model/model_export/onnx_helpers.cpp) as the version). This bound +also admits the short tool/release strings written by [skl2onnx](https://github.com/onnx/sklearn-onnx/blob/main/skl2onnx/__init__.py), [onnxmltools](https://github.com/onnx/onnxmltools/blob/main/onnxmltools/__init__.py) and +[PyTorch](https://github.com/pytorch/pytorch/blob/main/torch/onnx/_internal/exporter/_core.py). Opset domains retain their 256-character ceiling and closed domain set. +Symbolic dimensions, verified column names and string class labels retain the +shared 1,024-character ceiling. Matrix input/output names and metadata keys/values +have no separate character ceiling: their full projection shares the 1 MiB +hyperparameter budget. Graph/node names, model domain and documentation are not +claims and remain bounded by member bytes. String-limit errors identify the +closed offending claim field and numeric limit without echoing its value; +projection-limit errors name inputs/outputs/metadata_props and their byte/depth cap. + ## Before any native session Both export and load perform the same checks, in this order: 1. Bound bytes to the 512 MiB native-member ceiling (archive/caller limits also apply). Scan protobuf wire fields through the installed ONNX descriptors - before construction: depth at most 32, at most 1,000,000 fields/scalar values, + before construction: depth at most 32, at most 536,870,912 fields/scalar values, valid lengths/wire types/varints/packed fields, no unknown fields or duplicate - singular fields. This rejects hidden content and parser amplification tricks. + singular fields. Each field header and packed scalar consumes at least one + distinct byte, so this count cannot exceed the admitted member bytes. Unlike + the former 1,000,000 ceiling, it admits large unpacked tree attributes up to + the byte ceiling; it does not cap protobuf's decoded object memory. Repeated + scalars use bounded 64 KiB NumPy scans with C regex fallback for short/irregular + runs; equal repeated strings/bytes up to 4 KiB use C regex. Packed varints use + C regex with NumPy counts. A 64-byte regex probe gates vector scans, whose + windows grow from 128 bytes only after proportional progress to avoid + overlapping work on short alternating fields. Other fields use the descriptor + walker. Batching preserves every wire check and counts every scalar/header. 2. Parse in-memory with ModelProto. Visit every message, including sparse tensors, tensor attributes, nested graphs and functions. Reject any tensor with an external-data entry or non-default data location, segments or unsupported @@ -128,6 +149,13 @@ Both export and load perform the same checks, in this order: layout and build bounded strict claims before creating any runtime session. Terminal LinearRegressor/TreeEnsembleRegressor native target counts must be one. +The immutable bytes are scanned, parsed and described once per export or load. +Each newly created adapter retains one description by bytes-object identity for +that operation; there is no global cache or author-supplied description. Export +still creates and validates independent CPU sessions for preparation and its +native round trip. Load compares the recomputed description with the manifest +and constructs its predictor from that same validated description. + Create ONNX Runtime from those bytes with CPUExecutionProvider only, sequential execution, one intra/inter-op thread, disabled graph optimization/prepacking, CPU arena and memory patterns. This limits load-time work and retained buffers; diff --git a/docs/format-state.md b/docs/format-state.md index bcaea8c..03350e8 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -10,10 +10,11 @@ Skops' type audit is necessary but does not define which attributes belong to an estimator. mlbundle additionally owns a closed class/attribute catalog in `_sklearn/state_fields.json`, audited against scikit-learn 1.9.1. It covers public estimator constructor parameters, inherited and fitted attributes, shared fit -helpers and the admitted loss/link internals. It is package data, never supplied +helpers and the admitted loss/link/MLP optimizer internals. It is package data, never supplied by a bundle and never used to import or construct a class. Unknown object types, attributes, nested estimator attributes, node keys, loaders and unreferenced files -are rejected. The supplemental skops trusted list remains Tree only. A catalog +are rejected. The supplemental skops trusted list contains Tree, AdamOptimizer +and SGDOptimizer only (exact paths in [the adapter contract](format-adapters.md)). A catalog entry does not extend skops' trust or guarantee that every configuration exports. The audited/tested baseline is scikit-learn 1.9.1 with skops 0.16.0; the @@ -35,6 +36,20 @@ from the per-output class counts. Missing directions are zero or one. Estimator classes must be the exact installed native class, rather than a subclass impersonating its module and name. This rule also applies before export. +MLPClassifier and MLPRegressor retain their native fitted state for all three +solvers, standalone and in pipelines. AdamOptimizer fields are `beta_1`, `beta_2`, +`epsilon`, `learning_rate`, `learning_rate_init`, `ms`, `t`, `vs`; SGDOptimizer +fields are `learning_rate`, `learning_rate_init`, `lr_schedule`, `momentum`, +`nesterov`, `power_t`, `velocities`. Every field uses the same schema/reference/ +array budgets and complete commitment as other sklearn state; extra fields fail. +Skops 0.16.0 ObjectNode uses `object.__new__` then `__dict__.update` for these +classes. They have no custom allocation, `__setstate__` or reduce hook; their +constructors and training update methods are never called on load. Other loaders +for these classes are rejected before skops construction. MLP structure remains +null, with coefficients, intercepts, layer/output/activation state, label binarizer, +random state, optimizer buffers and training/validation histories committed in +`model.state_sha256`. Pipelines commit their complete nested state the same way. + Every admitted state field participates in a complete state commitment, including fields omitted from visualization and optional training-only state. Deserialization also compares the payload commitment with a fresh skops serialization of the diff --git a/docs/format.md b/docs/format.md index a75dc7a..22f312e 100644 --- a/docs/format.md +++ b/docs/format.md @@ -181,7 +181,7 @@ ONNX/ONNX Runtime package versions; they are not evidence of compatibility. The ONNX record contains required fields: `opsets` (1–128 objects with `domain`, a string of 0–256 characters, and `version`, an integer 1–2^31−1), `ir_version` -(integer 1–2^31−1), `producer_name` and `producer_version` (strings 0–256). +(integer 1–2^31−1), `producer_name` and `producer_version` (strings 0–65,536). Opset domains are unique and sorted by Unicode code point. All these graph fields are verified claims. Matching producer text proves agreement with the file, not who produced or trained it. diff --git a/src/mlbundle/_onnx/__init__.py b/src/mlbundle/_onnx/__init__.py index da7293d..37bf0cb 100644 --- a/src/mlbundle/_onnx/__init__.py +++ b/src/mlbundle/_onnx/__init__.py @@ -6,7 +6,7 @@ from typing import Any import onnx -from pydantic import JsonValue +from pydantic import JsonValue, ValidationError from mlbundle.claims import ModelClaims from mlbundle.contract import Library, MemberName @@ -33,6 +33,17 @@ def _description(data: bytes) -> Description: return describe(parse_model(data)) except MLBundleError: raise + except ValidationError as error: + failure = error.errors(include_input=False, include_url=False)[0] + # Disclose closed field names, never author-supplied keys or values. + fields = {"producer_name", "producer_version", "feature_names", "class_labels"} + field = next((part for part in failure["loc"] if part in fields), None) + if field is not None and failure["type"] == "string_too_long": + limit = failure.get("ctx", {})["max_length"] + raise InvalidModelError( + f"ONNX {field} exceeds the {limit}-character limit." + ) from None + raise InvalidModelError("Invalid ONNX model claims.") from None except Exception: raise InvalidModelError("Invalid ONNX model claims.") from None @@ -41,6 +52,17 @@ class Adapter: library = Library.ONNX model_member = MemberName.ONNX + def __init__(self) -> None: + self._data: bytes | None = None + self._validated: Description | None = None + + def _describe(self, data: bytes) -> Description: + # An adapter belongs to one export/load, never a global cache or bundle. + if data is not self._data or self._validated is None: + description = _description(data) + self._data, self._validated = data, description + return self._validated + def recognizes(self, model: object) -> bool: return isinstance(model, (bytes, onnx.ModelProto, OnnxModel)) or ( isinstance(model, (str, PathLike)) and Path(model).suffix.lower() == ".onnx" @@ -63,7 +85,7 @@ def prepare_export(self, model: object, declarations: ExportDeclarations) -> obj raise LimitExceededError("ONNX model exceeds the byte limit.") with path.open("rb") as stream: data = stream.read(cap + 1) - description = _description(data) + description = self._describe(data) resolve_labels(description.claims, declarations) return self.deserialize(data) except MLBundleError: @@ -75,7 +97,7 @@ def serialize(self, model: Any) -> bytes: return model.data def deserialize(self, data: bytes) -> OnnxModel: - description = _description(data) + description = self._describe(data) try: return OnnxModel(data, create_session(data, description)) except MLBundleError: @@ -84,10 +106,10 @@ def deserialize(self, data: bytes) -> OnnxModel: raise InvalidModelError("ONNX CPU session creation failed.") from None def extract_claims(self, model: Any) -> ModelClaims: - return _description(model.data).claims + return self._describe(model.data).claims def extract_structure(self, model: object) -> dict[str, JsonValue] | None: # noqa: ARG002 return None def predictor(self, model: Any) -> Predictor: - return Predictor(model.session, _description(model.data)) + return Predictor(model.session, self._describe(model.data)) diff --git a/src/mlbundle/_onnx/_wire_runs.py b/src/mlbundle/_onnx/_wire_runs.py new file mode 100644 index 0000000..680310f --- /dev/null +++ b/src/mlbundle/_onnx/_wire_runs.py @@ -0,0 +1,103 @@ +"""Bounded C regex scans of repeated wire scalars; no protobuf objects allocated.""" + +import re +from functools import lru_cache + +import numpy as np + +# Match exactly the same uint64 encodings accepted by the scalar walker, +# including overlong encodings; byte ten may contain only bit zero. +VARINT = rb"(?:[\x00-\x7f]|[\x80-\xff]{1,8}[\x00-\x7f]|[\x80-\xff]{9}[\x00-\x01])" +CHUNK_BYTES = 64 * 1024 +PACKED = re.compile(rb"(?:" + VARINT + rb")+") + + +@lru_cache(maxsize=128) +def _pattern(tag: bytes, wire: int, value: bytes): + scalar = VARINT if wire == 0 else rb".{%d}" % (8 if wire == 1 else 4) + unit = re.escape(tag) + scalar if wire != 2 else re.escape(value) + return re.compile(rb"(?:" + unit + rb")+", re.DOTALL) + + +def _varint_chunk(data: bytes, start: int, end: int, tag: bytes): + values = np.frombuffer(data, dtype=np.uint8, count=end - start, offset=start) + terminals = np.flatnonzero(values < 128) + count = len(terminals) // 2 + if not count: + return start, 0 + pairs = terminals[: count * 2].reshape(-1, 2) + starts = np.concatenate(([0], pairs[:-1, 1] + 1)) + valid = pairs[:, 0] == starts + len(tag) - 1 + for offset, byte in enumerate(tag): + # Clipping is only for malformed, too-short tags; their offset check fails. + valid &= values[np.minimum(starts + offset, len(values) - 1)] == byte + widths = pairs[:, 1] - pairs[:, 0] + valid &= (widths <= 10) & ((widths != 10) | (values[pairs[:, 1]] <= 1)) + invalid = np.flatnonzero(~valid) + count = int(invalid[0]) if len(invalid) else count + return start + int(pairs[count - 1, 1]) + 1 if count else start, count + + +def _fixed_chunk(data: bytes, start: int, end: int, tag: bytes, wire: int): + size = len(tag) + (8 if wire == 1 else 4) + count = (end - start) // size + values = np.frombuffer( + data, dtype=np.uint8, count=count * size, offset=start + ).reshape(-1, size) + valid = np.all(values[:, : len(tag)] == np.frombuffer(tag, dtype=np.uint8), axis=1) + invalid = np.flatnonzero(~valid) + count = int(invalid[0]) if len(invalid) else count + return start + count * size, count + + +def repeated( + data: bytes, start: int, end: int, *, value_start: int, stop: int, wire: int +): + """Include the already validated first field, then scan equivalent wire units.""" + if wire == 2 and stop - start > 4096: + return stop, 1 + tag = data[start:value_start] + pattern = _pattern(tag, wire, data[start:stop] if wire == 2 else b"") + probe = pattern.match(data, start, min(end, start + 64)) + vectorize = wire != 2 and probe is not None and probe.end() - start >= 32 + chunk_bytes = 128 if vectorize else CHUNK_BYTES + count, position = 0, start + while position < end and data.startswith(tag, position, end): + chunk_end = min(end, position + chunk_bytes) + if vectorize: + next_position, units = ( + _varint_chunk(data, position, chunk_end, tag) + if wire == 0 + else _fixed_chunk(data, position, chunk_end, tag, wire) + ) + # Grow only after proportional progress; short alternating fields + # never speculate over a full 64 KiB window. + if next_position - position >= (chunk_end - position) // 2 and units: + count += units + position = next_position + chunk_bytes = min(CHUNK_BYTES, 2 * chunk_bytes) + continue + match = pattern.match(data, position, chunk_end) + if match is None: + break + next_position = match.end() + if wire == 0: + # Every field has one terminating tag byte and one scalar byte. + values = np.frombuffer( + data, dtype=np.uint8, count=next_position - position, offset=position + ) + count += int(np.count_nonzero(values < 128)) // 2 + else: + size = stop - start if wire == 2 else len(tag) + (8 if wire == 1 else 4) + count += (next_position - position) // size + position = next_position + return position, count + + +def packed_varints(data: bytes, position: int, end: int): + match = PACKED.match(data, position, min(end, position + CHUNK_BYTES)) + if match is None: + return position, 0 + stop = match.end() + values = np.frombuffer(data, dtype=np.uint8, count=stop - position, offset=position) + return stop, int(np.count_nonzero(values < 128)) diff --git a/src/mlbundle/_onnx/description.py b/src/mlbundle/_onnx/description.py index 7cb1f4f..fd78cd4 100644 --- a/src/mlbundle/_onnx/description.py +++ b/src/mlbundle/_onnx/description.py @@ -9,8 +9,8 @@ from mlbundle.claims import ClassLabels, ModelClaims from mlbundle.contract import ModelKind from mlbundle.environment import OnnxInfo, Opset -from mlbundle.errors import InvalidModelError -from mlbundle.limits import MAX_CLASSES +from mlbundle.errors import InvalidModelError, LimitExceededError +from mlbundle.limits import HYPERPARAMETER_BYTES, MAX_CLASSES from .signatures import _batch, _inputs, signature @@ -215,13 +215,22 @@ def describe(model: Any) -> Description: metadata = {item.key: item.value for item in model.metadata_props} if len(metadata) != len(model.metadata_props): raise InvalidModelError("Duplicate ONNX metadata keys.") + projection = { + "inputs": list(inputs), + "outputs": outputs, + "metadata_props": metadata, + } + try: + canonical_dumps(projection, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) + except LimitExceededError: + raise LimitExceededError( + "ONNX hyperparameters (inputs/outputs/metadata_props) exceed the " + f"{HYPERPARAMETER_BYTES}-byte or depth-16 limit." + ) from None claims = ModelClaims( estimator_class="ONNX", kind=kind, - hyperparameters=cast( - dict[str, JsonValue], - {"inputs": list(inputs), "outputs": outputs, "metadata_props": metadata}, - ), + hyperparameters=cast(dict[str, JsonValue], projection), n_features=width, n_classes=None if count is None else [count], n_outputs=1, diff --git a/src/mlbundle/_onnx/protobuf.py b/src/mlbundle/_onnx/protobuf.py index da5d543..190ae8f 100644 --- a/src/mlbundle/_onnx/protobuf.py +++ b/src/mlbundle/_onnx/protobuf.py @@ -5,11 +5,14 @@ from mlbundle.errors import InvalidModelError, LimitExceededError from mlbundle.limits import ArchiveLimits -MAX_FIELDS = 1_000_000 +from ._wire_runs import packed_varints, repeated + +# Each field/header or packed scalar consumes at least one distinct wire byte. +MAX_FIELDS = ArchiveLimits().max_member_bytes MAX_DEPTH = 32 -def _varint(data: memoryview, position: int, end: int) -> tuple[int, int]: +def _varint(data: bytes, position: int, end: int) -> tuple[int, int]: value = 0 for shift in range(0, 70, 7): if position >= end: @@ -30,7 +33,7 @@ def validate_wire(data: bytes, descriptor: Any) -> None: if len(data) > ArchiveLimits().max_member_bytes: raise LimitExceededError("ONNX model exceeds the byte limit.") budget = [0] - _message(memoryview(data), 0, len(data), descriptor, depth=0, budget=budget) + _message(data, 0, len(data), descriptor, depth=0, budget=budget) def _message(data, position, end, descriptor, *, depth, budget): @@ -41,13 +44,22 @@ def _message(data, position, end, descriptor, *, depth, budget): budget[0] += 1 if budget[0] > MAX_FIELDS: raise LimitExceededError("ONNX protobuf field count exceeds the limit.") + start = position tag, position = _varint(data, position, end) number, wire = tag >> 3, tag & 7 field = descriptor.fields_by_number.get(number) if field is None or (not field.is_repeated and number in seen): raise InvalidModelError("Unknown or duplicate ONNX protobuf field.") seen.add(number) + value_start = position position = _field(data, position, end, field, wire, depth=depth, budget=budget) + if field.is_repeated and (wire != 2 or field.type in (9, 12)): + position, count = repeated( + data, start, end, value_start=value_start, stop=position, wire=wire + ) + budget[0] += count - 1 + if budget[0] > MAX_FIELDS: + raise LimitExceededError("ONNX protobuf field count exceeds the limit.") def _field(data, position, end, field, wire, *, depth, budget): @@ -85,8 +97,12 @@ def _packed(data, position, end, wire, budget): budget[0] += (end - position) // size else: while position < end: - _, position = _varint(data, position, end) - budget[0] += 1 + stop, count = packed_varints(data, position, end) + if not count: + stop = _varint(data, position, end)[1] + count = 1 + position = stop + budget[0] += count if budget[0] > MAX_FIELDS: raise LimitExceededError( "ONNX protobuf scalar count exceeds the limit." diff --git a/src/mlbundle/_onnx/validation.py b/src/mlbundle/_onnx/validation.py index c94f202..51ea07d 100644 --- a/src/mlbundle/_onnx/validation.py +++ b/src/mlbundle/_onnx/validation.py @@ -81,7 +81,7 @@ def _shape(shape: Any) -> None: if dim.HasField("dim_value"): static.append(dim.dim_value) elif len(dim.dim_param) > 1024: - raise LimitExceededError("ONNX symbolic dimension exceeds the limit.") + raise LimitExceededError("ONNX dim_param exceeds the 1024-character limit.") _dimensions(static, allow_zero=True) diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index acdc1b5..67242bd 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -28,10 +28,7 @@ from .payload import validate_payload from .prediction import Predictor from .structure import extract_structure, validate_tree_storage - -# Native numeric node storage for DecisionTree, RandomForest, ExtraTrees and -# GradientBoosting; no functions/I/O constructors. Indices are validated on load. -TRUSTED_TYPES = ("sklearn.tree._tree.Tree",) +from .trusted import TRUSTED_TYPES def _untrusted(data: bytes) -> None: diff --git a/src/mlbundle/_sklearn/nodes.py b/src/mlbundle/_sklearn/nodes.py index e5ae85f..2df199c 100644 --- a/src/mlbundle/_sklearn/nodes.py +++ b/src/mlbundle/_sklearn/nodes.py @@ -4,6 +4,8 @@ from mlbundle.errors import InvalidModelError, UnsupportedModelError +from .trusted import OPTIMIZER_TYPES + FIELDS = { "ObjectNode": {"content"}, "TreeNode": {"content", "__reduce__"}, @@ -32,6 +34,9 @@ def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None: for node in nodes: loader = node["__loader__"] + name = node.get("__module__", "") + "." + node.get("__class__", "") + if name in OPTIMIZER_TYPES and loader not in ("ObjectNode", "CachedNode"): + raise InvalidModelError("Invalid skops optimizer loader.") if ( node.get("__module__") == "sklearn.tree._tree" and node.get("__class__") == "Tree" diff --git a/src/mlbundle/_sklearn/state_fields.json b/src/mlbundle/_sklearn/state_fields.json index 6a6902a..c6ca13f 100644 --- a/src/mlbundle/_sklearn/state_fields.json +++ b/src/mlbundle/_sklearn/state_fields.json @@ -183,6 +183,8 @@ "sklearn.neighbors._regression.KNeighborsRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius", "weights"], "sklearn.neighbors._regression.RadiusNeighborsRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius", "weights"], "sklearn.neighbors._unsupervised.NearestNeighbors": ["_HTMLDocumentationLinkMixin__doc_link_template", "_fit_X", "_fit_method", "_sklearn_version", "_tree", "_y", "algorithm", "classes_", "effective_metric_", "effective_metric_params_", "feature_names_in_", "leaf_size", "metric", "metric_params", "n_features_in_", "n_jobs", "n_neighbors", "n_samples_fit_", "outputs_2d_", "p", "radius"], + "sklearn.neural_network._stochastic_optimizers.AdamOptimizer": ["beta_1", "beta_2", "epsilon", "learning_rate", "learning_rate_init", "ms", "t", "vs"], + "sklearn.neural_network._stochastic_optimizers.SGDOptimizer": ["learning_rate", "learning_rate_init", "lr_schedule", "momentum", "nesterov", "power_t", "velocities"], "sklearn.neural_network._multilayer_perceptron.MLPClassifier": ["_HTMLDocumentationLinkMixin__doc_link_template", "_best_coefs", "_best_intercepts", "_coef_indptr", "_intercept_indptr", "_label_binarizer", "_no_improvement_count", "_optimizer", "_random_state", "_sklearn_version", "activation", "alpha", "batch_size", "best_loss_", "best_validation_score_", "beta_1", "beta_2", "classes_", "coefs_", "early_stopping", "epsilon", "feature_names_in_", "hidden_layer_sizes", "intercepts_", "learning_rate", "learning_rate_init", "loss", "loss_", "loss_curve_", "max_fun", "max_iter", "momentum", "n_features_in_", "n_iter_", "n_iter_no_change", "n_layers_", "n_outputs_", "nesterovs_momentum", "out_activation_", "power_t", "random_state", "shuffle", "solver", "t_", "tol", "validation_fraction", "validation_scores_", "verbose", "warm_start"], "sklearn.neural_network._multilayer_perceptron.MLPRegressor": ["_HTMLDocumentationLinkMixin__doc_link_template", "_best_coefs", "_best_intercepts", "_coef_indptr", "_intercept_indptr", "_no_improvement_count", "_optimizer", "_random_state", "_sklearn_version", "activation", "alpha", "batch_size", "best_loss_", "best_validation_score_", "beta_1", "beta_2", "coefs_", "early_stopping", "epsilon", "feature_names_in_", "hidden_layer_sizes", "intercepts_", "learning_rate", "learning_rate_init", "loss", "loss_", "loss_curve_", "max_fun", "max_iter", "momentum", "n_features_in_", "n_iter_", "n_iter_no_change", "n_layers_", "n_outputs_", "nesterovs_momentum", "out_activation_", "power_t", "random_state", "shuffle", "solver", "t_", "tol", "validation_fraction", "validation_scores_", "verbose", "warm_start"], "sklearn.neural_network._rbm.BernoulliRBM": ["_HTMLDocumentationLinkMixin__doc_link_template", "_n_features_out", "_sklearn_output_config", "_sklearn_version", "batch_size", "components_", "feature_names_in_", "h_samples_", "intercept_hidden_", "intercept_visible_", "learning_rate", "n_components", "n_features_in_", "n_iter", "random_state", "random_state_", "verbose"], diff --git a/src/mlbundle/_sklearn/trusted.py b/src/mlbundle/_sklearn/trusted.py new file mode 100644 index 0000000..33f2282 --- /dev/null +++ b/src/mlbundle/_sklearn/trusted.py @@ -0,0 +1,9 @@ +"""Supplemental skops trust owned by mlbundle, never by serialized declarations.""" + +OPTIMIZER_TYPES = ( + "sklearn.neural_network._stochastic_optimizers.AdamOptimizer", + "sklearn.neural_network._stochastic_optimizers.SGDOptimizer", +) +# Numeric Tree storage and MLP optimizer records; no functions/I/O constructors. +# Optimizers use ObjectNode (__new__ plus __dict__.update), never __init__. +TRUSTED_TYPES = ("sklearn.tree._tree.Tree", *OPTIMIZER_TYPES) diff --git a/src/mlbundle/environment.py b/src/mlbundle/environment.py index 7c6dd4f..26f535a 100644 --- a/src/mlbundle/environment.py +++ b/src/mlbundle/environment.py @@ -44,8 +44,8 @@ class Opset(ContractModel): class OnnxInfo(ContractModel): opsets: list[Opset] = Field(min_length=1, max_length=128) ir_version: int = Field(gt=0, le=2**31 - 1) - producer_name: Annotated[str, Field(max_length=256)] - producer_version: Annotated[str, Field(max_length=256)] + producer_name: Annotated[str, Field(max_length=65536)] + producer_version: Annotated[str, Field(max_length=65536)] @model_validator(mode="after") def unique_domains(self) -> OnnxInfo: diff --git a/tests/test_onnx_compatibility.py b/tests/test_onnx_compatibility.py new file mode 100644 index 0000000..b500e4e --- /dev/null +++ b/tests/test_onnx_compatibility.py @@ -0,0 +1,158 @@ +"""Real-exporter layouts remain bounded, unchanged and validated once per operation.""" + +import numpy as np +import onnxruntime as ort +import pytest +from onnx import TensorProto as T, helper as h + +import mlbundle +from mlbundle._onnx import protobuf, validation +from onnx_helpers import classifier, regressor + + +def tree_ensemble(): + trees, nodes = 8192, 15 + count = trees * nodes + branches = nodes // 2 + leaves = list(range(branches, nodes)) + tree = h.make_node( + "TreeEnsembleClassifier", + ["X"], + ["label", "scores"], + domain="ai.onnx.ml", + classlabels_int64s=[0, 1], + nodes_treeids=[tree for tree in range(trees) for _ in range(nodes)], + nodes_nodeids=list(range(nodes)) * trees, + nodes_featureids=[0] * count, + nodes_values=[0.0] * count, + nodes_modes=(["BRANCH_LEQ"] * branches + ["LEAF"] * len(leaves)) * trees, + nodes_truenodeids=([2 * i + 1 for i in range(branches)] + [0] * len(leaves)) + * trees, + nodes_falsenodeids=([2 * i + 2 for i in range(branches)] + [0] * len(leaves)) + * trees, + nodes_missing_value_tracks_true=[0] * count, + class_treeids=[tree for tree in range(trees) for _ in leaves], + class_nodeids=leaves * trees, + class_ids=[0] * (trees * len(leaves)), + class_weights=[0.25 / trees] * (trees * len(leaves)), + ) + probabilities = h.make_value_info( + "probabilities", + h.make_sequence_type_proto( + h.make_map_type_proto(T.INT64, h.make_tensor_type_proto(T.FLOAT, [])) + ), + ) + native = h.make_model( + h.make_graph( + [ + tree, + h.make_node( + "ZipMap", + ["scores"], + ["probabilities"], + domain="ai.onnx.ml", + classlabels_int64s=[0, 1], + ), + ], + "CatBoostModel", + [h.make_tensor_value_info("X", T.FLOAT, [None, 2])], + [h.make_tensor_value_info("label", T.INT64, [None]), probabilities], + ), + ir_version=3, + opset_imports=[h.make_opsetid("ai.onnx.ml", 2)], + ) + native.producer_name = "CatBoost" + native.producer_version = "Git info:\n" + "a" * 353 + native.metadata_props.add(key="training", value="x" * 62946) + return native + + +def test_large_unpacked_tree_exporter_roundtrip(tmp_path, monkeypatch): + native = tree_ensemble() + data = native.SerializeToString() + budget = [0] + protobuf._message(data, 0, len(data), native.DESCRIPTOR, depth=0, budget=budget) + assert budget[0] > 1_000_000 + calls = [] + original = validation.validate_wire + + def scan(*args): + calls.append(len(args[0])) + return original(*args) + + monkeypatch.setattr(validation, "validate_wire", scan) + path = tmp_path / "tree.mlbundle" + mlbundle.export(data, path) + assert calls == [len(data)] + calls.clear() + loaded = mlbundle.load(path) + assert calls == [len(data)] + assert loaded.native_model.data == data + assert loaded.class_labels == [[0, 1]] + assert loaded.manifest.environment.onnx.producer_version == native.producer_version + rows = np.array([[-1, 0], [1, 0]], dtype=np.float32) + options = ort.SessionOptions() + options.intra_op_num_threads = options.inter_op_num_threads = 1 + session = ort.InferenceSession(data, options, providers=["CPUExecutionProvider"]) + expected = session.run(None, {"X": rows})[1] + assert loaded.predict_proba(rows) == [[row[0], row[1]] for row in expected] + + +@pytest.mark.parametrize("field", ["producer_name", "producer_version"]) +def test_producer_string_limit_names_field_without_value(field, tmp_path): + native = regressor() + setattr(native, field, "SECRET\n" + "x" * 65536) + with pytest.raises(mlbundle.InvalidModelError) as error: + mlbundle.export(native, tmp_path / "bad.mlbundle") + message = str(error.value) + assert field in message and "65536" in message + assert "SECRET" not in message and len(message) < 200 + + +@pytest.mark.parametrize("field", ["producer_name", "producer_version"]) +def test_producer_strings_at_limit_roundtrip(field, tmp_path): + native = regressor() + value = "".join(map(chr, np.random.default_rng(7).integers(33, 127, 65536))) + setattr(native, field, value) + path = tmp_path / "producer.mlbundle" + mlbundle.export(native, path) + assert getattr(mlbundle.load(path).manifest.environment.onnx, field) == value + + +def test_oversized_metadata_names_projection_limit(tmp_path): + native = regressor() + native.metadata_props.add(key="SECRET", value="x" * (1024 * 1024)) + with pytest.raises(mlbundle.LimitExceededError) as error: + mlbundle.export(native, tmp_path / "bad.mlbundle") + assert "metadata_props" in str(error.value) and "1048576" in str(error.value) + assert "SECRET" not in str(error.value) + + +def test_oversized_native_label_names_shared_limit(tmp_path): + with pytest.raises(mlbundle.InvalidModelError) as error: + mlbundle.export( + classifier(("SECRET" + "x" * 1024, "other")), tmp_path / "bad.mlbundle" + ) + assert "class_labels" in str(error.value) and "1024" in str(error.value) + assert "SECRET" not in str(error.value) + + +def test_description_cache_is_scoped_to_bytes_identity(tmp_path, monkeypatch): + calls = [] + original = validation.validate_wire + + def scan(*args): + calls.append(args[0]) + return original(*args) + + monkeypatch.setattr(validation, "validate_wire", scan) + path = tmp_path / "small.mlbundle" + mlbundle.export(regressor(), path) + assert len(calls) == 1 + calls.clear() + first = mlbundle.load(path).native_model + mlbundle.load(path) + assert len(calls) == 2 + calls.clear() + mlbundle.export(first, tmp_path / "second.mlbundle") + assert len(calls) == 1 diff --git a/tests/test_onnx_wire.py b/tests/test_onnx_wire.py new file mode 100644 index 0000000..3010b18 --- /dev/null +++ b/tests/test_onnx_wire.py @@ -0,0 +1,107 @@ +"""Batched scalar scans retain descriptor, duplicate, length and budget checks.""" + +import onnx +import pytest + +from mlbundle import InvalidModelError, LimitExceededError +from mlbundle._onnx import protobuf + +ATTRIBUTE = onnx.AttributeProto.DESCRIPTOR + + +def counted(data, descriptor=ATTRIBUTE): + budget = [0] + protobuf._message(data, 0, len(data), descriptor, depth=0, budget=budget) + return budget[0] + + +@pytest.mark.parametrize( + "unit", + [ + b"\x40\x01", + b"\x40\xff\x01", + b"\x40" + b"\xff" * 9 + b"\x01", + b"\x3d1234", + b"\x4a\x04LEAF", + ], +) +def test_unpacked_runs_count_across_chunks(unit): + assert counted(unit * 40000) == 40000 + + +@pytest.mark.parametrize( + "tail", + [ + b"\x40\x80", + b"\x40" + b"\x80" * 10, + b"\x40" + b"\xff" * 9 + b"\x02", + b"\x3d123", + b"\x4a\x05LEAF", + b"\x3c", + b"\xf8\x7f\x01", + b"\x40\x01\x12\x00\x12\x00", + ], +) +def test_invalid_tail_of_batched_fields_rejected(tail): + with pytest.raises(InvalidModelError): + protobuf.validate_wire(b"\x40\x01" * 40000 + tail, ATTRIBUTE) + + +def test_repeated_length_fields_preserve_boundaries_and_duplicates(): + # Equal bytes inside a string are content, never nested protobuf fields. + assert counted(b"\x4a\x02\x40\x01" * 40000) == 40000 + assert counted(b"\x40\x01\x4a\x01a\x40\x02") == 3 + with pytest.raises(InvalidModelError, match="duplicate"): + counted(b"\x0a\x01a" + b"\x40\x01" * 40000 + b"\x0a\x01b") + + +@pytest.mark.parametrize("wire", [b"\x42\x03\x01\x02\x03" * 3, b"\x3a\x041234" * 3]) +def test_each_packed_header_and_scalar_counted(wire): + assert counted(wire) == (12 if wire[0] == 66 else 6) + + +@pytest.mark.parametrize("unit", [b"\x40\x01", b"\x3d1234", b"\x4a\x04LEAF"]) +def test_unpacked_batch_enforces_lowered_field_budget(unit, monkeypatch): + monkeypatch.setattr(protobuf, "MAX_FIELDS", 100) + with pytest.raises(LimitExceededError, match="field count"): + protobuf.validate_wire(unit * 40000, ATTRIBUTE) + + +@pytest.mark.parametrize( + "wire", [b"\x42\x02\x01\x80", b"\x42\x0a" + b"\xff" * 9 + b"\x02", b"\x3a\x03abc"] +) +def test_invalid_packed_tail_rejected(wire): + with pytest.raises(InvalidModelError): + protobuf.validate_wire(wire, ATTRIBUTE) + + +def test_noncanonical_tags_and_varints_have_same_uint64_boundary(): + assert counted((b"\xc0\x00" + b"\x80" * 9 + b"\x00") * 10000) == 10000 + + +def test_large_strings_use_bounded_scalar_fallback(): + data = onnx.AttributeProto( + name="strings", strings=[b"x" * 70000] * 2 + ).SerializeToString() + assert counted(data) == 3 + + +def test_short_alternating_fields_do_not_scan_overlapping_windows(monkeypatch): + from mlbundle._onnx import _wire_runs + + scanned = [] + original = _wire_runs._varint_chunk + + def scan(data, start, end, tag): + scanned.append(end - start) + return original(data, start, end, tag) + + monkeypatch.setattr(_wire_runs, "_varint_chunk", scan) + data = (b"\x08\x01\x38\x01") * 1000 + assert counted(data, onnx.TensorProto.DESCRIPTOR) == 2000 + assert not scanned + # A run just beyond the probe must not speculate over the whole input. + data = (b"\x08\x01" * 20 + b"\x38\x01" * 20) * 1000 + scanned.clear() + assert counted(data, onnx.TensorProto.DESCRIPTOR) == 40000 + assert sum(scanned) <= 4 * len(data) diff --git a/tests/test_sklearn_mlp.py b/tests/test_sklearn_mlp.py new file mode 100644 index 0000000..acf3beb --- /dev/null +++ b/tests/test_sklearn_mlp.py @@ -0,0 +1,181 @@ +"""MLP solver state round trips faithfully and remains closed and committed.""" + +import numpy as np +import pytest +import skops.io as sio +from sklearn.neural_network import MLPClassifier, MLPRegressor +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler + +import mlbundle +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.payload import validate_payload +from sklearn_helpers import alter_schema, rewrite + +pytestmark = pytest.mark.filterwarnings( + "ignore:.*:sklearn.exceptions.ConvergenceWarning" +) + + +def fitted(solver="adam", classifier=True, pipeline=False, early_stopping=False): + rows = np.random.default_rng(7).normal(size=(40, 3)) + target = (rows[:, 0] > 0).astype(int) if classifier else rows[:, 0] + rows[:, 1] + estimator = (MLPClassifier if classifier else MLPRegressor)( + hidden_layer_sizes=(4, 2), + solver=solver, + max_iter=5, + random_state=7, + early_stopping=early_stopping, + ) + model = ( + Pipeline([("scale", StandardScaler()), ("model", estimator)]) + if pipeline + else estimator + ) + model.fit(rows, target) + return model, rows + + +@pytest.mark.parametrize("solver", ["adam", "sgd", "lbfgs"]) +@pytest.mark.parametrize("classifier", [False, True]) +@pytest.mark.parametrize("pipeline", [False, True]) +def test_mlp_all_solvers_roundtrip(solver, classifier, pipeline, tmp_path): + model, rows = fitted(solver, classifier, pipeline) + path = tmp_path / "mlp.mlbundle" + before = validate_payload(sio.dumps(model)) + mlbundle.export(model, path) + loaded = mlbundle.load(path) + method = "predict_proba" if classifier else "predict" + assert np.array_equal( + np.asarray(getattr(loaded, method)(rows)).squeeze(), + getattr(model, method)(rows).squeeze(), + ) + assert loaded.structure is None + assert loaded.manifest.model.state_sha256 is not None + assert validate_payload(sio.dumps(model)) == before + + +@pytest.mark.parametrize("solver", ["adam", "sgd"]) +def test_mlp_early_stopping_state_roundtrip(solver, tmp_path): + model, rows = fitted(solver, early_stopping=True) + path = tmp_path / "early.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + assert loaded.native_model.validation_scores_ == model.validation_scores_ + + +@pytest.mark.parametrize("solver", ["adam", "sgd"]) +def test_unknown_optimizer_state_rejected_on_both_paths(solver, tmp_path): + model, _ = fitted(solver) + model._optimizer.extra_ = 1 + for action in [ + lambda: mlbundle.export(model, tmp_path / "bad.mlbundle"), + lambda: Adapter().deserialize(sio.dumps(model)), + ]: + with pytest.raises( + mlbundle.InvalidModelError, match="Unknown sklearn estimator state" + ): + action() + + +@pytest.mark.parametrize("solver", ["adam", "sgd"]) +def test_optimizer_load_bypasses_constructor(solver, monkeypatch): + model, _ = fitted(solver) + optimizer = model._optimizer + assert type(optimizer).__new__ is object.__new__ + assert not hasattr(optimizer, "__setstate__") + assert all( + "__reduce__" not in vars(cls) and "__reduce_ex__" not in vars(cls) + for cls in type(optimizer).__mro__[:-1] + ) + payload = sio.dumps(model) + monkeypatch.setattr( + type(optimizer), "__init__", lambda *a, **kw: pytest.fail("constructor called") + ) + restored = Adapter().deserialize(payload) + assert type(restored._optimizer) is type(optimizer) + assert vars(restored._optimizer).keys() == vars(optimizer).keys() + + +@pytest.mark.parametrize("solver", ["adam", "sgd"]) +@pytest.mark.parametrize( + "loader", ["ConstructorFromReduceNode", "ListNode", "TypeNode"] +) +def test_optimizer_cannot_select_another_loader(solver, loader, monkeypatch): + model, _ = fitted(solver) + + def change(schema, entries): + optimizer = schema["content"]["content"]["_optimizer"] + optimizer["__loader__"] = loader + + payload = alter_schema(sio.dumps(model), change) + monkeypatch.setattr(sio, "loads", lambda *a, **kw: pytest.fail("skops called")) + with pytest.raises(mlbundle.InvalidModelError, match="optimizer loader"): + Adapter().deserialize(payload) + + +@pytest.mark.parametrize( + "attribute", ["coefs_", "intercepts_", "loss_curve_", "optimizer"] +) +def test_changed_mlp_state_rejected_by_commitment(attribute, tmp_path): + model, _ = fitted() + path = tmp_path / "original.mlbundle" + mlbundle.export(model, path) + if attribute == "optimizer": + model._optimizer.ms[0][0, 0] += 1 + elif attribute == "loss_curve_": + model.loss_curve_[0] += 1 + else: + getattr(model, attribute)[0].flat[0] += 1 + hostile = rewrite(path, sio.dumps(model)) + with pytest.raises(mlbundle.ClaimMismatchError, match="state_sha256"): + mlbundle.load(hostile) + + +@pytest.mark.parametrize( + "solver,attribute", + [ + ("adam", name) + for name in ( + "beta_1", + "beta_2", + "epsilon", + "learning_rate", + "learning_rate_init", + "ms", + "t", + "vs", + ) + ] + + [ + ("sgd", name) + for name in ( + "learning_rate", + "learning_rate_init", + "lr_schedule", + "momentum", + "nesterov", + "power_t", + "velocities", + ) + ], +) +def test_every_optimizer_attribute_is_committed(solver, attribute, tmp_path): + model, _ = fitted(solver) + path = tmp_path / "optimizer.mlbundle" + mlbundle.export(model, path) + value = getattr(model._optimizer, attribute) + if isinstance(value, list): + value[0].flat[0] += 1 + else: + changed = ( + "invscaling" + if isinstance(value, str) + else not value + if isinstance(value, bool) + else value + 1 + ) + setattr(model._optimizer, attribute, changed) + with pytest.raises(mlbundle.ClaimMismatchError, match="state_sha256"): + mlbundle.load(rewrite(path, sio.dumps(model))) From 13ce6391e481833f2d7e459115b1d4cb73dbe3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 14:22:08 +0200 Subject: [PATCH 10/15] feat: add native numeric CatBoost adapter (#12) Use CBM with closed wrappers and verified native prediction state. Reject categorical CTR storage until it has a bounded preflight. --- AGENTS.md | 26 +-- README.md | 184 +++++++-------- SECURITY.md | 38 ++- docs/format-adapters.md | 9 +- docs/format-catboost.md | 164 +++++++++++++ docs/format-state.md | 8 +- docs/format.md | 16 +- pyproject.toml | 5 +- scripts/package_smoke.py | 1 + src/mlbundle/_catboost/__init__.py | 159 +++++++++++++ src/mlbundle/_catboost/budget.py | 22 ++ src/mlbundle/_catboost/flatbuffer.py | 104 +++++++++ src/mlbundle/_catboost/native.py | 244 +++++++++++++++++++ src/mlbundle/_catboost/prediction.py | 67 ++++++ src/mlbundle/_catboost/scope.py | 109 +++++++++ src/mlbundle/_catboost/state.py | 62 +++++ src/mlbundle/_container_rules.py | 1 + src/mlbundle/_sklearn/__init__.py | 16 +- src/mlbundle/contract.py | 4 + src/mlbundle/environment.py | 2 + src/mlbundle/registry.py | 13 ++ tests/catboost_helpers.py | 76 ++++++ tests/test_catboost.py | 156 +++++++++++++ tests/test_catboost_claims.py | 127 ++++++++++ tests/test_catboost_custom.py | 62 +++++ tests/test_catboost_payload.py | 159 +++++++++++++ tests/test_catboost_validation.py | 274 ++++++++++++++++++++++ tests/test_package.py | 2 +- tests/test_readme.py | 4 +- tests/test_registry.py | 1 + uv.lock | 338 ++++++++++++++++++++++++++- 31 files changed, 2326 insertions(+), 127 deletions(-) create mode 100644 docs/format-catboost.md create mode 100644 src/mlbundle/_catboost/__init__.py create mode 100644 src/mlbundle/_catboost/budget.py create mode 100644 src/mlbundle/_catboost/flatbuffer.py create mode 100644 src/mlbundle/_catboost/native.py create mode 100644 src/mlbundle/_catboost/prediction.py create mode 100644 src/mlbundle/_catboost/scope.py create mode 100644 src/mlbundle/_catboost/state.py create mode 100644 tests/catboost_helpers.py create mode 100644 tests/test_catboost.py create mode 100644 tests/test_catboost_claims.py create mode 100644 tests/test_catboost_custom.py create mode 100644 tests/test_catboost_payload.py create mode 100644 tests/test_catboost_validation.py diff --git a/AGENTS.md b/AGENTS.md index c62e8db..353e0a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ in one compressed `.mlbundle` file. It owns both writing and validated reading so that the format is defined once. The pre-release v0.1 library implements strict format 1 manifests, secure container -I/O, inspect/export/load, sklearn/XGBoost/ONNX adapters, local schema, training +I/O, inspect/export/load, sklearn/XGBoost/CatBoost/ONNX adapters, local schema, training dtypes, explicit samples and prediction-state verification. The CLI provides metadata-only `inspect` and full `verify`. Format 1 is not yet frozen. Implementation claims require code and test proof. `docs/format.md` owns the wire contract. @@ -39,11 +39,11 @@ rows only when explicitly requested: samples are real user data. ### 4. Native formats with a narrow support boundary -Version 0.1 supports three libraries: scikit-learn via `skops.io.dumps/loads`, -XGBoost via native in-memory UBJSON, and the user's ONNX +Version 0.1 supports four libraries: scikit-learn via `skops.io.dumps/loads`, +XGBoost via native in-memory UBJSON, CatBoost via CBM, and the user's ONNX file unchanged. Do not convert models. Adapters import their library lazily; their dependencies belong in the optional extras `mlbundle[sklearn]`, -`mlbundle[xgboost]` and `mlbundle[onnx]`. +`mlbundle[xgboost]`, `mlbundle[catboost]` and `mlbundle[onnx]`. The design takes ideas from MLflow's versioned manifest and native model storage, not its code. MLflow is Apache-2.0; mlbundle is MIT. @@ -138,7 +138,7 @@ still preserve validation, security and reproducibility. Never modify, commit or run write commands inside either checkout. - Do not publish to PyPI, create releases or version tags, or add a publish job. "Tags" in this project means GitHub repository topics. -- sklearn-to-ONNX conversion, per-model environments, bundle signing and other +- sklearn-to-ONNX conversion, per-model environments, bundle signing and additional libraries (including LightGBM and PyTorch) are outside the project scope. - Never claim a verification that was not run; report its real output or blocker. @@ -202,14 +202,13 @@ JSON-ready parts, computes integrity records and produces a deterministic ZIP. It enforces the same limits and validation as the container reader and publishes paths atomically on hard-link filesystems, requiring explicit overwrite. The exclusive-creation fallback removes partial destinations on failure but exposes -the file during copying. Public sklearn/XGBoost export extracts claims/structure, -serializes with skops/native in-memory UBJSON and verifies a native round trip +the file during copying. Public sklearn/XGBoost/CatBoost export extracts claims/structure, +serializes with skops/native UBJSON/CBM and verifies a native round trip before publication. XGBoost supports exact XGBClassifier/XGBRegressor tree wrappers with closed probability/regression objectives and one target. Original class labels supplied through output-major `class_labels=` are bound to a native copy through an mlbundle-owned booster attribute and verified on load. Omitted -labels use encoded indices; sklearn rejects this option because it owns classes_. -Local `training_data=` inference uses mlschema with `__`, excludes a named target, +labels use encoded indices; sklearn/CatBoost reject this option because they own classes_. Local `training_data=` inference uses mlschema with `__`, excludes a named target, and records training dtypes. Only explicit `sample_rows=N` includes real rows. Schema/report and sample validation is shared by export, inspect and load. ONNX preserves path/bytes unchanged (ModelProto is serialized once), validates @@ -219,8 +218,9 @@ claims. `native_model` is an OnnxModel with original bytes and a CPU session. Kind is inferred from supported outputs; native labels are verified, absent labels use count-checked declarations. Structure is null. See the normative ONNX section linked from `docs/format.md` for exact layouts and resource bounds. +CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. The closed member list is: required `manifest.json`, exactly -one of `model.skops`, `model.ubj` or `model.onnx`, optional `schema.json`, and +one of `model.skops`, `model.ubj`, `model.cbm` or `model.onnx`, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional `samples.json` requires explicit user intent. No other members are allowed. @@ -242,8 +242,7 @@ without importing model libraries; it cannot attest that model-derived claims match the model. Public `load` uses a closed adapter and recomputes claims and structure before returning the native model and validated predictor. -An adapter module provides `Adapter` with the library and model-member identity, -recognition, export preparation, serialization/deserialization, `ModelClaims`, structure extraction +An adapter module provides `Adapter` with library/member identity, recognition, export preparation, serialization/deserialization, `ModelClaims`, structure extraction and a `Predictor`. Extraction uses only the native model, shared by export/load; ONNX graph fields belong in `ModelClaims.onnx`. Missing names are null claims; the shared pipeline owns supplied/generated names and environment declarations. @@ -268,6 +267,7 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. - `src/mlbundle/_sklearn/` — skops trust/loading (including MLP adam/sgd/lbfgs state), parameter and claim extraction, native structure, nested payload bounds and validated prediction. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. +- `src/mlbundle/_catboost/` — closed numeric symmetric wrappers/losses, CBM preflight/in-memory loading, complete native state/structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. - `src/mlbundle/declarations.py` — export-time class labels and resolution for native models without labels. - `src/mlbundle/claims.py` — shared extracted claims and exact claim/structure comparison. @@ -275,7 +275,7 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/integrity.py` — member SHA-256/size records and verification. - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` and `_prediction_input.py` — adapter/predictor protocols, output types and shared input checks. -- `src/mlbundle/registry.py` — closed lazy resolution of implemented `_sklearn`, `_xgboost` and `_onnx` adapters. +- `src/mlbundle/registry.py` — closed lazy resolution of `_sklearn`, `_xgboost`, `_catboost` and `_onnx` adapters. - `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. diff --git a/README.md b/README.md index debfc5b..e24bec5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@ # mlbundle -Package a trained model, its manifest, locally inferred schema and model -structure in one compressed `.mlbundle` file. Use the same Python library to -export on the training machine and validate/load in the consuming application. +Package a trained model, manifest, locally inferred schema and structure in one `.mlbundle` file. +Export on the training machine and validate/load in the consuming application with the same Python library. mlbundle replaces three gaps in separate model/DataFrame uploads: -- **Pickle code execution:** closed adapters use skops, native XGBoost UBJSON - or an existing ONNX file, with no pickle/joblib/cloudpickle loading path. +- **Pickle code execution:** closed adapters use skops, native XGBoost UBJSON, + CatBoost CBM or an existing ONNX file, without pickle/joblib/cloudpickle. - **Missing training versions:** the manifest records Python, OS, architecture and installed package versions alongside model and feature metadata. - **Training data leaving the machine:** schema inference runs locally; @@ -21,7 +20,7 @@ Windows and Linux; the schema extra currently works only on Python 3.14. Install from Git with `python -m pip install "mlbundle[sklearn] @ git+https://github.com/UlloaSP/mlbundle.git@develop"`. -Replace `[sklearn]` with `[xgboost]`, `[onnx]`, `[schema]` or `[all]` for the +Replace `[sklearn]` with `[xgboost]`, `[catboost]`, `[onnx]`, `[schema]` or `[all]` for the corresponding dependencies. Combine extras as `[sklearn,schema]` for local DataFrame inference. Omit the brackets for a base install that can inspect every bundle without model libraries. Load/verify needs the matching model extra. @@ -44,18 +43,14 @@ print(bundle.predict_proba([[0.0, 1.0]])) # [[1.0, 0.0]] assert inspect("trained.mlbundle").manifest.model.library == "sklearn" ``` -`export` returns a metadata view and refuses an existing destination unless -`overwrite=True`. `load` accepts a path, bytes or a binary stream. Classifiers -use `bundle.predict_proba`; regressors use `bundle.predict`. Results are plain -lists. For multiple targets, classifier labels/probabilities are output-major, -while regression values are row-major. `native_model` exposes the loaded native -object for explanations and native inference. Do not mutate it and continue -treating the original metadata as verified. +`export` returns metadata and requires `overwrite=True` for existing destinations. +`load` accepts paths, bytes or streams. Classifiers use `bundle.predict_proba`; +regressors use `bundle.predict`. Results are plain lists: output-major classifier +labels/probabilities, row-major regression values. `native_model` supports native +inference/explanations; mutating it invalidates the metadata verification. -Native feature names take precedence over `feature_names=`. Otherwise supplied -names have origin `declared`; omitted names become `feature_1` through -`feature_N` with origin `generated`. DataFrame names have origin `dataframe` -when the model has none. `target_name=` and `metadata=` are author declarations. +Native names win; otherwise origin is `declared`, `dataframe`, or `generated` (`feature_1` through `feature_N`). +`target_name=` and `metadata=` remain author declarations. ## XGBoost @@ -81,15 +76,40 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() assert bundle.class_labels == [["cold", "warm"]] ``` -The adapter binds labels and prediction settings to a native **copy**, leaving -your model unchanged. Load checks agreement with that binding, not the historical -truth of the label mapping. Without `class_labels=`, labels are encoded indices -or an existing mlbundle binding. Native `predict` still returns encoded indices. -Sklearn and regressors reject `class_labels=`. +The adapter binds labels/settings to a native **copy**, leaving your model +unchanged. Load verifies that binding, not historical truth. Omitted labels use +encoded indices or an existing binding; native `predict` still returns indices. +Sklearn, CatBoost and regressors reject `class_labels=`. -Only recoverable native configuration is recorded. Training settings such as -learning rate, max depth, seed and thread count do not survive native XGBoost -I/O and are not reconstructed from the manifest. +Only recoverable native configuration is recorded; learning rate, max depth, +seed and thread count do not survive native XGBoost I/O and are not reconstructed. + +## CatBoost + +Install the `catboost` extra (>=1.2.10; includes matplotlib/plotly). Numeric, +single-target symmetric classifiers/regressors use native CBM and native labels: + +```python +from catboost import CatBoostClassifier +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = CatBoostClassifier( + iterations=3, depth=2, verbose=False, allow_writing_files=False +).fit(rows, ["cold", "warm"] * 2) +export(model, "catboost.mlbundle") +bundle = load("catboost.mlbundle") +assert bundle.class_labels == [["cold", "warm"]] +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +``` + +Export a pipeline's estimator as `export(pipeline.named_steps["est"], ...)`. +CatBoost pipelines, `class_labels=`, categorical/text/embedding features, +Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. +Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, +Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances +are verified on load. [The CatBoost contract](docs/format-catboost.md) details +wrapper recovery, native-parser limits and the private in-memory serializer. ## Existing ONNX files @@ -158,19 +178,15 @@ assert inspect("sampled.mlbundle").samples == features.head(2).to_dict("records" per target output. `color__red`/`color__blue` binary columns can become one category field using mlschema's `__` convention. Frame names must match native names in order; `feature_names=` cannot rename/reorder a supplied frame. -Training dtypes are declarations, separate from verified model-exposed dtypes. -Inspect/load validate schema mappings, report kinds/labels and sample row keys -without pandas or mlschema. - -**What data-derived information leaves the machine:** column names, dtypes, -field kinds, nullability, dtype-based numeric steps, categorical values (including -unused categories), one-hot suffixes, pair-axis metadata and class labels. -Inference adds no numeric/date ranges, means, counts, arbitrary text row values -or example rows. Categories and names can be sensitive. Models and structure -can themselves retain training information; omitting samples does not prevent -memorization. Explicit `sample_rows=N` adds exactly the first N feature rows, -without target/index; N must be positive and no larger than the frame. -See [schema and samples](docs/format-schema.md) for conversions and limits. +Training dtypes are declarations, separate from verified model dtypes. Inspect/load +validate schema mappings, reports and sample keys without pandas or mlschema. + +**Data leaving the machine:** names/dtypes, field kinds/nullability, dtype-based +steps, categories (including unused), one-hot suffixes, pair-axis metadata and +class labels. No inferred ranges, means, counts or arbitrary text/example rows. +Names/categories and learned models/structure can reveal training information. +Explicit `sample_rows=N` includes the first N feature rows, without target/index; +N must be positive and within the frame. See [schema and samples](docs/format-schema.md). ## Inspect, load and the CLI @@ -188,47 +204,37 @@ mlbundle inspect trained.mlbundle --json mlbundle verify trained.mlbundle ``` -Inspection prints identity, model/counts, feature origin/names, output-major -labels, member sizes/hashes, training environment and optional-member presence. -The manifest has no self-integrity record. Human display values are escaped and -truncated to 512 characters; `--json` prints the complete validated manifest with -ASCII JSON escaping and the format's size limits. This escapes terminal controls -without changing the decoded JSON. `verify` prints one valid-bundle line or an -error, and requires the matching extra. Errors use one bounded stderr line -without bundle content or traceback. Exit codes are **0** success, **1** bundle -or dependency rejection (`MLBundleError`), **2** usage or I/O failure. +Inspection prints metadata, members/hashes and declarations. Human values escape +terminal controls and truncate at 512 characters; `--json` is lossless ASCII-escaped +manifest JSON. `verify` requires the model extra. Errors are one bounded stderr +line without bundle content/traceback. Exit codes: **0** success, **1** bundle or +dependency rejection (`MLBundleError`), **2** usage/I/O failure. ## Supported models and limits -Support requires a faithful round trip through the closed adapter. A catalog -entry alone does not guarantee that every fitted configuration exports. +Support requires a faithful closed-adapter round trip; a catalog entry does not guarantee every fitted configuration exports. - **Sklearn:** fitted classifiers exposing `predict_proba` and regressors with - recoverable feature/output counts. Tested families include DecisionTree, - ExtraTree, RandomForest, ExtraTrees, GradientBoosting, AdaBoost and Bagging; - LogisticRegression, LinearRegression, Ridge, Bayesian/ARD, ElasticNet/Lasso, - Huber, Poisson/Gamma, SGD; SVC with probabilities, SVR/NuSVR/LinearSVR; - PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, - NearestCentroid, MLPClassifier/MLPRegressor (adam, sgd, lbfgs), MultiOutput - wrappers and RegressorChain. MLPs retain optimizer state, work alone/in pipelines - and have null structure with complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). Scaler and - dense OneHotEncoder pipelines round-trip; pipeline structure stays null - because format 1 does not map transformed features. See the + recoverable feature/output counts. Tested families include trees/ensembles, + linear/regularized/robust/GLM models, SVMs, PLSRegression, naive Bayes, LDA/QDA, + dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. + MLPClassifier/Regressor (adam, sgd, lbfgs) retain optimizer state, alone/in pipelines, + with null structure and complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). + Scaler and dense OneHotEncoder pipelines round-trip; structure stays null + because format 1 does not map transformed features. See [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). - **Sklearn rejections:** custom estimators/subclasses, callable/type parameters (including FunctionTransformer user functions), classifiers without - probabilities, multilabel/ClassifierChain layouts, XGBoost pipeline steps, + probabilities, multilabel/ClassifierChain layouts, XGBoost/CatBoost pipeline steps, and untrusted nested storage such as fitted sparse neighbor arrays. HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. - **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. - Unknown classes/attributes fail export/load. The embedded sklearn version is - committed too: loading with a different sklearn version fails native state - comparison. Use the same sklearn version for export and load. Other versions - may work when both ends match and their state satisfies the catalog, but are - not an advertised compatibility range. There is no manifest-driven restoration - or permissive fallback. See [state ownership](docs/format-state.md). + Unknown classes/attributes fail export/load. The committed sklearn version + must match at export/load; other matching-version configurations can work + when they satisfy the catalog. No permissive fallback or advertised broader + range. See [state ownership](docs/format-state.md). - **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native `gbtree` storage (including XGBoost 3.4.1 `dart` dropout normalized to gbtree). Classifiers: `binary:logistic`, `multi:softprob`. Regressors: @@ -238,24 +244,21 @@ entry alone does not guarantee that every fitted configuration exports. storage, ranking/survival/custom objectives and multiple targets are rejected. At most 100,000 trees and depth 14; exact native state and complete dumps are required. See [the XGBoost mapping](docs/format-adapters.md#implemented-xgboost-mapping). -- **ONNX:** one numeric matrix input, or multiple width-one numeric inputs; - rank two, fixed feature width, float32/float64/int32/int64. Batch is dynamic - or one. One target: float regression `[batch]`/`[batch,1]`, or tensor/ZipMap - probabilities with an optional int64/string label output. Classification must - be inferable from supported graph evidence, including terminal Softmax or - native classifier labels. Ambiguous shapes, images/string inputs, fixed - batches above one, multiple targets, external tensors, custom domains/local - functions and training/runtime configuration are rejected before a CPU session. - Tested with ONNX 1.23.0/Runtime 1.30.0; actual operators/opsets must pass both. - Large unpacked TreeEnsembleClassifier/ZipMap exports and multiline producer - versions work. Wire validation runs once per export/load; the field/scalar - ceiling follows the 512 MiB member bound, producer strings allow 65,536 characters. - See [the ONNX bounds](docs/format-onnx.md). +- **ONNX:** rank-two numeric matrix or width-one inputs, fixed width, + float32/float64/int32/int64; dynamic or one-row batches. One target: float + regression `[batch]`/`[batch,1]` or tensor/ZipMap probabilities, optional labels. + Classification requires native graph evidence. Ambiguous/image/string layouts, + larger fixed batches, multiple targets, external tensors, custom domains/local + functions and training/runtime configuration fail before CPU sessions. + Tested with ONNX 1.23.0/Runtime 1.30.0; operators/opsets must pass both. + Large unpacked ensembles/ZipMap and multiline producer versions work. One wire + validation per export/load; field/scalar ceilings follow 512 MiB, producer strings + allow 65,536 characters. See [ONNX bounds](docs/format-onnx.md). Archive ceilings: **5 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. -Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/ONNX: one). +Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). Callers may lower `ArchiveLimits`, never raise these ceilings. Native work has additional [state](docs/format-state.md) and [ONNX](docs/format-onnx.md) bounds; these byte budgets do not cap total process memory, prediction rows or CPU. @@ -263,7 +266,7 @@ these byte budgets do not cap total process memory, prediction rows or CPU. ## Bundle contents and security Every bundle contains `manifest.json` and exactly one of `model.skops`, -`model.ubj`, `model.onnx`. Optional members are `schema.json`, `structure.json` +`model.ubj`, `model.cbm`, `model.onnx`. Optional members are `schema.json`, `structure.json` (required when the adapter extracts structure) and explicitly requested `samples.json`. No training DataFrame or arbitrary files are included. The manifest records identity/time/writer, model parameters/counts/state, @@ -276,19 +279,16 @@ limits, strict JSON/manifest and hashes; adapters use closed trust/loading paths and recompute claims. **Hashes detect corruption, not deliberate tampering**; there is no signing or proof of authorship, training history, quality or fairness. Schema, samples and training-environment declarations are not authenticated. -Native parsers and inference remain dependency boundaries. Consumers should -isolate the loading/prediction process, enforce memory/CPU/row limits and maintain -dependencies. See [SECURITY.md](SECURITY.md) for guarantees, limits and reporting. +Native parsers/inference require process isolation, memory/CPU/row limits and +maintained dependencies. See [SECURITY.md](SECURITY.md) for guarantees and reporting. ## Development -From the repository root, use `uv python install 3.14`, -`uv sync --locked --all-extras` and `uv run pre-commit install`. -The default dev group installs every extra; base users can use `uv sync --no-dev`. -Pydantic v2 is the sole core runtime dependency; model/schema imports stay lazy. -The schema extra admits mlschema `>=0.2.1,<0.3` for the 0.2 inferred vocabulary, -with a Python 3.14 marker because 0.2.1 excludes 3.15. `uv.lock` fixes the tested -development versions. Dependency edits require `uv sync` and the updated lockfile. +From the root: `uv python install 3.14`, `uv sync --locked --all-extras`, +`uv run pre-commit install`. Default dev installs every extra; base: `uv sync --no-dev`. +Pydantic v2 is the sole core dependency; model/schema imports are lazy. Schema uses +mlschema `>=0.2.1,<0.3` on 3.14 only (0.2.1 excludes 3.15). `uv.lock` fixes tested +versions; dependency edits require `uv sync` and committing the updated lockfile. Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, `uv run pytest` and `uv build`. Tests execute every fenced README example, @@ -296,5 +296,5 @@ write terminal/XML coverage and enforce 80% branch-inclusive coverage. CI preserves lint/types and Ubuntu/Windows tests and checks a clean base wheel installation on both OSes. `uv build` creates a local wheel and source archive. Read [AGENTS.md](AGENTS.md) before contributing. Publishing, releases, version -tags, conversions, per-model environments, signing and other model libraries +tags, conversions, per-model environments, signing and additional model libraries remain outside scope. The project is MIT licensed. diff --git a/SECURITY.md b/SECURITY.md index 9ef3acd..0adbb68 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ mlbundle v0.1 is pre-release; format version 1 is not yet frozen. It implements strict manifest validation, secure container reading/writing, metadata-only -`inspect`, export/load, sklearn, XGBoost and ONNX adapters, local schema inference, +`inspect`, export/load, sklearn, XGBoost, CatBoost and ONNX adapters, local schema inference, explicit samples, prediction-state verification and the inspect/verify CLI. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, checks CRC and member integrity, and rejects unsafe members and polyglot wrappers. @@ -51,7 +51,7 @@ make a received file trustworthy. disagreements in visualisation structure. The supported storage formats are skops for scikit-learn, native UBJSON for -XGBoost and the user's original ONNX file. There is no conversion or fallback +XGBoost, native CBM for CatBoost and the user's original ONNX file. There is no conversion or fallback serialization path. Adapters must use lazy imports for optional dependencies. ## Implemented sklearn trust boundary @@ -62,7 +62,7 @@ the fixed supplemental list `sklearn.tree._tree.Tree`, `sklearn.neural_network._stochastic_optimizers.SGDOptimizer`. Load passes exactly that list to `skops.io.loads`, alongside skops' built-in trusted defaults. These are native numeric tree storage and fitted MLP optimizer records. They add no arbitrary functions or I/O -constructors. The bundle cannot provide additional trusted types. +constructors. The bundle cannot provide additional trusted types; CatBoost adds none. The optimizer path was checked against skops 0.16.0/sklearn 1.9.1: ObjectNode uses ordinary `object.__new__` and `__dict__.update`, bypassing constructors. @@ -137,6 +137,38 @@ prediction and enforce process resource limits. All predictors reject invalid in without echoing data cells. See the exact support and resource boundaries in [docs/format.md](docs/format.md). +## Implemented CatBoost dependency boundary + +Only exact fitted CatBoostClassifier/CatBoostRegressor, closed single-target +losses and numeric symmetric trees are admitted. CBM export uses public +`save_model` in an export-only temporary directory; load uses `load_model(blob=...)` +in memory. Extraction uses CatBoost 1.2.10's audited private `_serialize_model` +CBM serializer to recompute current native state without writing reader files. +Class selection and the prediction loss come from checked native state, never +manifest strings. Native class labels, parameters, feature order, calibration, +tree state and the complete native metadata commitment are recomputed on load. +The original fitted estimator is not modified. See [the contract](docs/format-catboost.md). + +Before native parsing, mlbundle checks the exact CBM core length, version, +FlatBuffer offsets/fields/strings/vectors, tree and feature counts, symmetric +depth/split offsets, leaf dimensions/values/weights, split indices and numeric +borders/NaN routing. Metadata JSON is byte/depth-bounded. Numeric buffers remain +views during preflight; a conservative 256 MiB structure expansion budget is +checked before native parsing or Python list conversion. Categorical/CTR, text/embedding/calcer parts and +non-symmetric trees are rejected before their native parsers or traversals. +CatBoost's CTR table reader lacks its core FlatBuffer verifier; supporting it +requires a separate complete preflight, not merely permitting categorical input. + +Windows 1.2.10 subprocess probes returned CatBoostError for truncated/bad-root +models and UnicodeDecodeError for corrupted metadata, and accepted a model +missing its final byte. mlbundle independently rejects inconsistent core lengths +and wraps native Python exceptions in bounded errors without their content. +These probes found no crash or timeout; they cannot prove memory safety. +The native loader, serializer, feature-importance traversal and inference can +still crash or allocate inside native code. Exceptions cannot contain process +crashes, and member/count ceilings do not cap native RAM or CPU. Consumers must +isolate these operations and enforce process limits, as for the other adapters. + ## Implemented ONNX dependency boundary Original self-contained ONNX bytes are parsed and loaded in memory. Before any diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 8ea69fa..723b8cb 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -19,6 +19,7 @@ add required `state_sha256` and `prediction_state` fields; XGBoost adds required | `tree_ensemble` | `estimator_shape`: original shape of the ensemble's estimator collection; `trees`: row-major flattened list of tree objects. Product of shape equals list length. A one-dimensional collection uses `[N]`. | | `linear` | `coef`, `intercept`: native numeric arrays/scalars converted without reshaping. | | `xgboost` | `trees`: ordered list of parsed JSON tree dumps, each from `get_dump(dump_format="json", with_stats=True)`. Retain all native fields, including node IDs, split/leaf values, missing-child IDs, gain/cover and children. Do not embed dump strings. | +| `catboost` | `native_model`: complete numeric symmetric-tree arrays, feature borders/NaN routing, scale/bias and repacked splits defined in [CatBoost](format-catboost.md). | | `feature_importances` | No additional fields; the importance array must be non-null. | A scikit-learn tree object has exactly `node_count` (positive integer), @@ -59,8 +60,8 @@ the object to serialize and extract, leaving the caller's model unchanged. bind or reject declarations according to their native contracts; neither `deserialize` nor extraction receives manifest fields. The shared pipeline resolves labels only when extraction returns null labels for a classifier. -The registry imports only the three constant mlbundle module paths `_sklearn`, -`_xgboost`, `_onnx`, and constructs their module's `Adapter` class. Library +The registry imports only the four constant mlbundle module paths `_sklearn`, +`_xgboost`, `_catboost`, `_onnx`, and constructs their module's `Adapter` class. Library imports must be lazy, with dependency failures naming the extra to install. Missing adapter modules raise `AdapterNotImplementedError` on load; export skips unimplemented adapters while looking for one that recognizes its input. A missing @@ -91,7 +92,9 @@ Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected at export and independently at load. XGBoost estimators inside sklearn pipelines are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). -The native XGBoost adapter owns standalone XGBoost loading. +CatBoost pipeline steps are also rejected, with unsupported types listed and +an instruction to export `pipeline.named_steps["est"]` directly. Native adapters +own standalone XGBoost and CatBoost loading; neither is implicitly unwrapped. MLPClassifier/MLPRegressor support adam, sgd and lbfgs, alone or in a Pipeline. MLP structure is null; layer weights and complete fitted/optimizer state are bound by the manifest state commitment. Optimizers permit ObjectNode only diff --git a/docs/format-catboost.md b/docs/format-catboost.md new file mode 100644 index 0000000..9d2bd2f --- /dev/null +++ b/docs/format-catboost.md @@ -0,0 +1,164 @@ +# Version 1 CatBoost mapping + +Normative section of [the format contract](format.md). Audited and tested with +CatBoost 1.2.10 on Python 3.14. `catboost>=1.2.10` is an optional extra included +in `all`; its floor supplies the tested CBM/API baseline and Python 3.14 wheels. +The floor does not promise compatibility with every later native schema. +Base import and metadata-only inspect do not import CatBoost. + +## Closed support boundary + +Only exact fitted `catboost.CatBoostClassifier` and `CatBoostRegressor` are +supported, with one target and numeric features. Classifier losses are +`Logloss`, `CrossEntropy`, `MultiClass`. Regressor losses are `RMSE`, `MAE`, +`Quantile`, `Huber`, `Poisson`, `MAPE`, including their native parameter strings. +`MultiClassOneVsAll` does not guarantee probability row sums of one and is +rejected. Multi-target/uncertainty, ranking, other losses, bare CatBoost, +rankers, subclasses and custom Python objectives/metrics are unsupported. +The fitted wrapper attribute set and native `_object` type are closed; overrides, unknown +attributes and prediction-changing parameter caches are rejected. Export never +silently normalizes a blob-loaded Poisson wrapper that originally predicts raw values. + +Only `SymmetricTree` (oblivious trees) is admitted. `Depthwise` and `Lossguide` +are rejected. Categorical, text and embedding features are rejected at export +and independently before native load. Categorical CBM round trips work natively, +but CTR storage has an unchecked native loader and needs its own preflight. +There is no implicit input encoding or model conversion in this adapter. + +A sklearn Pipeline containing CatBoost is unsupported. The skops trust list +does not admit CatBoost; export lists the unsupported types and instructs the +caller to export `pipeline.named_steps["est"]` directly. No implicit unwrapping. +`class_labels=` is rejected even if equal: native `classes_` owns the labels. +Class arrays retain native scalar types and probability-column order and are +output-major, including `[[0, 1]]` for binary classification. Int and string +binary/multiclass labels have tested exact round trips. + +## Native I/O and wrapper recovery + +Library/serialization/member pairing is `catboost` / `cbm` / `model.cbm`. +There is exactly one native member per bundle; no pickle or skops fallback. +Public `save_model(path, format="cbm")` writes in an export-only temporary +directory that is removed before publication. Public saving has no in-memory +API. Public `load_model(blob=data)` consumes bytes without reader-side disk I/O. +The blob path ignores its `format` argument and supports CBM, not native JSON. + +CatBoost's audited private `_serialize_model()` returns CBM bytes through its +C++ `SerializeModel`; it is used only to recompute current native state for +extraction/verification. It performs no pickle serialization and writes no files. +CBM layout/metadata order may change when reserialized, so verification compares +semantic snapshots, not a SHA of the entire serialized CBM buffer. + +Blob loading preserves `get_all_params`, feature names, class labels, best +iteration, stored tree truncation, scale/bias and probability threshold. +It loses constructor `get_params` and returns zero for `n_features_in_`. +The reader chooses a class from the fixed classifier/regressor map using the +validated native loss and sets its feature-width cache from the native table. +It constructs that wrapper with the validated loss. This is prediction-critical +for Poisson: a default regressor loaded via blob otherwise predicts raw values +instead of exponentiated values. No manifest setting restores state. + +Constructor parameters are not claimed or reconstructed wholesale. The +recoverable effective parameters from `get_all_params()` are claimed. Native +training/evaluation metadata is committed verbatim, but API evaluation-history +projections are not separately claimed: native parsing may change their float +spelling/projection without changing predictions. Native labels/borders and +numeric CBM probability/regression predictions survived tested round trips +exactly. JSON file export/load produced float noise and is not the chosen format. + +## Claims and complete structure + +`hyperparameters` has exactly `parameters` (the recoverable `get_all_params()` +JSON projection), `tree_count` (actual stored trees), `best_iteration` (native +value or null), `scale_and_bias` (native pair), and `probability_threshold` +(native classifier threshold, null for a regressor). CatBoost predicts all stored +trees by default; best iteration is descriptive, and shrinking changes the +actual native tree arrays. Feature names are always model-origin: CatBoost +generates numeric string names when fitting unnamed arrays. `model_dtypes` is +null; numerical acceptance/quantization does not establish training dtypes. + +Counts, kind, class identity, ordered names, native labels, hyperparameters and +`model.state_sha256` are extracted by the same code at export/load and compared +by canonical JSON bytes. The commitment covers every admitted native feature, +tree, repacked split and all native metadata, including class mapping, params, +best-iteration/training metadata and the threshold. It does not authenticate +training history. Loaded results contain the real CatBoost object. Mutating it +invalidates the verified relationship with metadata. + +Mandatory structure is exactly `family="catboost"`, `feature_importances` +and `native_model`. Importances are freshly computed with +`get_feature_importance(type="PredictionValuesChange", thread_count=1)` on a +native public `copy()`, so computation cannot overwrite the caller's importance cache; +not copied from the fitted wrapper's cached `feature_importances_`. +`native_model` has exactly these fields: + +| Field | Native representation | +| --- | --- | +| `approx_dimension` | One for binary/regression; class count for MultiClass. | +| `tree_depths` | Depth per tree, in native order. | +| `tree_split_offsets` | Start offset per tree into `tree_splits`; cumulative depths. | +| `tree_splits` | Native bin-feature indices, in tree/level order. | +| `leaf_values` | Native flat binary64 array in tree, leaf, approximation-dimension order. Tree `t` has `2**tree_depths[t] * approx_dimension` values; retain every dimension. | +| `leaf_weights` | Native flat binary64 weights in tree/leaf order. | +| `float_features` | Native ordered records with `index`, `flat_index`, `name`, `has_nans`, `nan_value_treatment`, `borders`. Borders promote binary32 exactly to binary64. NaN treatment is native enum 0=AsIs, 1=AsFalse, 2=AsTrue. | +| `scale`, `bias` | Native binary64 scale and dimension-length bias. Legacy scalar bias normalizes to that vector. | +| `repacked_bins` | Native little-endian uint32 words: low 16 bits feature bucket, next byte XOR mask, high byte split index; zero or one per stored split. | + +The split lookup flattens float-feature borders in feature/border order. Bit +`level` in a leaf index corresponds to that level's split decision. Splits use +CatBoost's native greater-than-border test after native NaN handling. No pruning, +rounding or manufactured node gains. Full canonical structure comparison rejects +missing, altered or additional records. The existing 256 MiB structure ceiling +and caller-lowered limits apply; oversize structure is rejected, never truncated. + +### Native state commitment + +Root is exactly `{"native_model":NATIVE,"metadata":INFO_MAP}` using the snapshot +above before JSON conversion. Every dictionary replaces children by digests +then hashes `b"dict:" + canonical_dumps(record)`. Lists hash +`b"list:" + canonical_dumps(child_digests)`. Scalars hash +`b"scalar:" + canonical_dumps(value)`. Each numeric array hashes +`b"array:" + canonical_dumps([dtype.str, shape]) + contiguous_bytes`. +Arrays use their native little-endian `=1.6", "skops>=0.16.0"] xgboost = ["xgboost>=3.4.1", "scikit-learn>=1.2"] +# Audited CBM schema and Python 3.14 wheels on Windows/Linux. +catboost = ["catboost>=1.2.10"] onnx = ["onnx>=1.23.0", "onnxruntime>=1.30.0"] # The reader validates the 0.2 inferred vocabulary; 0.2.1 requires Python <3.15. schema = ["mlschema>=0.2.1,<0.3; python_version == '3.14'"] all = [ - "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", + "scikit-learn>=1.6", "skops>=0.16.0", "xgboost>=3.4.1", "catboost>=1.2.10", "onnx>=1.23.0", "onnxruntime>=1.30.0", "mlschema>=0.2.1,<0.3; python_version == '3.14'", ] diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index c64f764..441e6ca 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -13,6 +13,7 @@ "sklearn", "skops", "xgboost", + "catboost", "onnx", "onnxruntime", "numpy", diff --git a/src/mlbundle/_catboost/__init__.py b/src/mlbundle/_catboost/__init__.py new file mode 100644 index 0000000..0883f20 --- /dev/null +++ b/src/mlbundle/_catboost/__init__.py @@ -0,0 +1,159 @@ +"""Native CBM adapter; exact CatBoost wrappers and no reader-side file I/O.""" + +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, cast + +import catboost as cb +from pydantic import JsonValue + +from mlbundle.canonical import canonical_value +from mlbundle.claims import ClassLabels, ModelClaims +from mlbundle.contract import Library, MemberName, ModelKind +from mlbundle.declarations import ExportDeclarations +from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError + +from .native import decode +from .prediction import Predictor +from .scope import CLASSIFIERS, validate +from .state import commitment, feature_importances, json_value, snapshot + + +class Adapter: + library = Library.CATBOOST + model_member = MemberName.CBM + + def recognizes(self, model: object) -> bool: + return isinstance(model, cb.CatBoost) + + def prepare_export(self, model: Any, declarations: ExportDeclarations) -> object: + try: + validate(model) + if declarations.class_labels is not None: + raise UnsupportedModelError( + reason="CatBoost already carries class labels; class_labels is unsupported." + ) + self.extract_claims(model) + return model + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="CatBoost export preparation failed." + ) from None + + def serialize(self, model: Any) -> bytes: + try: + validate(model) + # Public save_model accepts paths only; export owns this temporary file. + with TemporaryDirectory(prefix="mlbundle-catboost-") as directory: + path = Path(directory) / "model.cbm" + model.save_model(str(path), format="cbm") + data = path.read_bytes() + decode(data) + return data + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="CatBoost CBM serialization failed." + ) from None + + def deserialize(self, data: bytes) -> object: + try: + state = decode(data) + cls = ( + cb.CatBoostClassifier + if state.loss in CLASSIFIERS + else cb.CatBoostRegressor + ) + # blob loading does not restore _init_params. Poisson's default predict + # selects Exponent from that wrapper field, so bind the validated loss. + model: Any = cls(loss_function=state.loss) + model.load_model(blob=data) + model._n_features_in = state.width + actual = snapshot(model) + if commitment(state) != commitment(actual): + raise InvalidModelError( + "CatBoost state does not survive native construction." + ) + validate(model) + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid CatBoost CBM payload.") from None + + def extract_claims(self, model: Any) -> ModelClaims: + try: + validate(model) + state = snapshot(model) + classifier = type(model) is cb.CatBoostClassifier + if ( + classifier != (state.loss in CLASSIFIERS) + or model.n_features_in_ not in (0, state.width) + or model.tree_count_ != len(state.native["tree_depths"]) + ): + raise InvalidModelError( + "CatBoost wrapper and native dimensions or kind disagree." + ) + labels = ( + cast(ClassLabels, canonical_value([model.classes_])) + if classifier + else None + ) + if classifier and ( + labels is None + or len(labels[0]) + != (state.dimension if state.loss == "MultiClass" else 2) + ): + raise InvalidModelError( + "CatBoost classes and native dimensions disagree." + ) + loss = model.get_params().get("loss_function") + if loss is not None and loss.split(":", 1)[0] != state.loss: + raise InvalidModelError("CatBoost wrapper and native loss disagree.") + return ModelClaims( + estimator_class=type(model).__name__, + kind=ModelKind.CLASSIFIER if classifier else ModelKind.REGRESSOR, + hyperparameters={ + "parameters": canonical_value(model.get_all_params()), + "tree_count": model.tree_count_, + "best_iteration": model.get_best_iteration(), + "scale_and_bias": canonical_value(model.get_scale_and_bias()), + "probability_threshold": model.get_probability_threshold() + if classifier + else None, + }, + state_sha256=commitment(state), + n_features=state.width, + n_classes=None if labels is None else [len(labels[0])], + n_outputs=1, + feature_names=model.feature_names_, + feature_dtypes=None, + class_labels=labels, + ) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid CatBoost model claims.") from None + + def extract_structure(self, model: Any) -> dict[str, JsonValue]: + try: + validate(model) + state = snapshot(model) + return cast( + dict[str, JsonValue], + { + "family": "catboost", + "native_model": json_value(state.native), + "feature_importances": feature_importances(model, state.width), + }, + ) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid CatBoost model structure.") from None + + def predictor(self, model: object) -> Predictor: + return Predictor(model, self.extract_claims(model)) diff --git a/src/mlbundle/_catboost/budget.py b/src/mlbundle/_catboost/budget.py new file mode 100644 index 0000000..95c095b --- /dev/null +++ b/src/mlbundle/_catboost/budget.py @@ -0,0 +1,22 @@ +"""Bound structure expansion before native parsing or allocating Python lists.""" + +from typing import Any + +from mlbundle.errors import LimitExceededError +from mlbundle.limits import STRUCTURE_BYTES + + +def structure_budget(native: dict[str, Any]) -> None: + # Worst finite binary64 JSON token plus separator is 25 bytes. The bound + # intentionally rejects some compact-value models before list allocation. + size = 4096 + for value in native.values(): + if hasattr(value, "dtype"): + size += value.size * (25 if value.dtype.kind == "f" else 12) + for feature in native["float_features"]: + size += 256 + 6 * len(feature["name"].encode("utf-8")) + size += feature["borders"].size * 25 + if size > STRUCTURE_BYTES: + raise LimitExceededError( + "CatBoost structure allocation budget exceeds the limit." + ) diff --git a/src/mlbundle/_catboost/flatbuffer.py b/src/mlbundle/_catboost/flatbuffer.py new file mode 100644 index 0000000..a5278d7 --- /dev/null +++ b/src/mlbundle/_catboost/flatbuffer.py @@ -0,0 +1,104 @@ +"""Checked views of the closed CatBoost 1.2.10 CBM FlatBuffer schema.""" + +import struct +from typing import Any + +import numpy as np + +from mlbundle.errors import InvalidModelError + + +class Buffer: + def __init__(self, data: memoryview) -> None: + self.data = data + self.work = 0 + + def span(self, position: int, size: int) -> memoryview: + if position < 0 or size < 0 or size > len(self.data) - position: + raise InvalidModelError("Truncated CatBoost CBM buffer.") + return self.data[position : position + size] + + def number(self, position: int, fmt: str) -> Any: + return struct.unpack("<" + fmt, self.span(position, struct.calcsize(fmt)))[0] + + def indirect(self, position: int) -> int: + offset = self.number(position, "I") + if offset < 4: + raise InvalidModelError("Invalid CatBoost CBM offset.") + target = position + offset + self.span(target, 4) + return target + + def table(self, position: int, fields: int) -> "Table": + self.work += 1 + if self.work > 200_000: + raise InvalidModelError("CatBoost CBM table work exceeds the limit.") + return Table(self, position, fields) + + def string(self, position: int, cap: int) -> str: + start = self.indirect(position) + size = self.number(start, "I") + if size > cap or self.number(start + 4 + size, "B") != 0: + raise InvalidModelError("Invalid CatBoost CBM string length.") + return bytes(self.span(start + 4, size)).decode("utf-8") + + +class Table: + def __init__(self, buffer: Buffer, position: int, fields: int) -> None: + self.buffer, self.position = buffer, position + vtable = position - buffer.number(position, "i") + size = buffer.number(vtable, "H") + self.size = buffer.number(vtable + 2, "H") + if size < 4 or size % 2 or size > 4 + 2 * fields or self.size < 4: + raise InvalidModelError("Unknown or invalid CatBoost CBM table fields.") + buffer.span(position, self.size) + buffer.span(vtable, size) + self.offsets = [buffer.number(vtable + i, "H") for i in range(4, size, 2)] + if any(offset and not 4 <= offset < self.size for offset in self.offsets): + raise InvalidModelError("Invalid CatBoost CBM field offset.") + + def field(self, index: int, size: int = 4) -> int | None: + offset = self.offsets[index] if index < len(self.offsets) else 0 + if not offset: + return None + if size > self.size - offset: + raise InvalidModelError("Invalid CatBoost CBM field size.") + return self.position + offset + + def scalar(self, index: int, fmt: str, default: Any = 0) -> Any: + position = self.field(index, struct.calcsize(fmt)) + return default if position is None else self.buffer.number(position, fmt) + + def string(self, index: int, cap: int) -> str: + position = self.field(index) + return "" if position is None else self.buffer.string(position, cap) + + def child(self, index: int, fields: int) -> "Table": + position = self.field(index) + if position is None: + raise InvalidModelError("Missing CatBoost CBM table.") + return self.buffer.table(self.buffer.indirect(position), fields) + + def vector(self, index: int, dtype: str, cap: int) -> np.ndarray: + position = self.field(index) + kind = np.dtype(dtype) + if position is None: + return np.empty(0, dtype=kind) + start = self.buffer.indirect(position) + count = self.buffer.number(start, "I") + if count > cap: + raise InvalidModelError("CatBoost CBM vector count exceeds the limit.") + return np.frombuffer(self.buffer.span(start + 4, count * kind.itemsize), kind) + + def tables(self, index: int, fields: int, cap: int) -> list["Table"]: + offsets = self.vector(index, " list[dict[str, Any]]: + records = [] + remaining = MAX_TREES * MAX_DEPTH + for feature in tree.tables(5, 6, MAX_FEATURES): + has_nans = feature.scalar(0, "B") + index, flat = (feature.scalar(i, "i", -1) for i in (1, 2)) + treatment = feature.scalar(5, "b") + borders = feature.vector(3, " 1024: + raise InvalidModelError("CatBoost feature name exceeds the limit.") + records.append( + { + "index": index, + "flat_index": flat, + "name": name, + "has_nans": bool(has_nans), + "nan_value_treatment": treatment, + "borders": borders, + } + ) + if ( + not records + or [r["index"] for r in records] != list(range(len(records))) + or [r["flat_index"] for r in records] != list(range(len(records))) + ): + raise InvalidModelError("Invalid CatBoost numeric feature order.") + return records + + +def _metadata(core: Table) -> dict[str, str]: + result = {} + total = 0 + for item in core.tables(2, 2, 4096): + key, value = item.string(0, 1024), item.string(1, MANIFEST_BYTES) + total += len(key.encode()) + len(value.encode()) + if not key or key in result or total > MANIFEST_BYTES: + raise InvalidModelError("Invalid or oversized CatBoost metadata.") + result[key] = value + for key in ("training", "output_options"): + if key in result: + canonical_loads(result[key].encode(), max_bytes=MANIFEST_BYTES) + return result + + +def _classes(metadata: dict[str, str], count: int) -> None: + classes = canonical_loads( + metadata["class_params"].encode(), max_bytes=HYPERPARAMETER_BYTES + ) + if not isinstance(classes, dict): + raise InvalidModelError("Invalid CatBoost native class record.") + mapping, names = classes["class_to_label"], classes["class_names"] + if ( + not isinstance(mapping, list) + or len(mapping) != count + or not isinstance(names, list) + or len(names) not in (0, count) + ): + raise InvalidModelError("Invalid CatBoost native class count.") + declared = classes["classes_count"] + if type(declared) is not int or declared not in (0, count): + raise InvalidModelError("Invalid CatBoost declared class count.") + if classes.get("class_label_type") not in ( + "Integer", + "Float", + "String", + "Boolean", + ) or any( + type(label) not in (int, float) or not math.isfinite(cast(float, label)) + for label in mapping + ): + raise InvalidModelError("Invalid CatBoost native class mapping.") + + +def _loss(metadata: dict[str, str], dimension: int) -> str: + params = canonical_loads( + metadata["params"].encode(), max_bytes=HYPERPARAMETER_BYTES, max_depth=16 + ) + if not isinstance(params, dict): + raise InvalidModelError("Invalid CatBoost native parameters.") + params = cast(dict[str, Any], params) + loss = params["loss_function"]["type"] + if ( + loss not in CLASSIFIERS | REGRESSORS + or params["tree_learner_options"]["grow_policy"] != "SymmetricTree" + ): + raise InvalidModelError("Unsupported CatBoost native loss or tree policy.") + if loss == "MultiClass": + if not 2 <= dimension <= MAX_CLASSES: + raise InvalidModelError("Invalid CatBoost multiclass dimension.") + elif dimension != 1: + raise InvalidModelError("CatBoost requires a single target.") + for metric in [ + params["metrics"]["eval_metric"], + *params["metrics"]["custom_metrics"], + ]: + if metric["type"].startswith("Python"): + raise InvalidModelError("Custom Python CatBoost metrics are unsupported.") + if loss in CLASSIFIERS: + _classes(metadata, dimension if loss == "MultiClass" else 2) + return loss + + +def _trees(tree: Table) -> dict[str, Any]: + dimension = tree.scalar(0, "i") + if not 1 <= dimension <= MAX_CLASSES: + raise InvalidModelError("Invalid CatBoost output dimension.") + depths = tree.vector(2, " MAX_DEPTH) + or len(offsets) != len(depths) + ): + raise InvalidModelError("Invalid CatBoost tree count or depth.") + expected = np.concatenate(([0], np.cumsum(depths[:-1], dtype=np.int64))) + if not np.array_equal(offsets, expected) or len(splits) != int(depths.sum()): + raise InvalidModelError("Invalid CatBoost tree split offsets.") + leaves = int(np.left_shift(np.int64(1), depths).sum()) + values = tree.vector(8, "= border_count): + raise InvalidModelError("Invalid CatBoost split feature indices.") + scale, legacy_bias = tree.scalar(14, "d", 1.0), tree.scalar(15, "d", 0.0) + bias = tree.vector(16, " Snapshot: + if ( + type(data) is not bytes + or len(data) > ArchiveLimits().max_member_bytes + or len(data) < 12 + or data[:4] != b"CBM1" + ): + raise InvalidModelError("Expected a bounded native CatBoost CBM model.") + size = struct.unpack_from(" None: + self.model, self.claims = model, claims + + def _call(self, data: object, *, probabilities: bool) -> tuple[np.ndarray, int]: + try: + inputs, rows = tabular(data, self.claims.n_features) + if rows < 1: + raise PredictionInputError( + "CatBoost prediction requires at least one row." + ) + if ( + hasattr(inputs, "columns") + and list(inputs.columns) != self.claims.feature_names + ): + raise PredictionInputError( + "CatBoost prediction columns must match native feature order." + ) + raw = ( + self.model.predict_proba(inputs, thread_count=1) + if probabilities + else self.model.predict(inputs, thread_count=1) + ) + return np.asarray(raw, dtype=float), rows + except MLBundleError: + raise + except Exception: + raise InvalidModelError("CatBoost prediction failed.") from None + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + values, rows = self._call(data, probabilities=False) + if values.shape != (rows,) or not np.isfinite(values).all(): + raise InvalidModelError( + "Invalid regression prediction dimensions or values." + ) + return cast(RegressionOutput, values.tolist()) + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + values, rows = self._call(data, probabilities=True) + if ( + values.shape != (rows, (self.claims.n_classes or [0])[0]) + or not np.isfinite(values).all() + or np.any(values < 0) + or np.any(values > 1) + or not np.allclose(values.sum(axis=1), 1.0, rtol=1e-6, atol=1e-7) + ): + raise InvalidModelError( + "Invalid probability dimensions, values or row sums." + ) + return cast(ProbabilityOutput, values.tolist()) diff --git a/src/mlbundle/_catboost/scope.py b/src/mlbundle/_catboost/scope.py new file mode 100644 index 0000000..7c0df4b --- /dev/null +++ b/src/mlbundle/_catboost/scope.py @@ -0,0 +1,109 @@ +"""Exact wrappers, verified losses and numeric symmetric trees only.""" + +from typing import Any + +import catboost as cb + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidJSONError, UnsupportedModelError +from mlbundle.limits import HYPERPARAMETER_BYTES + +CLASSIFIERS = frozenset({"Logloss", "CrossEntropy", "MultiClass"}) +REGRESSORS = frozenset({"RMSE", "MAE", "Quantile", "Huber", "Poisson", "MAPE"}) +CLASSES = (cb.CatBoostClassifier, cb.CatBoostRegressor) +NATIVE_CLASS = type(vars(cb.CatBoostClassifier())["_object"]) +MAX_TREES = 100_000 +MAX_DEPTH = 16 +WRAPPER_FIELDS = frozenset( + { + "_init_params", + "_canonized_params", + "_object", + "_is_fitted_", + "_random_seed", + "_learning_rate", + "_tree_count", + "_n_features_in", + "_prediction_values_change", + "_loss_value_change", + } +) + + +def _parameters(params: dict[str, Any]) -> None: + for key in ("loss_function", "objective", "eval_metric"): + if params.get(key) is not None and not isinstance(params[key], str): + raise UnsupportedModelError( + reason="Custom Python CatBoost objectives and metrics are unsupported." + ) + metrics = params.get("custom_metric", []) + if isinstance(metrics, str): + metrics = [metrics] + if metrics is not None and any(not isinstance(metric, str) for metric in metrics): + raise UnsupportedModelError( + reason="Custom Python CatBoost objectives and metrics are unsupported." + ) + try: + canonical_dumps(params, max_bytes=HYPERPARAMETER_BYTES) + except InvalidJSONError: + raise UnsupportedModelError( + reason="CatBoost parameters must contain only JSON values." + ) from None + + +def _wrapper(model: Any, loss: str) -> None: + cached = model._canonized_params + params = model.get_params() if cached is None else cached + if not isinstance(params, dict): + raise UnsupportedModelError(reason="Invalid CatBoost wrapper parameter cache.") + _parameters(params) + if type(model) is cb.CatBoostRegressor: + declared = params.get("loss_function", params.get("objective")) + mode = "RawFormulaVal" + if isinstance(declared, str): + if declared.startswith(("Poisson", "Tweedie")): + mode = "Exponent" + elif declared == "RMSEWithUncertainty": + mode = "RMSEWithUncertainty" + if mode != ("Exponent" if loss == "Poisson" else "RawFormulaVal"): + raise UnsupportedModelError( + reason="CatBoost wrapper default prediction does not survive CBM loading." + ) + + +def validate(model: Any) -> None: + if type(model) not in CLASSES: + raise UnsupportedModelError( + reason="Only exact CatBoostClassifier and CatBoostRegressor are supported." + ) + if set(vars(model)) - WRAPPER_FIELDS: + raise UnsupportedModelError( + reason="Unknown CatBoost wrapper attributes are unsupported." + ) + if type(vars(model).get("_object")) is not NATIVE_CLASS: + raise UnsupportedModelError( + reason="Only the native CatBoost model object is supported." + ) + if not model.is_fitted(): + raise UnsupportedModelError(reason="CatBoost estimator must be fitted.") + _parameters(model.get_params()) + if model.get_cat_feature_indices(): + raise UnsupportedModelError( + reason="CatBoost categorical features are unsupported." + ) + if model.get_text_feature_indices() or model.get_embedding_feature_indices(): + raise UnsupportedModelError( + reason="CatBoost text and embedding features are unsupported." + ) + native = model.get_all_params() + loss = native["loss_function"].split(":", 1)[0] + allowed = CLASSIFIERS if type(model) is cb.CatBoostClassifier else REGRESSORS + if loss not in allowed: + raise UnsupportedModelError( + reason="Unsupported CatBoost loss; only closed single-target losses are supported." + ) + if native.get("grow_policy") != "SymmetricTree": + raise UnsupportedModelError( + reason="Only CatBoost SymmetricTree is supported; Depthwise/Lossguide are unsupported." + ) + _wrapper(model, loss) diff --git a/src/mlbundle/_catboost/state.py b/src/mlbundle/_catboost/state.py new file mode 100644 index 0000000..b466d58 --- /dev/null +++ b/src/mlbundle/_catboost/state.py @@ -0,0 +1,62 @@ +"""Recomputed native snapshots and commitments independent of CBM buffer layout.""" + +from hashlib import sha256 +from typing import Any + +import numpy as np + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidModelError +from mlbundle.limits import MANIFEST_BYTES + +from .native import Snapshot, decode + + +def snapshot(model: Any) -> Snapshot: + # CatBoost has no public in-memory save. This audited native CBM serializer + # lets extraction recompute loaded state without reader-side disk writes. + return decode(model._serialize_model()) + + +def commitment(state: Snapshot) -> str: + def digest(value: Any) -> str: + if isinstance(value, np.ndarray): + header = canonical_dumps([value.dtype.str, list(value.shape)]) + result = sha256(b"array:") + result.update(header) + result.update(memoryview(value)) + return result.hexdigest() + if isinstance(value, dict): + record = {key: digest(child) for key, child in value.items()} + return sha256(b"dict:" + canonical_dumps(record)).hexdigest() + if isinstance(value, list): + return sha256( + b"list:" + canonical_dumps([digest(child) for child in value]) + ).hexdigest() + return sha256( + b"scalar:" + canonical_dumps(value, max_bytes=MANIFEST_BYTES) + ).hexdigest() + + return digest({"native_model": state.native, "metadata": state.metadata}) + + +def json_value(value: Any) -> Any: + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, dict): + return {key: json_value(child) for key, child in value.items()} + if isinstance(value, list): + return [json_value(child) for child in value] + return value + + +def feature_importances(model: Any, width: int) -> list[float]: + values = np.asarray( + model.copy().get_feature_importance( + type="PredictionValuesChange", thread_count=1 + ), + dtype=float, + ) + if values.shape != (width,) or not np.isfinite(values).all() or np.any(values < 0): + raise InvalidModelError("Invalid CatBoost feature importances.") + return values.tolist() diff --git a/src/mlbundle/_container_rules.py b/src/mlbundle/_container_rules.py index ec49ca7..a843b35 100644 --- a/src/mlbundle/_container_rules.py +++ b/src/mlbundle/_container_rules.py @@ -15,6 +15,7 @@ MemberName.MANIFEST, MemberName.SKOPS, MemberName.UBJSON, + MemberName.CBM, MemberName.ONNX, MemberName.SCHEMA, MemberName.STRUCTURE, diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index 67242bd..fe04903 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -36,6 +36,14 @@ def _untrusted(data: bytes) -> None: sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) ) if unsupported: + if any(name.startswith("catboost.") for name in unsupported): + raise UnsupportedModelError( + unsupported, + reason=( + f"{UnsupportedModelError(unsupported)} " + 'Export the estimator directly: export(pipeline.named_steps["est"], ...).' + ), + ) raise UnsupportedModelError(unsupported) @@ -90,7 +98,7 @@ class Adapter: def recognizes(self, model: object) -> bool: return ( isinstance(model, BaseEstimator) - and not type(model).__module__.startswith("xgboost.") + and not type(model).__module__.startswith(("xgboost.", "catboost.")) and (is_classifier(model) or is_regressor(model)) ) @@ -133,7 +141,11 @@ def _validate(self, model: Any) -> None: def serialize(self, model: object) -> bytes: try: data = sio.dumps(model) - validate_payload(data) + try: + validate_payload(data) + except UnsupportedModelError: + _untrusted(data) + raise _untrusted(data) self._validate(model) return data diff --git a/src/mlbundle/contract.py b/src/mlbundle/contract.py index aa65b3a..3501fdb 100644 --- a/src/mlbundle/contract.py +++ b/src/mlbundle/contract.py @@ -10,6 +10,7 @@ class Library(StrEnum): SKLEARN = "sklearn" XGBOOST = "xgboost" + CATBOOST = "catboost" ONNX = "onnx" @@ -21,6 +22,7 @@ class ModelKind(StrEnum): class SerializationFormat(StrEnum): SKOPS = "skops" UBJSON = "ubjson" + CBM = "cbm" ONNX = "onnx" @@ -35,6 +37,7 @@ class MemberName(StrEnum): MANIFEST = "manifest.json" SKOPS = "model.skops" UBJSON = "model.ubj" + CBM = "model.cbm" ONNX = "model.onnx" SCHEMA = "schema.json" STRUCTURE = "structure.json" @@ -45,6 +48,7 @@ class MemberName(StrEnum): { Library.SKLEARN: (SerializationFormat.SKOPS, MemberName.SKOPS), Library.XGBOOST: (SerializationFormat.UBJSON, MemberName.UBJSON), + Library.CATBOOST: (SerializationFormat.CBM, MemberName.CBM), Library.ONNX: (SerializationFormat.ONNX, MemberName.ONNX), } ) diff --git a/src/mlbundle/environment.py b/src/mlbundle/environment.py index 26f535a..67807ad 100644 --- a/src/mlbundle/environment.py +++ b/src/mlbundle/environment.py @@ -18,6 +18,7 @@ "scipy", "scikit-learn", "xgboost", + "catboost", "onnx", "onnxruntime", "skops", @@ -30,6 +31,7 @@ "scipy", "scikit-learn", "xgboost", + "catboost", "onnx", "onnxruntime", "skops", diff --git a/src/mlbundle/registry.py b/src/mlbundle/registry.py index cf159d2..eacdd8a 100644 --- a/src/mlbundle/registry.py +++ b/src/mlbundle/registry.py @@ -26,6 +26,19 @@ {"xgboost", "sklearn", "numpy", "scipy"}, ), Library.ONNX: ("mlbundle._onnx", "onnx", {"onnx", "onnxruntime", "numpy"}), + Library.CATBOOST: ( + "mlbundle._catboost", + "catboost", + { + "catboost", + "numpy", + "scipy", + "pandas", + "matplotlib", + "plotly", + "graphviz", + }, + ), } ) diff --git a/tests/catboost_helpers.py b/tests/catboost_helpers.py new file mode 100644 index 0000000..be71e36 --- /dev/null +++ b/tests/catboost_helpers.py @@ -0,0 +1,76 @@ +"""Small native fixtures and CBM byte mutations; never use production models.""" + +import struct + +import catboost as cb +import numpy as np +import pandas as pd + +from mlbundle import MemberName +from mlbundle._catboost.flatbuffer import Buffer +from mlbundle.container import read_container +from mlbundle.writing import write_container + + +def data(): + values = np.random.default_rng(7).normal(size=(45, 3)) + return pd.DataFrame(values, columns=list("abc")), np.arange(45) % 3 + + +def classifier(frame=None, labels=None, **params): + x, y = data() + return cb.CatBoostClassifier( + iterations=4, + depth=2, + thread_count=1, + verbose=False, + allow_writing_files=False, + **params, + ).fit(x if frame is None else frame, y if labels is None else labels) + + +def regressor(loss="RMSE", **params): + x, y = data() + return cb.CatBoostRegressor( + iterations=4, + depth=2, + thread_count=1, + verbose=False, + allow_writing_files=False, + loss_function=loss, + **params, + ).fit(x, y + 1.0) + + +def rewrite(path, change): + manifest, members = read_container(path) + parts = manifest.model_dump(mode="json", by_alias=True) + del members[MemberName.MANIFEST] + change(parts, members) + return write_container(parts, members) + + +def tables(data): + buffer = Buffer(memoryview(data)[8:]) + core = buffer.table(buffer.indirect(0), 4) + return core, core.child(1, 19) + + +def field(data, index, value, fmt="i", *, feature=False): + _, tree = tables(data) + target = tree.tables(5, 6, 100_000)[0] if feature else tree + position = target.field(index, struct.calcsize(fmt)) + assert position is not None + result = bytearray(data) + struct.pack_into("<" + fmt, result, position + 8, value) + return bytes(result) + + +def vector(data, index, value, fmt="i", *, count=False): + _, tree = tables(data) + position = tree.field(index) + assert position is not None + start = tree.buffer.indirect(position) + 8 + result = bytearray(data) + struct.pack_into("<" + fmt, result, start if count else start + 4, value) + return bytes(result) diff --git a/tests/test_catboost.py b/tests/test_catboost.py new file mode 100644 index 0000000..129884f --- /dev/null +++ b/tests/test_catboost.py @@ -0,0 +1,156 @@ +"""CatBoost native round trips, every admitted loss, labels, and publication.""" + +import subprocess +import sys +from pathlib import Path + +import catboost as cb +import numpy as np +import pytest + +import mlbundle +from catboost_helpers import classifier, data, regressor +from mlbundle._catboost import Adapter +from mlbundle._catboost.scope import REGRESSORS +from mlbundle.canonical import canonical_dumps + + +@pytest.mark.parametrize( + "labels", [[0, 1], [10, 20], ["cold", "warm"], [10, 20, 30], ["a", "b", "c"]] +) +def test_classifier_roundtrip(labels, tmp_path): + x, y = data() + model = classifier(labels=np.asarray(labels)[y % len(labels)]) + before = model._serialize_model() + path = tmp_path / "classifier.mlbundle" + view = mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert type(loaded.native_model) is cb.CatBoostClassifier + assert view.manifest == loaded.manifest + assert model._serialize_model() == before + assert loaded.library == "catboost" + assert view.manifest.model.serialization == "cbm" + assert view.manifest.environment.packages["catboost"] == cb.__version__ + assert loaded.class_labels == [labels] + assert list(loaded.feature_names) == list("abc") + assert loaded.feature_origin == "model" + assert loaded.native_model.n_features_in_ == 3 + assert loaded.native_model.get_all_params() == model.get_all_params() + for inputs in [x, x.to_numpy(), x.to_numpy().tolist()]: + assert np.array_equal(loaded.predict_proba(inputs), model.predict_proba(inputs)) + assert np.array_equal(loaded.native_model.predict(x), model.predict(x)) + assert canonical_dumps(loaded.structure) == canonical_dumps( + Adapter().extract_structure(model) + ) + for command in ("inspect", "verify"): + executable = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" + ) + result = subprocess.run( + [executable, command, str(path)], + capture_output=True, + text=True, + check=True, + ) + assert not result.stderr + + +@pytest.mark.parametrize("loss", ["Logloss", "CrossEntropy", "MultiClass"]) +def test_classifier_losses(loss, tmp_path): + x, y = data() + model = classifier(labels=y if loss == "MultiClass" else y % 2, loss_function=loss) + path = tmp_path / "loss.mlbundle" + mlbundle.export(model, path) + assert np.array_equal(mlbundle.load(path).predict_proba(x), model.predict_proba(x)) + + +@pytest.mark.parametrize("loss", sorted(REGRESSORS)) +def test_regressor_losses(loss, tmp_path): + options = {"Huber": "Huber:delta=1.0", "Quantile": "Quantile:alpha=0.7"} + model = regressor(options.get(loss, loss)) + x, _ = data() + path = tmp_path / "regressor.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert type(loaded.native_model) is cb.CatBoostRegressor + assert loaded.class_labels is None + assert np.array_equal(loaded.predict(x), model.predict(x)) + assert np.array_equal(loaded.native_model.predict(x), model.predict(x)) + assert loaded.native_model.get_all_params() == model.get_all_params() + + +def test_calibration_truncation_and_best_iteration(tmp_path): + x, y = data() + model = cb.CatBoostClassifier( + iterations=9, + depth=2, + thread_count=1, + verbose=False, + allow_writing_files=False, + use_best_model=False, + ).fit(x, y % 2, eval_set=(x, y % 2)) + model.shrink(4) + model.set_scale_and_bias(1.2, 0.3) + model.set_probability_threshold(0.65) + path = tmp_path / "calibrated.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + params = loaded.manifest.model.hyperparameters + assert params["tree_count"] == 4 + assert params["best_iteration"] == model.get_best_iteration() + assert params["scale_and_bias"] == [1.2, 0.3] + assert params["probability_threshold"] == 0.65 + assert np.array_equal(loaded.predict_proba(x), model.predict_proba(x)) + assert np.array_equal(loaded.native_model.predict(x), model.predict(x)) + + +@pytest.mark.parametrize("nan_mode", ["Min", "Max", "Forbidden"]) +def test_nan_handling(nan_mode, tmp_path): + x, y = data() + if nan_mode != "Forbidden": + x.iloc[0, 0] = np.nan + model = classifier(frame=x, labels=y % 2, nan_mode=nan_mode) + path = tmp_path / "nan.mlbundle" + mlbundle.export(model, path) + assert np.array_equal(mlbundle.load(path).predict_proba(x), model.predict_proba(x)) + + +def test_schema_samples_and_lowered_limits(tmp_path): + x, _ = data() + model = classifier() + path = tmp_path / "schema.mlbundle" + mlbundle.export(model, path, training_data=x, sample_rows=2) + loaded = mlbundle.load(path) + assert loaded.samples == x.head(2).to_dict("records") + assert loaded.schema["reports"][0]["labels"] == [0, 1, 2] + assert set(loaded.members) == { + mlbundle.MemberName.MANIFEST, + mlbundle.MemberName.CBM, + mlbundle.MemberName.STRUCTURE, + mlbundle.MemberName.SCHEMA, + mlbundle.MemberName.SAMPLES, + } + for limits in [ + mlbundle.ArchiveLimits(max_member_bytes=1), + mlbundle.ArchiveLimits(max_structure_bytes=1), + ]: + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export(model, tmp_path / "bad.mlbundle", limits=limits) + assert not (tmp_path / "bad.mlbundle").exists() + + +def test_native_importance_cache_and_export_immutability(tmp_path): + x, y = data() + model = regressor() + model.get_feature_importance( + cb.Pool(x, label=y + 1.0), type="LossFunctionChange", thread_count=1 + ) + model._canonized_params = None + before = model._serialize_model() + cache = model._prediction_values_change + path = tmp_path / "cache.mlbundle" + mlbundle.export(model, path) + assert model._canonized_params is None + assert model._prediction_values_change is cache + assert model._serialize_model() == before + assert np.array_equal(mlbundle.load(path).predict(x), model.predict(x)) diff --git a/tests/test_catboost_claims.py b/tests/test_catboost_claims.py new file mode 100644 index 0000000..73d5e19 --- /dev/null +++ b/tests/test_catboost_claims.py @@ -0,0 +1,127 @@ +"""Recomputed CatBoost claims, complete structure, and fitted-state commitment.""" + +import pytest + +import mlbundle +from catboost_helpers import classifier, regressor, rewrite, vector +from mlbundle.canonical import canonical_dumps, canonical_loads + + +@pytest.mark.parametrize( + "change,claim", + [ + (lambda m, b: m["target"].update(class_labels=[[10, 20, 30]]), "class_labels"), + (lambda m, b: m["features"].update(names=list("cba")), "feature_names"), + (lambda m, b: m["features"].update(origin="declared"), "feature_names"), + ( + lambda m, b: m["features"].update(model_dtypes=["int64"] * 3), + "feature_dtypes", + ), + ( + lambda m, b: m["model"].update(estimator_class="CatBoostRegressor"), + "estimator_class", + ), + ( + lambda m, b: ( + m["model"].update(kind="regressor", n_classes=None), + m["target"].update(class_labels=None), + ), + "kind", + ), + ( + lambda m, b: ( + m["model"].update(n_classes=[2]), + m["target"].update(class_labels=[[0, 1]]), + ), + "n_classes", + ), + ( + lambda m, b: m["model"]["hyperparameters"].update(tree_count=1), + "hyperparameters", + ), + ( + lambda m, b: m["model"]["hyperparameters"].update(best_iteration=0), + "hyperparameters", + ), + ( + lambda m, b: m["model"]["hyperparameters"].update( + scale_and_bias=[2.0, 1.0] + ), + "hyperparameters", + ), + ( + lambda m, b: m["model"]["hyperparameters"].update( + probability_threshold=0.1 + ), + "hyperparameters", + ), + ( + lambda m, b: m["model"]["hyperparameters"]["parameters"].update(depth=9), + "hyperparameters", + ), + (lambda m, b: m["model"].update(state_sha256="0" * 64), "state_sha256"), + ( + lambda m, b: ( + m["references"].update(structure=None), + b.pop(mlbundle.MemberName.STRUCTURE), + ), + "structure", + ), + ], +) +def test_lying_manifest(change, claim, tmp_path): + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(), path) + with pytest.raises(mlbundle.ClaimMismatchError) as error: + mlbundle.load(rewrite(path, change)) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "alter", + [ + lambda s: s["native_model"]["tree_splits"].pop(), + lambda s: s["native_model"]["tree_depths"].pop(), + lambda s: s["native_model"]["leaf_values"].__setitem__(0, 99.0), + lambda s: s["native_model"]["leaf_weights"].__setitem__(0, 99.0), + lambda s: s["native_model"]["float_features"][0].update(nan_value_treatment=2), + lambda s: s["native_model"].update(scale=2.0), + lambda s: s["feature_importances"].__setitem__(0, 99.0), + ], +) +def test_lying_structure(alter, tmp_path): + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(), path) + + def change(manifest, members): + value = canonical_loads(members[mlbundle.MemberName.STRUCTURE]) + alter(value) + members[mlbundle.MemberName.STRUCTURE] = canonical_dumps(value) + + with pytest.raises(mlbundle.ClaimMismatchError, match="structure"): + mlbundle.load(rewrite(path, change)) + + +def test_native_leaf_state_changed_with_fresh_integrity(tmp_path): + path = tmp_path / "model.mlbundle" + mlbundle.export(classifier(), path) + + def change(manifest, members): + members[mlbundle.MemberName.CBM] = vector( + members[mlbundle.MemberName.CBM], 8, 99.0, "d" + ) + + with pytest.raises(mlbundle.ClaimMismatchError, match="state_sha256"): + mlbundle.load(rewrite(path, change)) + + +def test_regressor_kind_is_native(tmp_path): + path = tmp_path / "regressor.mlbundle" + mlbundle.export(regressor(), path) + + def change(manifest, members): + manifest["model"].update(kind="classifier", n_classes=[2]) + manifest["target"].update(class_labels=[[0, 1]]) + + with pytest.raises(mlbundle.ClaimMismatchError): + mlbundle.load(rewrite(path, change)) diff --git a/tests/test_catboost_custom.py b/tests/test_catboost_custom.py new file mode 100644 index 0000000..00d7b6b --- /dev/null +++ b/tests/test_catboost_custom.py @@ -0,0 +1,62 @@ +"""Real Python callback training is rejected; native metric strings round-trip.""" + +import warnings + +import catboost as cb +import numpy as np +import pytest + +import mlbundle +from catboost_helpers import data +from mlbundle._catboost import Adapter + + +class Objective: + def calc_ders_range(self, approxes, targets, weights): + return [ + (float(target - approx), -1.0) + for approx, target in zip(approxes, targets, strict=True) + ] + + +class Metric: + def is_max_optimal(self): + return False + + def evaluate(self, approxes, targets, weights): + return float(np.square(np.asarray(approxes[0]) - targets).sum()), float( + len(targets) + ) + + def get_final_error(self, error, weight): + return error / weight + + +@pytest.mark.parametrize("custom", ["objective", "metric", None]) +def test_real_callbacks_and_native_metrics(custom, tmp_path): + x, y = data() + options = ( + {"loss_function": Objective(), "eval_metric": "RMSE"} + if custom == "objective" + else {"loss_function": "RMSE", "eval_metric": Metric() if custom else "MAE"} + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model = cb.CatBoostRegressor( + iterations=2, + depth=2, + thread_count=1, + verbose=False, + allow_writing_files=False, + **options, + ).fit(x, y) + path = tmp_path / "metrics.mlbundle" + if custom: + with pytest.raises(mlbundle.UnsupportedModelError, match="Custom Python"): + mlbundle.export(model, path) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(model._serialize_model()) + assert not path.exists() + else: + mlbundle.export(model, path) + assert np.array_equal(mlbundle.load(path).predict(x), model.predict(x)) diff --git a/tests/test_catboost_payload.py b/tests/test_catboost_payload.py new file mode 100644 index 0000000..3a5983e --- /dev/null +++ b/tests/test_catboost_payload.py @@ -0,0 +1,159 @@ +"""CBM preflight rejects unsafe dimensions and malformed bytes before native load.""" + +import struct +from types import SimpleNamespace + +import catboost as cb +import pytest + +import mlbundle +from catboost_helpers import classifier, field, rewrite, tables, vector +from mlbundle._catboost import Adapter +from mlbundle._catboost.flatbuffer import Buffer + + +@pytest.mark.parametrize( + "damage", + [ + lambda b: b"", + lambda b: b"CBM1", + lambda b: b"random bytes", + lambda b: b[: len(b) // 2], + lambda b: b[:-1], + lambda b: b + b"extra", + lambda b: b[:4] + struct.pack(" Date: Mon, 12 Oct 2026 14:38:04 +0200 Subject: [PATCH 11/15] fix: export everyday sklearn pipelines (#13) Admit plain dtype state, NumPy scalar type parameters and closed sklearn score/NumPy elementwise function references with owning-loader checks. Keep all new values bound to the complete fitted-state commitment. Visit fitted categorical clones and avoid repeated composite parameters that exceed the existing depth limit. The unreleased format-1 pipeline parameter projection now uses constructor-list references; older pipeline bundles require re-export. No manifest schema or dependency changes. --- AGENTS.md | 8 +- README.md | 20 ++-- SECURITY.md | 27 +++-- docs/format-adapters.md | 12 ++- docs/format-state.md | 47 ++++++++- docs/format.md | 20 +++- src/mlbundle/_sklearn/dtypes.py | 41 ++++++++ src/mlbundle/_sklearn/nodes.py | 24 ++++- src/mlbundle/_sklearn/parameters.py | 48 +++++++-- src/mlbundle/_sklearn/payload.py | 2 + src/mlbundle/_sklearn/prediction.py | 32 ++++-- src/mlbundle/_sklearn/structure.py | 20 ++-- src/mlbundle/_sklearn/trusted.py | 86 +++++++++++++++- tests/test_sklearn_dtypes.py | 143 ++++++++++++++++++++++++++ tests/test_sklearn_numpy_functions.py | 97 +++++++++++++++++ tests/test_sklearn_scalar_types.py | 135 ++++++++++++++++++++++++ tests/test_sklearn_score_functions.py | 110 ++++++++++++++++++++ 17 files changed, 808 insertions(+), 64 deletions(-) create mode 100644 src/mlbundle/_sklearn/dtypes.py create mode 100644 tests/test_sklearn_dtypes.py create mode 100644 tests/test_sklearn_numpy_functions.py create mode 100644 tests/test_sklearn_scalar_types.py create mode 100644 tests/test_sklearn_score_functions.py diff --git a/AGENTS.md b/AGENTS.md index 353e0a7..c006fe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ still preserve validation, security and reproducibility. - Never use pickle, joblib or cloudpickle, even as an option or fallback. - Never import or instantiate from strings in the manifest. The class to instantiate must come from a closed list inside mlbundle. -- The skops trusted-types list (Tree, AdamOptimizer, SGDOptimizer) lives in mlbundle; never read it from a bundle. Optimizers admit ObjectNode/CachedNode only. +- The skops trusted lists live in mlbundle; never read them from a bundle or add XGBoost/CatBoost types. Tree, optimizers, scalar dtypes and closed functions each admit only their owning loaders; see `docs/format-state.md`. - Export must fail on the user's machine when the model contains custom types, listing unsupported types from `skops.io.get_untrusted_types`. Do not defer discovery to upload time or offer to trust those types. @@ -208,7 +208,7 @@ before publication. XGBoost supports exact XGBClassifier/XGBRegressor tree wrappers with closed probability/regression objectives and one target. Original class labels supplied through output-major `class_labels=` are bound to a native copy through an mlbundle-owned booster attribute and verified on load. Omitted -labels use encoded indices; sklearn/CatBoost reject this option because they own classes_. Local `training_data=` inference uses mlschema with `__`, excludes a named target, +labels use encoded indices; sklearn/CatBoost reject `class_labels=` because they own classes_. Local `training_data=` inference uses mlschema with `__`, excludes a named target, and records training dtypes. Only explicit `sample_rows=N` includes real rows. Schema/report and sample validation is shared by export, inspect and load. ONNX preserves path/bytes unchanged (ModelProto is serialized once), validates @@ -218,7 +218,7 @@ claims. `native_model` is an OnnxModel with original bytes and a CPU session. Kind is inferred from supported outputs; native labels are verified, absent labels use count-checked declarations. Structure is null. See the normative ONNX section linked from `docs/format.md` for exact layouts and resource bounds. -CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. +CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. Sklearn pipelines containing XGBoost/CatBoost estimators are rejected; export the estimator directly. The closed member list is: required `manifest.json`, exactly one of `model.skops`, `model.ubj`, `model.cbm` or `model.onnx`, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional @@ -265,7 +265,7 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. - `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. - `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. -- `src/mlbundle/_sklearn/` — skops trust/loading (including MLP adam/sgd/lbfgs state), parameter and claim extraction, native structure, nested payload bounds and validated prediction. +- `src/mlbundle/_sklearn/` — closed dtype/type/function trust, skops loading, MLP state, parameter/claim extraction, native structure, nested payload bounds and fitted categorical prediction. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. - `src/mlbundle/_catboost/` — closed numeric symmetric wrappers/losses, CBM preflight/in-memory loading, complete native state/structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. diff --git a/README.md b/README.md index e24bec5..6f8f25d 100644 --- a/README.md +++ b/README.md @@ -218,14 +218,14 @@ Support requires a faithful closed-adapter round trip; a catalog entry does not recoverable feature/output counts. Tested families include trees/ensembles, linear/regularized/robust/GLM models, SVMs, PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. - MLPClassifier/Regressor (adam, sgd, lbfgs) retain optimizer state, alone/in pipelines, - with null structure and complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). - Scaler and dense OneHotEncoder pipelines round-trip; structure stays null - because format 1 does not map transformed features. See - [tested configurations](tests/test_sklearn_families.py) and - [core round trips/rejections](tests/test_sklearn.py). -- **Sklearn rejections:** custom estimators/subclasses, callable/type parameters - (including FunctionTransformer user functions), classifiers without + MLPClassifier/Regressor (adam, sgd, lbfgs) retain optimizer state, alone/in pipelines, with null structure and complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). + SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, + univariate selectors and closed NumPy FunctionTransformer functions round-trip, + including nested ColumnTransformer pipelines; see [the exact lists](docs/format-state.md). + Pipeline structure stays null because format 1 does not map transformed features. See + [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). +- **Sklearn rejections:** custom estimators/subclasses, functions/types outside the + closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without probabilities, multilabel/ClassifierChain layouts, XGBoost/CatBoost pipeline steps, and untrusted nested storage such as fitted sparse neighbor arrays. HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. @@ -291,8 +291,8 @@ mlschema `>=0.2.1,<0.3` on 3.14 only (0.2.1 excludes 3.15). `uv.lock` fixes test versions; dependency edits require `uv sync` and committing the updated lockfile. Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, -`uv run pytest` and `uv build`. Tests execute every fenced README example, -write terminal/XML coverage and enforce 80% branch-inclusive coverage. +`uv run pytest` and `uv build`. Tests execute every fenced README example and +write terminal/XML coverage with an 80% branch-inclusive minimum. CI preserves lint/types and Ubuntu/Windows tests and checks a clean base wheel installation on both OSes. `uv build` creates a local wheel and source archive. Read [AGENTS.md](AGENTS.md) before contributing. Publishing, releases, version diff --git a/SECURITY.md b/SECURITY.md index 0adbb68..300a081 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,12 +57,22 @@ serialization path. Adapters must use lazy imports for optional dependencies. ## Implemented sklearn trust boundary Export and load call skops' `get_untrusted_types` and reject all types outside -the fixed supplemental list `sklearn.tree._tree.Tree`, -`sklearn.neural_network._stochastic_optimizers.AdamOptimizer` and -`sklearn.neural_network._stochastic_optimizers.SGDOptimizer`. Load passes exactly -that list to `skops.io.loads`, alongside skops' built-in trusted defaults. These -are native numeric tree storage and fitted MLP optimizer records. They add no arbitrary functions or I/O -constructors. The bundle cannot provide additional trusted types; CatBoost adds none. +mlbundle's fixed supplemental list: native Tree, AdamOptimizer/SGDOptimizer, +`numpy.dtype`, six sklearn univariate score functions and nine NumPy elementwise +functions. Load passes exactly that list to `skops.io.loads`, alongside skops' +built-in trusted defaults. Exact names and scalar types are in +[the state contract](docs/format-state.md). The bundle cannot add trusted types; +XGBoost and CatBoost add none to this list and use their own native adapters. + +Skops 0.16.0 FunctionNode resolves `getattr(import_module(module), name)` without +calling the function. mlbundle checks exact module/name pairs and permits only +FunctionNode/CachedNode for these names, rejecting arbitrary functions and +alternative constructor loaders before skops. TypeNode resolves a NumPy scalar +class without constructing it; parameter extraction also checks native identity. +DTypeNode reads an empty array's dtype with `allow_pickle=False`; mlbundle bounds +and validates that representation before loading. Only closed plain scalar dtypes +and empty JSON object-dtype arrays are admitted; compound dtypes and hidden state +fail. Every new reference/value participates in the complete state commitment. The optimizer path was checked against skops 0.16.0/sklearn 1.9.1: ObjectNode uses ordinary `object.__new__` and `__dict__.update`, bypassing constructors. @@ -91,8 +101,9 @@ make the native parser a sandbox. The installed skops load path builds nodes, audits them and only then constructs objects; NumPy buffers use `allow_pickle=False`. No pickle/joblib loading fallback is used. Skops imports `pickle` only to recognize an unrelated save-side exception; -this does not deserialize pickle bytes. Custom estimators/functions and XGBoost -estimators inside sklearn pipelines are rejected. HistGradientBoosting's separate +this does not deserialize pickle bytes. Custom estimators/functions and XGBoost/CatBoost +estimators inside sklearn pipelines are rejected. CatBoost rejections name the unsupported +type and advise exporting the estimator directly; neither adapter unwraps pipelines. HistGradientBoosting's separate `TreePredictor` storage is also excluded. Broadening the supplemental list requires source review and hostile-payload tests, never author approval or bundle metadata. diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 723b8cb..0b50d30 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -84,10 +84,11 @@ including a present JSON null. Malformed native payloads raise `InvalidModelErro an `MLBundleError` subclass, without native exception text. The sklearn adapter uses only `skops.io.dumps/loads`. Its fixed supplemental -trusted list is exactly `sklearn.tree._tree.Tree`, -`sklearn.neural_network._stochastic_optimizers.AdamOptimizer` and -`sklearn.neural_network._stochastic_optimizers.SGDOptimizer`: numeric tree storage -and the fitted MLP solver records. It adds no callable, arbitrary class or I/O type. +trusted list includes `sklearn.tree._tree.Tree`, +`sklearn.neural_network._stochastic_optimizers.AdamOptimizer`, +`sklearn.neural_network._stochastic_optimizers.SGDOptimizer`, `numpy.dtype` and +the exact score/NumPy function names in [the state contract](format-state.md). +It adds no arbitrary class, callable or I/O constructor. Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected at export and independently at load. XGBoost estimators inside sklearn pipelines @@ -198,7 +199,8 @@ uses `predict_proba`; regressor prediction uses `predict`. Unsupported probabili or regression contracts fail rather than synthesizing outputs. Inputs must have rank two and the native feature width. Numeric inputs reject strings, complex values and infinities; sklearn/XGBoost permit NaN for native missing-value handling. -A sklearn Pipeline with a fitted OneHotEncoder/OrdinalEncoder also admits strings; +A sklearn Pipeline with a fitted OneHotEncoder/OrdinalEncoder/TargetEncoder also admits strings, +including fitted clones inside nested ColumnTransformer/FeatureUnion steps; categorical XGBoost DataFrame columns retain native recoding. ONNX requires finite numeric cells as specified separately. Input and remaining native failures become bounded `MLBundleError` subclasses without cell contents. Shape errors additionally diff --git a/docs/format-state.md b/docs/format-state.md index b3704db..759e348 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -13,8 +13,9 @@ estimator constructor parameters, inherited and fitted attributes, shared fit helpers and the admitted loss/link/MLP optimizer internals. It is package data, never supplied by a bundle and never used to import or construct a class. Unknown object types, attributes, nested estimator attributes, node keys, loaders and unreferenced files -are rejected. The supplemental skops trusted list contains Tree, AdamOptimizer -and SGDOptimizer only (exact paths in [the adapter contract](format-adapters.md)). A catalog +are rejected. The supplemental skops trusted list contains Tree, AdamOptimizer, +SGDOptimizer, `numpy.dtype` and the closed functions below (other exact paths in +[the adapter contract](format-adapters.md)). A catalog entry does not extend skops' trust or guarantee that every configuration exports. The audited/tested baseline is scikit-learn 1.9.1 with skops 0.16.0; the @@ -56,6 +57,44 @@ also compares the payload commitment with a fresh skops serialization of the loaded object. Ignored declarations or state that changes during construction are invalid models. Export applies the same rules before publication. +### Closed scalar and function values + +`_sklearn/trusted.py` owns these lists for export, projection and load; bundle +declarations never select or expand them. NumPy scalar type parameters are exactly +`bool` (`bool_` alias), `int8`, `int16`, `int32`, `int64`, `uint8`, `uint16`, +`uint32`, `uint64`, `float16`, `float32`, `float64`, `complex64`, `complex128`. +Aliases such as `intp`/`int_` use their installed scalar identity/name. These +classes use skops' existing TypeNode defaults; TypeNode resolves the attribute +without calling it. Projection checks object identity, not an advertised name. + +Fitted dtype values admit those scalar storage representations, either byte +order, plus object: categorical SimpleImputer stores an object `_fit_dtype`. +String, datetime/timedelta, void, structured, sub-array and metadata-bearing +dtypes are rejected. Numeric dtype storage must be exactly a NumPy ndarray of +shape `(0,)` whose bounded NPY header passes the scalar list. Object dtype storage +must be the canonical empty JSON ndarray, with a one-element TupleNode shape +containing the JSON integer zero. DTypeNode returns the constructed array's dtype; +only DTypeNode/CachedNode with exact `numpy.dtype` identity are permitted. +Object cells are never loaded with pickle. Native dtype validation also rejects +metadata/compound values that serialization could otherwise discard. + +Score functions are exactly `f_classif`, `f_regression`, `chi2`, `r_regression` +in `sklearn.feature_selection._univariate_selection`, and `mutual_info_classif`, +`mutual_info_regression` in `sklearn.feature_selection._mutual_info`. +NumPy functions are exactly `numpy.log`, `numpy.log1p`, `numpy.log2`, `numpy.log10`, +`numpy.exp`, `numpy.expm1`, `numpy.sqrt`, `numpy.square`, `numpy.absolute` +(`np.abs` is the same native object and serializes as `absolute`). They support +FunctionTransformer `func` and `inverse_func`. Other functions, including NumPy +reductions, lambdas and user functions, remain rejected with unsupported names. + +Skops 0.16.0 FunctionNode stores only module/name/loader/identity. Its load path +uses `getattr(import_module(module), name)` and never calls the function. Only +FunctionNode/CachedNode are permitted for these names; all other function names +and alternative constructor loaders fail before skops auditing/construction. +The closed list includes no I/O function. Function execution remains native to +the fitted transformer at prediction time. All names, loader identities, dtype +values and fitted selector statistics participate in `state_sha256`. + ### Commitment encoding `state_sha256` is a lowercase SHA-256 digest. A sklearn structure object requires @@ -119,8 +158,8 @@ selection inferred from attribute suffixes. The explicit checks supplement it: | Naive Bayes / QDA / neighbors / nearest centroid / MLP / semi-supervised / opaque meta-estimators | Their complete fitted statistics, data, class/output mappings, distance/activation state and nested models are committed in the manifest when structure is null. Statistics/likelihood/prior dimensions agree with classes/features; neighbor sample counts agree with fitted arrays. MLP layer sizes, output counts/activation and label-binarizer classes agree with public parameters/labels. Existing probability/count/trust constraints still apply. | | Pipeline | All fitted steps, transformations, final estimator, order, names, classes and parameters are committed/claimed. Structure stays null; no transformed-feature visualization is inferred. | -Unsupported custom types, callable/type parameters, HistGradientBoosting storage, -multilabel/chain probability layouts and untrusted nested internals remain +Unsupported custom types, functions/types outside these lists, HistGradientBoosting storage, +XGBoost/CatBoost pipeline steps, multilabel/chain probability layouts and untrusted nested internals remain rejections. For example, actual fitted `csr_matrix` neighbor state is outside the unchanged trust list; merely declaring its loader as the trusted base `spmatrix` does not make its state survive the native construction comparison. diff --git a/docs/format.md b/docs/format.md index d9dadbe..30e505b 100644 --- a/docs/format.md +++ b/docs/format.md @@ -95,12 +95,22 @@ Sklearn parameters preserve `None`, JSON booleans, strings, integers and floats; non-finite floats use the canonical tags. NumPy scalars/arrays use the canonical conversion below; tuples become arrays, and dictionaries require string keys. Nested sklearn estimators use the `$estimator` record with their class name and -recursively projected deep parameters. Deep keys such as `classifier__C` are +recursively projected deep parameters. In the constructor collections `steps`, +`transformers` and `transformer_list`, estimator items instead use +`{"$estimator_ref":CLASS_NAME}`; the owning estimator's named deep keys carry +their complete parameter records. Collection order, names, column routing and +other items are preserved. This avoids duplicating nested composition through +constructor lists and keeps ordinary ColumnTransformer pipelines within depth 16. +Deep keys such as `classifier__C` are retained alongside the estimator record. A NumPy `RandomState` becomes `{"$random_state":[ALGORITHM,STATE_ARRAY,POSITION,HAS_GAUSSIAN,CACHED_GAUSSIAN]}` from its current `get_state()`, after training. No initial seed is guessed. -Callables (including built-in functions, NumPy functions, lambdas and partials), -types, cyclic containers, byte strings, complex/datetime values and other custom +Closed NumPy scalar classes use `{"$type":"numpy.SCALAR_NAME"}`; admitted plain +dtype instances use `{"$dtype":DTYPE_STR}`. Closed sklearn score functions and +NumPy functions use `{"$function":"MODULE.NAME"}`. Exact native identities, +lists and loader restrictions are defined in [state ownership](format-state.md). +These records are claims, never loading instructions. Other callables/types, +lambdas, partials, cyclic containers, byte strings, complex/datetime values and other custom objects are rejected. There is no `repr`, identity/address or stringification fallback. A skops-trusted object can still be rejected if its parameters have no faithful projection. The round-trip comparison checks all these records. @@ -277,7 +287,9 @@ not change the format. Corrections to documentation that preserve wire behavior and implementation changes behind these contracts do not require a version bump. The unreleased version 1 includes schema vocabulary, `declared` origin, prediction-state records, missing tree routing, native XGBoost routing/dropout -and closed state/work rules. It remains subject to change before format freeze. +and closed state/work rules, scalar dtype/type/function records and composition +references. It remains subject to change before format freeze; older sklearn +pipeline parameter projections require re-export under this revised v1 contract. Hashes detect corruption, not deliberate tampering: an attacker can replace a model and update every record. Claim recomputation prevents a bundle from showing diff --git a/src/mlbundle/_sklearn/dtypes.py b/src/mlbundle/_sklearn/dtypes.py new file mode 100644 index 0000000..56c5fdb --- /dev/null +++ b/src/mlbundle/_sklearn/dtypes.py @@ -0,0 +1,41 @@ +"""Validate skops' empty-array dtype representation before construction.""" + +from mlbundle.canonical import canonical_loads +from mlbundle.errors import InvalidModelError + +from .arrays import array_header +from .trusted import dtype_identifier + + +def validate_dtypes(nodes, resolve, archive) -> None: + for node in nodes: + if node.get("__loader__") != "DTypeNode": + continue + content = resolve(node.get("content")) + if content.get("__loader__") != "NdArrayNode" or ( + content.get("__module__"), + content.get("__class__"), + ) != ("numpy", "ndarray"): + raise InvalidModelError("Invalid skops dtype storage.") + if content.get("type") == "numpy": + name = content["file"] + shape, dtype = array_header(archive, name, archive.getinfo(name).file_size) + dtype_identifier(dtype) + if shape != (0,): + raise InvalidModelError("Skops dtype storage must be empty and scalar.") + elif content.get("type") == "json": + # Only the canonical empty object array emitted by dtype_get_state. + shape = resolve(content.get("shape")) + if shape.get("__loader__") != "TupleNode" or len(shape["content"]) != 1: + raise InvalidModelError("Invalid skops object dtype shape.") + size = resolve(shape["content"][0]) + if ( + size.get("__loader__") != "JsonNode" + or size.get("is_json") is not True + or canonical_loads(size["content"].encode()) != 0 + or size["content"] != "0" + or content.get("content") != [] + ): + raise InvalidModelError("Skops object dtype storage must be empty.") + else: + raise InvalidModelError("Invalid skops dtype storage kind.") diff --git a/src/mlbundle/_sklearn/nodes.py b/src/mlbundle/_sklearn/nodes.py index 2df199c..d4202ce 100644 --- a/src/mlbundle/_sklearn/nodes.py +++ b/src/mlbundle/_sklearn/nodes.py @@ -4,7 +4,7 @@ from mlbundle.errors import InvalidModelError, UnsupportedModelError -from .trusted import OPTIMIZER_TYPES +from .trusted import FUNCTIONS, OPTIMIZER_TYPES, SCALAR_NAMES FIELDS = { "ObjectNode": {"content"}, @@ -16,6 +16,7 @@ "SetNode": {"content"}, "JsonNode": {"content", "is_json"}, "TypeNode": set(), + "FunctionNode": set(), "CachedNode": set(), "NdArrayNode": {"type", "file", "content", "shape"}, "SparseMatrixNode": {"type", "file"}, @@ -35,6 +36,7 @@ def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None: for node in nodes: loader = node["__loader__"] name = node.get("__module__", "") + "." + node.get("__class__", "") + _special_loader(node, name, loader) if name in OPTIMIZER_TYPES and loader not in ("ObjectNode", "CachedNode"): raise InvalidModelError("Invalid skops optimizer loader.") if ( @@ -44,9 +46,6 @@ def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None: ): raise InvalidModelError("Invalid skops native tree loader.") if loader not in FIELDS: - name = ( - str(node.get("__module__", "")) + "." + str(node.get("__class__", "")) - ) raise UnsupportedModelError((name,)) allowed = ( COMMON @@ -57,3 +56,20 @@ def validate_nodes(nodes: list[dict[str, Any]], root: Any) -> None: raise InvalidModelError("Unknown skops node state.") if loader in ("TreeNode", "LossNode") and set(node["__reduce__"]) != {"args"}: raise InvalidModelError("Unknown skops constructor state.") + + +def _special_loader(node, name, loader) -> None: + if name in FUNCTIONS and loader not in ("FunctionNode", "CachedNode"): + raise InvalidModelError("Invalid skops function loader.") + if loader == "FunctionNode" and name not in FUNCTIONS: + raise UnsupportedModelError((name,)) + if name == "numpy.dtype" and loader not in ("DTypeNode", "CachedNode"): + raise InvalidModelError("Invalid skops dtype loader.") + if loader == "DTypeNode" and name != "numpy.dtype": + raise InvalidModelError("Invalid skops dtype identity.") + if ( + loader == "TypeNode" + and node.get("__module__") == "numpy" + and name not in SCALAR_NAMES + ): + raise UnsupportedModelError((name,)) diff --git a/src/mlbundle/_sklearn/parameters.py b/src/mlbundle/_sklearn/parameters.py index a3c4d76..444fbc2 100644 --- a/src/mlbundle/_sklearn/parameters.py +++ b/src/mlbundle/_sklearn/parameters.py @@ -10,11 +10,17 @@ from mlbundle.errors import LimitExceededError, UnsupportedModelError from mlbundle.limits import HYPERPARAMETER_BYTES +from .trusted import dtype_identifier, reference_identifier + MAX_VISITS = 100_000 def _parameter( - value: object, ancestors: frozenset[int], budget: list[int] | None = None + value: object, + ancestors: frozenset[int], + budget: list[int] | None = None, + *, + estimator_ref: bool = False, ) -> JsonValue: budget = [0] if budget is None else budget budget[0] += 1 @@ -28,12 +34,7 @@ def _parameter( raise UnsupportedModelError( (f"{type(value).__module__}.{type(value).__name__}",) ) - return { - "$estimator": { - "class": type(value).__name__, - "params": parameters(value, path, budget), - } - } + return _estimator(value, path, budget, estimator_ref) if isinstance(value, np.random.RandomState): return {"$random_state": canonical_value(value.get_state())} if isinstance(value, dict): @@ -41,14 +42,38 @@ def _parameter( raise UnsupportedModelError(reason="Hyperparameter keys must be strings.") return {key: _parameter(item, path, budget) for key, item in value.items()} if isinstance(value, (list, tuple)): - return [_parameter(item, path, budget) for item in value] + return [ + _parameter(item, path, budget, estimator_ref=estimator_ref) + for item in value + ] + return _leaf(value) + + +def _leaf(value: object) -> JsonValue: + if isinstance(value, np.dtype): + return {"$dtype": dtype_identifier(value)} if callable(value): + identifier = reference_identifier(value) + if identifier is not None: + return {"$type" if isinstance(value, type) else "$function": identifier} raise UnsupportedModelError( reason="Callable and type hyperparameters are unsupported." ) return canonical_value(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) +def _estimator(value, ancestors, budget, reference) -> JsonValue: + # Named deep keys own step parameters; constructor lists retain routing/order. + if reference: + return {"$estimator_ref": type(value).__name__} + return { + "$estimator": { + "class": type(value).__name__, + "params": parameters(value, ancestors, budget), + } + } + + def parameters( model: BaseEstimator, ancestors: frozenset[int] = frozenset(), @@ -58,7 +83,12 @@ def parameters( return cast( dict[str, JsonValue], { - key: _parameter(value, ancestors, budget) + key: _parameter( + value, + ancestors, + budget, + estimator_ref=key in ("steps", "transformers", "transformer_list"), + ) for key, value in model.get_params(deep=True).items() }, ) diff --git a/src/mlbundle/_sklearn/payload.py b/src/mlbundle/_sklearn/payload.py index 3b02ce2..c68d2e0 100644 --- a/src/mlbundle/_sklearn/payload.py +++ b/src/mlbundle/_sklearn/payload.py @@ -18,6 +18,7 @@ from .archive import bounded_zip from .arrays import MAX_FILES, array_header, sparse_headers +from .dtypes import validate_dtypes from .nodes import validate_nodes from .references import References from .state import state_digest, validate_state @@ -73,6 +74,7 @@ def validate_payload(data: bytes) -> str: if name not in names: raise InvalidModelError("Missing skops numeric array member.") _array_header(archive, name, archive.getinfo(name).file_size) + validate_dtypes(references.nodes, references.resolve, archive) _tree_constructors(references, archive) files = _array_references(schema) | sparse | {"schema.json"} if set(names) != files: diff --git a/src/mlbundle/_sklearn/prediction.py b/src/mlbundle/_sklearn/prediction.py index 3a326c9..1896a3f 100644 --- a/src/mlbundle/_sklearn/prediction.py +++ b/src/mlbundle/_sklearn/prediction.py @@ -4,8 +4,9 @@ from typing import Any, cast import numpy as np -from sklearn.pipeline import Pipeline -from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import FeatureUnion, Pipeline +from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, TargetEncoder from mlbundle._prediction_input import tabular from mlbundle.adapters import ProbabilityOutput, RegressionOutput @@ -14,15 +15,32 @@ from mlbundle.errors import InvalidModelError, MLBundleError +def _string_inputs(model: Any) -> bool: + pending = [model] + seen: set[int] = set() + while pending: + value = pending.pop() + if id(value) in seen: + continue + seen.add(id(value)) + if isinstance( + value, (OneHotEncoder, OrdinalEncoder, TargetEncoder) + ) and hasattr(value, "categories_"): + return True + if isinstance(value, Pipeline): + pending.extend(step for _, step in value.steps) + elif isinstance(value, ColumnTransformer): + pending.extend(step for _, step, _ in getattr(value, "transformers_", [])) + elif isinstance(value, FeatureUnion): + pending.extend(step for _, step in value.transformer_list) + return False + + class Predictor: def __init__(self, model: Any, claims: ModelClaims) -> None: self.model = model self.claims = claims - self.strings = isinstance(self.model, Pipeline) and any( - isinstance(value, (OneHotEncoder, OrdinalEncoder)) - and hasattr(value, "categories_") - for value in self.model.get_params(deep=True).values() - ) + self.strings = isinstance(self.model, Pipeline) and _string_inputs(self.model) def _data(self, data: object) -> tuple[object, int]: return tabular(data, self.claims.n_features, strings=self.strings) diff --git a/src/mlbundle/_sklearn/structure.py b/src/mlbundle/_sklearn/structure.py index 4efff29..c3e96f1 100644 --- a/src/mlbundle/_sklearn/structure.py +++ b/src/mlbundle/_sklearn/structure.py @@ -20,6 +20,7 @@ from .labels import validate_classes as _validate_classes from .payload import validate_payload from .prediction_state import prediction_state +from .trusted import dtype_identifier _ARRAYS = ( "children_left", @@ -79,12 +80,8 @@ def validate_tree_storage(model: object) -> None: if id(value) in seen: continue seen.add(id(value)) - if isinstance(value, Tree): - if type(value) is not Tree: - raise UnsupportedModelError( - reason="Only exact native Tree storage is supported." - ) - validate_tree(value) + if isinstance(value, (Tree, np.dtype)): + _native_storage(value) elif isinstance(value, dict): pending.extend(value.values()) elif isinstance(value, (list, tuple)): @@ -99,6 +96,17 @@ def validate_tree_storage(model: object) -> None: pending.extend(vars(value).values()) +def _native_storage(value) -> None: + if isinstance(value, np.dtype): + dtype_identifier(value) + elif type(value) is not Tree: + raise UnsupportedModelError( + reason="Only exact native Tree storage is supported." + ) + else: + validate_tree(value) + + def _width(model: Any) -> int | None: if not hasattr(model, "n_features_in_"): return None diff --git a/src/mlbundle/_sklearn/trusted.py b/src/mlbundle/_sklearn/trusted.py index 33f2282..4acd9e2 100644 --- a/src/mlbundle/_sklearn/trusted.py +++ b/src/mlbundle/_sklearn/trusted.py @@ -1,9 +1,89 @@ """Supplemental skops trust owned by mlbundle, never by serialized declarations.""" +import numpy as np +from sklearn.feature_selection import ( + chi2, + f_classif, + f_regression, + mutual_info_classif, + mutual_info_regression, + r_regression, +) + +from mlbundle.errors import UnsupportedModelError + +SCALAR_TYPES = ( + np.bool_, + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.float16, + np.float32, + np.float64, + np.complex64, + np.complex128, +) +SCALAR_NAMES = tuple(f"numpy.{value.__name__}" for value in SCALAR_TYPES) +SCORE_FUNCTIONS = ( + f_classif, + f_regression, + chi2, + mutual_info_classif, + mutual_info_regression, + r_regression, +) +NUMPY_FUNCTIONS = ( + np.log, + np.log1p, + np.log2, + np.log10, + np.exp, + np.expm1, + np.sqrt, + np.square, + np.absolute, +) +FUNCTIONS = { + **{f"{value.__module__}.{value.__name__}": value for value in SCORE_FUNCTIONS}, + **{f"numpy.{value.__name__}": value for value in NUMPY_FUNCTIONS}, +} + + +def reference_identifier(value: object) -> str | None: + """Check native identity, rather than trusting a callable's advertised name.""" + for name, native in zip(SCALAR_NAMES, SCALAR_TYPES, strict=True): + if value is native: + return name + for name, native in FUNCTIONS.items(): + if value is native: + return name + return None + + +def dtype_identifier(value: np.dtype) -> str: + # Categorical SimpleImputer stores object; no supported fit needs string, + # datetime, void, compound or platform-dependent extended-precision dtypes. + if ( + value.fields is not None + or value.subdtype is not None + or value.metadata is not None + or value.newbyteorder("=") + not in (*map(np.dtype, SCALAR_TYPES), np.dtype(object)) + ): + raise UnsupportedModelError( + ("numpy.dtype",), reason="Unsupported scalar dtype." + ) + return value.str + + OPTIMIZER_TYPES = ( "sklearn.neural_network._stochastic_optimizers.AdamOptimizer", "sklearn.neural_network._stochastic_optimizers.SGDOptimizer", ) -# Numeric Tree storage and MLP optimizer records; no functions/I/O constructors. -# Optimizers use ObjectNode (__new__ plus __dict__.update), never __init__. -TRUSTED_TYPES = ("sklearn.tree._tree.Tree", *OPTIMIZER_TYPES) +# Each supplemental name is restricted to its owning loader before skops audit. +TRUSTED_TYPES = ("sklearn.tree._tree.Tree", *OPTIMIZER_TYPES, "numpy.dtype", *FUNCTIONS) diff --git a/tests/test_sklearn_dtypes.py b/tests/test_sklearn_dtypes.py new file mode 100644 index 0000000..4b6b78c --- /dev/null +++ b/tests/test_sklearn_dtypes.py @@ -0,0 +1,143 @@ +"""Plain fitted dtypes round trip; compound and hostile dtype nodes stay closed.""" + +from io import BytesIO + +import numpy as np +import pytest +import skops.io as sio +from sklearn.impute import SimpleImputer +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import KBinsDiscretizer, OneHotEncoder + +import mlbundle +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.parameters import _parameter +from mlbundle._sklearn.trusted import SCALAR_TYPES +from sklearn_helpers import alter_schema, rewrite + + +def fitted(): + rows = np.array([[1, 2], [2, np.nan], [3, 4], [4, 5]], dtype=float) + model = make_pipeline(SimpleImputer(), LogisticRegression()).fit(rows, [0, 0, 1, 1]) + return model, rows + + +@pytest.mark.parametrize("scalar", [*SCALAR_TYPES, ">f8", ">i4"]) +def test_plain_dtype_state_roundtrip(scalar, tmp_path): + model, rows = fitted() + model[0]._fit_dtype = np.dtype(scalar) + path = tmp_path / "dtype.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert loaded.native_model[0]._fit_dtype == np.dtype(scalar) + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + assert _parameter(np.dtype(scalar), frozenset()) == {"$dtype": np.dtype(scalar).str} + + +def test_object_imputation_and_kbins_dtype(tmp_path): + rows = np.array([["red"], ["blue"], ["red"], ["blue"]], dtype=object) + model = make_pipeline( + SimpleImputer(strategy="most_frequent"), OneHotEncoder(), LogisticRegression() + ).fit(rows, [0, 1, 0, 1]) + path = tmp_path / "object.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert loaded.native_model[0]._fit_dtype == np.dtype(object) + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + rows = np.arange(40, dtype=float).reshape(20, 2) + model = make_pipeline(KBinsDiscretizer(dtype=np.float64), LogisticRegression()).fit( + rows, [0] * 10 + [1] * 10 + ) + mlbundle.export(model, path, overwrite=True) + assert np.array_equal( + mlbundle.load(path).predict_proba(rows), model.predict_proba(rows) + ) + + +@pytest.mark.parametrize( + "dtype", + [ + np.dtype([("x", "f8")]), + np.dtype(("f8", (2,))), + np.dtype("U4"), + np.dtype("S4"), + np.dtype("datetime64[ns]"), + np.dtype("V8"), + np.dtype("f8", metadata={"hidden": 1}), + ], +) +def test_unsupported_dtype_export_and_projection(dtype, tmp_path): + model, _ = fitted() + model[0]._fit_dtype = dtype + with pytest.raises(mlbundle.MLBundleError): + mlbundle.export(model, tmp_path / "bad.mlbundle") + with pytest.raises(mlbundle.UnsupportedModelError, match="dtype"): + _parameter(dtype, frozenset()) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "dtype", [np.dtype([("x", "f8")]), np.dtype(("f8", (2,))), np.dtype("U4")] +) +def test_compound_dtype_rejected_before_skops_load(dtype, monkeypatch): + model, _ = fitted() + model[0]._fit_dtype = dtype + payload = sio.dumps(model) + monkeypatch.setattr(sio, "loads", lambda *a, **kw: pytest.fail("skops called")) + with pytest.raises(mlbundle.MLBundleError): + Adapter().deserialize(payload) + + +def test_dtype_value_is_committed(tmp_path): + model, _ = fitted() + path = tmp_path / "original.mlbundle" + mlbundle.export(model, path) + model[0]._fit_dtype = np.dtype("float32") + with pytest.raises(mlbundle.ClaimMismatchError, match="state_sha256"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize( + "attack", + ["loader", "identity", "storage", "nonempty", "object_shape", "object_content"], +) +def test_hostile_dtype_nodes_fail_before_skops(attack, monkeypatch): + from mlbundle._sklearn.references import References + + model, _ = fitted() + model[0]._fit_dtype = ( + np.dtype(object) if attack.startswith("object") else np.dtype("f8") + ) + + def change(schema, entries): + refs = References(schema) + dtype = next(node for node in refs.nodes if node["__loader__"] == "DTypeNode") + storage = refs.resolve(dtype["content"]) + if attack == "loader": + dtype["__loader__"] = "ObjectNode" + elif attack == "identity": + dtype["__class__"] = "float64" + elif attack == "storage": + storage["__class__"] = "float64" + elif attack == "nonempty": + buffer = BytesIO() + np.save(buffer, np.ones(1), allow_pickle=False) + entries[storage["file"]] = buffer.getvalue() + elif attack == "object_shape": + refs.resolve(storage["shape"])["content"] = [] + else: + storage["content"] = [ + { + "__loader__": "JsonNode", + "__class__": "str", + "__module__": "builtins", + "content": "1", + "is_json": True, + } + ] + + payload = alter_schema(sio.dumps(model), change) + monkeypatch.setattr(sio, "loads", lambda *a, **kw: pytest.fail("skops called")) + with pytest.raises(mlbundle.InvalidModelError, match=r"dtype|references"): + Adapter().deserialize(payload) diff --git a/tests/test_sklearn_numpy_functions.py b/tests/test_sklearn_numpy_functions.py new file mode 100644 index 0000000..01337a0 --- /dev/null +++ b/tests/test_sklearn_numpy_functions.py @@ -0,0 +1,97 @@ +"""FunctionTransformer uses only closed elementwise NumPy function references.""" + +import numpy as np +import pytest +import skops.io as sio +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import FunctionTransformer + +import mlbundle +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.parameters import _parameter +from mlbundle._sklearn.trusted import NUMPY_FUNCTIONS +from sklearn_helpers import alter_schema, rewrite + + +def user_transform(rows): + return np.log1p(rows) + + +def fitted(function=np.log1p, inverse=None): + rows = np.random.default_rng(7).uniform(0.1, 1, (40, 3)) + y = (rows[:, 0] > 0.5).astype(int) + model = make_pipeline( + FunctionTransformer(function, inverse_func=inverse, check_inverse=False), + LogisticRegression(), + ).fit(rows, y) + return model, rows + + +@pytest.mark.parametrize("function", [*NUMPY_FUNCTIONS, np.abs]) +@pytest.mark.parametrize("inverse", [False, True]) +def test_closed_numpy_functions_roundtrip(function, inverse, tmp_path): + model, rows = fitted( + np.log1p if inverse else function, function if inverse else None + ) + path = tmp_path / "function.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + key = "inverse_func" if inverse else "func" + assert getattr(loaded.native_model[0], key) is function + assert loaded.manifest.model.hyperparameters[f"functiontransformer__{key}"] == { + "$function": f"numpy.{function.__name__}" + } + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + + +@pytest.mark.parametrize( + "function", [user_transform, lambda x: x, np.sin, np.mean, np.load] +) +@pytest.mark.parametrize("inverse", [False, True]) +def test_other_functions_rejected_at_export_and_load(function, inverse, tmp_path): + model, _ = fitted() + setattr(model[0], "inverse_func" if inverse else "func", function) + with pytest.raises(mlbundle.UnsupportedModelError) as error: + mlbundle.export(model, tmp_path / "bad.mlbundle") + assert any(function.__name__ in name for name in error.value.untrusted_types) + with pytest.raises(mlbundle.UnsupportedModelError) as error: + Adapter().deserialize(sio.dumps(model)) + assert any(function.__name__ in name for name in error.value.untrusted_types) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("attribute", ["func", "inverse_func"]) +def test_altered_function_reference_fails_claims(attribute, tmp_path): + model, _ = fitted(inverse=np.expm1) + path = tmp_path / "original.mlbundle" + mlbundle.export(model, path) + setattr(model[0], attribute, np.square) + with pytest.raises(mlbundle.ClaimMismatchError): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize( + "loader", ["ObjectNode", "TypeNode", "ConstructorFromReduceNode", "NdArrayNode"] +) +def test_numpy_function_cannot_choose_calling_loader(loader, monkeypatch): + def change(schema, entries): + schema["__loader__"] = loader + + payload = alter_schema(sio.dumps(np.log1p), change) + monkeypatch.setattr(sio, "loads", lambda *a, **kw: pytest.fail("skops called")) + with pytest.raises(mlbundle.InvalidModelError, match="function loader"): + Adapter().deserialize(payload) + + +def test_spoofed_function_identity_rejected(): + from sklearn.feature_selection import f_classif + + user_transform.__module__ = f_classif.__module__ + user_transform.__name__ = f_classif.__name__ + try: + with pytest.raises(mlbundle.UnsupportedModelError): + _parameter(user_transform, frozenset()) + finally: + user_transform.__module__ = __name__ + user_transform.__name__ = "user_transform" diff --git a/tests/test_sklearn_scalar_types.py b/tests/test_sklearn_scalar_types.py new file mode 100644 index 0000000..8b8abb8 --- /dev/null +++ b/tests/test_sklearn_scalar_types.py @@ -0,0 +1,135 @@ +"""Closed NumPy scalar type parameter records and fitted categorical composition.""" + +import numpy as np +import pandas as pd +import pytest +import skops.io as sio +from sklearn.compose import ColumnTransformer +from sklearn.ensemble import GradientBoostingClassifier +from sklearn.impute import SimpleImputer +from sklearn.linear_model import LogisticRegression +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import ( + OneHotEncoder, + OrdinalEncoder, + StandardScaler, + TargetEncoder, +) + +import mlbundle +from mlbundle._sklearn import Adapter +from mlbundle._sklearn.parameters import _parameter +from mlbundle._sklearn.trusted import SCALAR_TYPES +from sklearn_helpers import alter_schema, rewrite + + +@pytest.mark.parametrize("scalar", SCALAR_TYPES) +def test_scalar_type_parameter_projection(scalar): + assert _parameter(scalar, frozenset()) == {"$type": f"numpy.{scalar.__name__}"} + + +@pytest.mark.parametrize("encoder", [OneHotEncoder, OrdinalEncoder, TargetEncoder]) +def test_default_encoder_in_column_transformer(encoder, tmp_path): + rows = pd.DataFrame({"category": ["red", "blue"] * 10, "number": np.arange(20)}) + model = make_pipeline( + ColumnTransformer([("cat", encoder(), ["category"])], remainder="passthrough"), + LogisticRegression(), + ).fit(rows, [0, 1] * 10) + path = tmp_path / "encoder.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert np.array_equal(loaded.predict_proba(rows), model.predict_proba(rows)) + if encoder is not TargetEncoder: + assert loaded.manifest.model.hyperparameters[ + "columntransformer__cat__dtype" + ] == {"$type": "numpy.float64"} + + +def test_nested_imputer_scaler_encoder_gradient_boosting(tmp_path): + rows = pd.DataFrame( + {"cat": ["red", "blue"] * 10, "num": np.arange(20, dtype=float)} + ) + rows.loc[0, "num"] = np.nan + model = make_pipeline( + ColumnTransformer( + [ + ("numeric", make_pipeline(SimpleImputer(), StandardScaler()), ["num"]), + ("cat", OneHotEncoder(), ["cat"]), + ] + ), + GradientBoostingClassifier(n_estimators=2), + ).fit(rows, [0, 1] * 10) + path = tmp_path / "nested.mlbundle" + mlbundle.export(model, path) + assert np.array_equal( + mlbundle.load(path).predict_proba(rows), model.predict_proba(rows) + ) + model[0].transformers_[0][1][0].statistics_[0] += 1 + with pytest.raises(mlbundle.ClaimMismatchError, match="state_sha256"): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +@pytest.mark.parametrize( + "scalar", + [float, object, np.str_, np.object_, np.void, np.datetime64, np.longdouble], +) +def test_other_type_hyperparameters_remain_rejected(scalar, tmp_path): + model = make_pipeline(OneHotEncoder(), LogisticRegression()).fit( + [[0], [1], [0], [1]], [0, 1, 0, 1] + ) + model[0].dtype = scalar + with pytest.raises(mlbundle.UnsupportedModelError): + mlbundle.export(model, tmp_path / "bad.mlbundle") + with pytest.raises(mlbundle.UnsupportedModelError): + _parameter(scalar, frozenset()) + + +def test_changed_type_parameter_rejected_on_load(tmp_path): + model = make_pipeline(OneHotEncoder(), LogisticRegression()).fit( + [[0], [1], [0], [1]], [0, 1, 0, 1] + ) + path = tmp_path / "original.mlbundle" + mlbundle.export(model, path) + model[0].dtype = np.float32 + with pytest.raises(mlbundle.ClaimMismatchError): + mlbundle.load(rewrite(path, sio.dumps(model))) + + +def test_dtype_instance_parameter_roundtrip(tmp_path): + rows = [[0], [1], [0], [1]] + model = make_pipeline( + OneHotEncoder(dtype=np.dtype("float64")), LogisticRegression() + ).fit(rows, [0, 1, 0, 1]) + path = tmp_path / "dtype-instance.mlbundle" + mlbundle.export(model, path) + loaded = mlbundle.load(path) + assert loaded.manifest.model.hyperparameters["onehotencoder__dtype"] == { + "$dtype": " Date: Mon, 12 Oct 2026 15:07:51 +0200 Subject: [PATCH 12/15] perf: speed up verified structure loading (#14) * perf: reuse bounded canonical JSON validation * perf: prove structure equality from stored bytes --- docs/format-container.md | 7 + docs/format-state.md | 16 ++- src/mlbundle/_canonical_work.py | 20 ++- src/mlbundle/canonical.py | 176 +++++++++++++++++++---- src/mlbundle/claims.py | 21 ++- src/mlbundle/container.py | 29 +++- src/mlbundle/inspection.py | 10 +- src/mlbundle/model_io.py | 43 +++--- src/mlbundle/schema.py | 14 +- src/mlbundle/writing.py | 24 +++- tests/canonical_reference.py | 212 ++++++++++++++++++++++++++++ tests/test_canonical_equivalence.py | 196 +++++++++++++++++++++++++ tests/test_inspection.py | 2 +- tests/test_json_reuse.py | 133 +++++++++++++++++ 14 files changed, 822 insertions(+), 81 deletions(-) create mode 100644 tests/canonical_reference.py create mode 100644 tests/test_canonical_equivalence.py create mode 100644 tests/test_json_reuse.py diff --git a/docs/format-container.md b/docs/format-container.md index 3ade8dd..af98118 100644 --- a/docs/format-container.md +++ b/docs/format-container.md @@ -96,6 +96,13 @@ their original bytes are preserved. The container validates JSON syntax and bounds, while later model/schema APIs own semantic structure and schema checks. Metadata inspection does not infer whether a model requires structure. +Internally, optional JSON members retain their validated values within one +operation. Inspect/load reuse that decode for schema/sample checks, and load +compares freshly encoded native extraction with stored structure bytes. Equality +proves canonical identity; other wire spellings encode the retained value once. +There is no global cache or bundle-supplied validation record. Public export +reuses the writer's complete container validation when returning metadata. + The writer emits manifest, model, schema, structure, samples in that fixed order, omitting absent members. ZIP timestamps are 1980-01-01 00:00:00, the creating platform is Unix, permissions are regular-file 0600, compression is deflate diff --git a/docs/format-state.md b/docs/format-state.md index 759e348..a7254e1 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -187,7 +187,21 @@ bypass this bound. All these are fixed ceilings in addition to archive limits. Canonical JSON preflight calculates expanded minimum size/depth of shared native containers with memoization before normalization, rejecting over-budget expansion and cycles. Native arrays are size-checked before `tolist`. Wire JSON has no -references and remains bounded by its stored bytes/depth. +references and remains bounded by its stored bytes/depth. Lexical depth and +Unicode escape checks precede decoding; object hooks reject duplicate keys and +invalid float tags. The C decoder handles ordinary numbers, followed by finite +float checks (bulk checks for flat arrays); integers retain the 4,096-digit check. +No native-value normalization or shared-container expansion pass is needed for +decoded wire values. + +Canonical encoding may be deferred until comparison when its byte budget is +already proven: strings/integers cannot grow from their wire spelling, and a +finite binary64 float has at most 24 canonical bytes for at least three wire +token bytes. Thus eight times the stored byte length is a conservative bound. +Otherwise the canonical encoder checks the exact size immediately. This does +not raise any ceiling. Equality of validated stored bytes to freshly recomputed +canonical bytes already proves exact canonical equality; otherwise comparison +encodes the decoded value with the same sorted keys, types and numeric spellings. ## XGBoost, CatBoost and ONNX diff --git a/src/mlbundle/_canonical_work.py b/src/mlbundle/_canonical_work.py index 32a697e..d9ef548 100644 --- a/src/mlbundle/_canonical_work.py +++ b/src/mlbundle/_canonical_work.py @@ -4,6 +4,22 @@ from .errors import LimitExceededError +_SCALARS = (str, int, float, bool, type(None)) +_NUMBERS = {int, float, bool, type(None)} + + +def _sequence_cost(item): + # C map/set classify flat numeric arrays without Python calls per element. + if len(item) >= 64 and set(map(type, item)) <= _NUMBERS: + return 1 + 2 * len(item), () + return 1, item + + +def _child_cost(child: Any, measure) -> tuple[int, int]: + if type(child) in _SCALARS: + return (len(child) + 2 if type(child) is str else 1), 0 + return measure(child) + def _array_cost(value: Any) -> tuple[int, int, Any]: return value.ndim, value.size, value.flat if value.dtype.kind == "O" else () @@ -33,11 +49,11 @@ def measure(item): size += sum(len(key) + 3 for key in item if isinstance(key, str)) children = item.values() else: - children = item + size, children = _sequence_cost(item) if size > max_bytes or depth > max_depth: raise LimitExceededError("Expanded JSON size or depth exceeds the limit.") for child in children: - child_size, child_depth = measure(child) + child_size, child_depth = _child_cost(child, measure) size += child_size + 1 depth = max(depth, child_depth + 1) if size > max_bytes or depth > max_depth: diff --git a/src/mlbundle/canonical.py b/src/mlbundle/canonical.py index 638037f..310cd77 100644 --- a/src/mlbundle/canonical.py +++ b/src/mlbundle/canonical.py @@ -2,6 +2,9 @@ import json import math +import re +from dataclasses import dataclass +from functools import cached_property from typing import cast from pydantic import JsonValue @@ -12,6 +15,9 @@ _FLOAT_TAG = "$float" _INTEGER_CEILING = 10**4096 +_DELIMITERS = re.compile(r'["\[\]{}]') +_STRING = re.compile(r'"[^"\\]*+(?:\\.[^"\\]*+)*+"?', re.DOTALL) +_ESCAPES = re.compile(r"\\(?:u([a-fA-F0-9]{4})|.)", re.DOTALL) def _float(value: float, depth: int, max_depth: int) -> JsonValue: @@ -59,10 +65,35 @@ def _normalize(value: object, depth: int, max_depth: int) -> JsonValue: key: _normalize(item, depth + 1, max_depth) for key, item in value.items() } if isinstance(value, (list, tuple)): - return [_normalize(item, depth + 1, max_depth) for item in value] + return _sequence(value, depth + 1, max_depth) return _numpy(value, depth, max_depth) +def _sequence(value, depth: int, max_depth: int) -> JsonValue: + if len(value) >= 64: + kinds = set(map(type, value)) + if (kinds == {float} and all(map(math.isfinite, value))) or ( + kinds == {int} and max(map(abs, value)) < _INTEGER_CEILING + ): + return value if type(value) is list else list(value) + return list(_items(value, depth, max_depth)) + + +def _items(values, depth: int, max_depth: int): + # Keep flat native numeric arrays out of the recursive dispatch path. + for item in values: + kind = type(item) + if ( + (kind is float and math.isfinite(item)) + or item is None + or kind in (bool, str) + or (kind is int and abs(item) < _INTEGER_CEILING) + ): + yield item + else: + yield _normalize(item, depth, max_depth) + + def canonical_dumps( value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH ) -> bytes: @@ -82,25 +113,54 @@ def canonical_dumps( return encoded +def _check_surrogates(text: str, start: int, end: int) -> None: + pending = None + for match in _ESCAPES.finditer(text, start, end): + token = match[1] + code = int(token, 16) if token is not None else 0 + if pending is not None: + if match.start() != pending or not 0xDC00 <= code <= 0xDFFF: + raise InvalidJSONError + pending = None + elif 0xD800 <= code <= 0xDBFF: + pending = match.end() + elif 0xDC00 <= code <= 0xDFFF: + raise InvalidJSONError + if pending is not None: + raise InvalidJSONError + + def _check_depth(text: str, max_depth: int) -> None: depth = 0 - quoted = escaped = False - for character in text: - if quoted: - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == '"': - quoted = False - elif character == '"': - quoted = True - elif character in "[{": + valid_unicode = True + # The C regex skips complete strings and numeric runs; only containers + # need Python work. Possessive runs keep malformed strings linear too. + position = 0 + while match := _DELIMITERS.search(text, position): + character = text[match.start()] + position = match.end() + if character in "[{": depth += 1 if depth > max_depth: raise LimitExceededError("JSON nesting exceeds the depth limit.") elif character in "]}": depth -= 1 + else: + string = _STRING.match(text, match.start()) + assert string is not None + position = string.end() + if text.find(r"\u", string.start(), position) != -1: + valid_unicode &= _valid_surrogates(text, string.start(), position) + if not valid_unicode: + raise InvalidJSONError + + +def _valid_surrogates(text: str, start: int, end: int) -> bool: + try: + _check_surrogates(text, start, end) + except InvalidJSONError: + return False + return True def _object(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]: @@ -116,13 +176,6 @@ def _object(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]: return result -def _finite_float(token: str) -> float: - result = float(token) - if not math.isfinite(result): - raise InvalidJSONError - return result - - def _constant(_token: str) -> JsonValue: raise InvalidJSONError @@ -133,9 +186,74 @@ def _integer(token: str) -> int: return int(token) -def canonical_loads( +def _finite_array(value: list) -> bool: + if len(value) < 64: + return False + kinds = set(map(type, value)) + if kinds == {float}: + if not all(map(math.isfinite, value)): + raise InvalidJSONError + return True + return not kinds.intersection((float, list, dict)) + + +def _check_finite(value) -> None: + if type(value) is float: + if not math.isfinite(value): + raise InvalidJSONError + return + if not isinstance(value, (list, dict)): + return + if isinstance(value, list) and _finite_array(value): + return + children = value.values() if isinstance(value, dict) else value + for child in children: + if type(child) is float: + if not math.isfinite(child): + raise InvalidJSONError + elif isinstance(child, (list, dict)): + _check_finite(child) + + +def _encode_json(value: JsonValue) -> bytes: + return json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + + +def _bounded_encoding( + value: JsonValue, stored_bytes: int, max_bytes: int +) -> bytes | None: + # Only float spelling can grow: <=24 canonical bytes per >=3 token bytes. + if stored_bytes * 8 <= max_bytes: + _check_finite(value) + return None + encoded = _encode_json(value) + if len(encoded) > max_bytes: + raise LimitExceededError("JSON exceeds the byte limit.") + return encoded + + +@dataclass(frozen=True) +class DecodedJSON: + """Operation-local decoded value and its checked canonical comparison bytes.""" + + value: JsonValue + source: bytes + _encoded: bytes | None + + @cached_property + def canonical(self) -> bytes: + return self._encoded if self._encoded is not None else _encode_json(self.value) + + +def decode_json( data: bytes, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH -) -> JsonValue: +) -> DecodedJSON: if len(data) > max_bytes: raise LimitExceededError("JSON exceeds the byte limit.") try: @@ -145,14 +263,20 @@ def canonical_loads( text, object_pairs_hook=_object, parse_constant=_constant, - parse_float=_finite_float, parse_int=_integer, ) - # Reject invalid Unicode and enforce the encoder's bounds too. - canonical_dumps(value, max_bytes=max_bytes, max_depth=max_depth) + # Stored byte/depth bounds also bound nodes/strings and expansion: + # wire JSON has no shared references. Retain encoding only when needed. + encoded = _bounded_encoding(value, len(data), max_bytes) except (ValueError, TypeError, UnicodeError, RecursionError): raise InvalidJSONError from None - return cast(JsonValue, value) + return DecodedJSON(cast(JsonValue, value), data, encoded) + + +def canonical_loads( + data: bytes, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> JsonValue: + return decode_json(data, max_bytes=max_bytes, max_depth=max_depth).value def canonical_value( diff --git a/src/mlbundle/claims.py b/src/mlbundle/claims.py index 9511888..ce01641 100644 --- a/src/mlbundle/claims.py +++ b/src/mlbundle/claims.py @@ -7,7 +7,7 @@ from pydantic import Field, JsonValue, field_validator, model_validator from ._base import ContractModel, Name, ShortString -from .canonical import canonical_dumps +from .canonical import DecodedJSON, canonical_dumps from .contract import ModelKind from .environment import OnnxInfo from .errors import ClaimMismatchError @@ -106,8 +106,19 @@ def verify_claims(expected: ModelClaims, actual: ModelClaims) -> None: raise ClaimMismatchError(name) -def verify_structure(expected: JsonValue, actual: JsonValue) -> None: - if canonical_dumps(expected, max_bytes=STRUCTURE_BYTES) != canonical_dumps( - actual, max_bytes=STRUCTURE_BYTES - ): +def verify_structure(expected: JsonValue, actual: JsonValue) -> bytes: + encoded = canonical_dumps(expected, max_bytes=STRUCTURE_BYTES) + verify_structure_bytes(encoded, actual) + return encoded + + +def verify_structure_bytes(expected: bytes | DecodedJSON, actual: JsonValue) -> None: + encoded = canonical_dumps(actual, max_bytes=STRUCTURE_BYTES) + if isinstance(expected, DecodedJSON): + # Equality to freshly recomputed canonical bytes proves this checked + # wire value is canonical too; other wire spellings still re-encode. + if expected.source == encoded: + return + expected = expected.canonical + if expected != encoded: raise ClaimMismatchError("structure") diff --git a/src/mlbundle/container.py b/src/mlbundle/container.py index 9f26e1a..8fc3254 100644 --- a/src/mlbundle/container.py +++ b/src/mlbundle/container.py @@ -2,13 +2,14 @@ import struct import zlib +from dataclasses import dataclass from os import PathLike from pathlib import Path from ._container_rules import CHUNK_BYTES, BundleSource, check_members, effective_limits from ._zip_directory import directory from ._zip_members import decompress, payloads -from .canonical import canonical_loads +from .canonical import DecodedJSON, decode_json from .contract import MemberName from .errors import InvalidContainerError, LimitExceededError from .integrity import verify_integrity @@ -37,9 +38,16 @@ def _read_source(source: BundleSource, cap: int) -> bytes: return bytes(content) -def read_container( +@dataclass +class ValidatedContainer: + manifest: Manifest + members: dict[MemberName, bytes] + json: dict[MemberName, DecodedJSON] + + +def read_validated_container( source: BundleSource, *, limits: ArchiveLimits | None = None -) -> tuple[Manifest, dict[MemberName, bytes]]: +) -> ValidatedContainer: """Validate ZIP records, bounded decompression, manifest and member integrity. Streams are consumed from their current position and are never closed here. @@ -58,6 +66,7 @@ def read_container( manifest = Manifest.from_bytes(manifest_bytes) check_members(by_name, manifest) members = {MemberName.MANIFEST: manifest_bytes} + decoded = {} for entry in entries: if entry.name != MemberName.MANIFEST: value = decompress(packed[entry.name], entry) @@ -67,8 +76,18 @@ def read_container( MemberName.STRUCTURE, MemberName.SAMPLES, ): - canonical_loads(value, max_bytes=selected.member_bytes(entry.name)) + decoded[entry.name] = decode_json( + value, max_bytes=selected.member_bytes(entry.name) + ) members[entry.name] = value except (struct.error, zlib.error, OverflowError): raise InvalidContainerError("Corrupt ZIP container.") from None - return manifest, members + return ValidatedContainer(manifest, members, decoded) + + +def read_container( + source: BundleSource, *, limits: ArchiveLimits | None = None +) -> tuple[Manifest, dict[MemberName, bytes]]: + """Return the validated manifest and original bytes, without loading a model.""" + result = read_validated_container(source, limits=limits) + return result.manifest, result.members diff --git a/src/mlbundle/inspection.py b/src/mlbundle/inspection.py index 727f7ad..952b0dc 100644 --- a/src/mlbundle/inspection.py +++ b/src/mlbundle/inspection.py @@ -1,7 +1,7 @@ """Public metadata-only inspection, without resolving any model adapter.""" from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits -from .container import read_container +from .container import ValidatedContainer, read_validated_container from .limits import ArchiveLimits from .results import MetadataView from .schema import read_declarations @@ -16,8 +16,12 @@ def inspect( model. Only a full validating model load can provide that guarantee. """ selected = effective_limits(limits) - manifest, members = read_container(source, limits=selected) - schema, samples = read_declarations(manifest, members, selected) + return metadata_view(read_validated_container(source, limits=selected), selected) + + +def metadata_view(container: ValidatedContainer, limits: ArchiveLimits) -> MetadataView: + manifest, members = container.manifest, container.members + schema, samples = read_declarations(manifest, container.json, limits) return MetadataView( manifest, tuple(name for name in MEMBER_ORDER if name in members), diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py index ee749b3..7ad2535 100644 --- a/src/mlbundle/model_io.py +++ b/src/mlbundle/model_io.py @@ -10,9 +10,14 @@ from pydantic import JsonValue from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits -from .canonical import canonical_dumps, canonical_loads -from .claims import Label, ModelClaims, verify_claims, verify_structure -from .container import read_container +from .claims import ( + Label, + ModelClaims, + verify_claims, + verify_structure, + verify_structure_bytes, +) +from .container import read_validated_container from .contract import ( FORMAT_NAME, FORMAT_VERSION, @@ -25,13 +30,13 @@ from .declarations import ExportDeclarations, export_declarations, resolve_labels from .environment import capture_environment from .errors import ClaimMismatchError, LimitExceededError -from .inspection import inspect +from .inspection import metadata_view from .limits import ArchiveLimits from .registry import find_adapter, get_adapter from .results import LoadedBundle, MetadataView from .schema import read_declarations from .training import TrainingDeclaration, prepare_training -from .writing import write_container +from .writing import write_validated_container def _manifest_parts( @@ -143,12 +148,10 @@ def export( verify_claims( claims, resolve_labels(adapter.extract_claims(restored), declarations) ) - verify_structure(structure, adapter.extract_structure(restored)) + structure_bytes = verify_structure(structure, adapter.extract_structure(restored)) members = {adapter.model_member: data} if structure is not None: - members[MemberName.STRUCTURE] = canonical_dumps( - structure, max_bytes=selected.member_bytes(MemberName.STRUCTURE) - ) + members[MemberName.STRUCTURE] = structure_bytes if training is not None: members[MemberName.SCHEMA] = training.schema if training.samples is not None: @@ -163,25 +166,18 @@ def export( target_name=target_name, metadata=metadata, ) - result = write_container( + _, validated = write_validated_container( parts, members, destination=destination, overwrite=overwrite, limits=selected ) - return inspect(result, limits=selected) - - -def _optional_json( - members: dict[MemberName, bytes], name: MemberName, limits: ArchiveLimits -) -> JsonValue: - if name not in members: - return None - return canonical_loads(members[name], max_bytes=limits.member_bytes(name)) + return metadata_view(validated, selected) def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> LoadedBundle: """Verify format, integrity and recomputed native claims before returning a model.""" selected = effective_limits(limits) - manifest, members = read_container(source, limits=selected) - schema, samples = read_declarations(manifest, members, selected) + container = read_validated_container(source, limits=selected) + manifest, members = container.manifest, container.members + schema, samples = read_declarations(manifest, container.json, selected) adapter = get_adapter(manifest.model.library) model = adapter.deserialize(members[adapter.model_member]) actual = adapter.extract_claims(model) @@ -199,11 +195,12 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded ), ) verify_claims(expected, actual) - structure = _optional_json(members, MemberName.STRUCTURE, selected) + decoded = container.json.get(MemberName.STRUCTURE) + structure = None if decoded is None else decoded.value extracted = adapter.extract_structure(model) if (MemberName.STRUCTURE in members) != (extracted is not None): raise ClaimMismatchError("structure") - verify_structure(structure, extracted) + verify_structure_bytes(b"null" if decoded is None else decoded, extracted) return LoadedBundle( manifest=manifest, members=tuple(name for name in MEMBER_ORDER if name in members), diff --git a/src/mlbundle/schema.py b/src/mlbundle/schema.py index e1769d5..c8088f1 100644 --- a/src/mlbundle/schema.py +++ b/src/mlbundle/schema.py @@ -6,7 +6,7 @@ from ._base import ContractModel from ._schema_fields import OneHotField, SchemaField, SeriesField -from .canonical import canonical_dumps, canonical_loads +from .canonical import DecodedJSON, canonical_dumps from .claims import Label, ModelClaims from .contract import MemberName, ModelKind from .errors import InvalidSamplesError, InvalidSchemaError @@ -118,7 +118,7 @@ def validate_samples(value: JsonValue, names: list[str]) -> list[dict[str, JsonV def read_declarations( - manifest: Manifest, members: dict[MemberName, bytes], limits: ArchiveLimits + manifest: Manifest, members: dict[MemberName, DecodedJSON], limits: ArchiveLimits ) -> tuple[dict[str, JsonValue] | None, list[dict[str, JsonValue]] | None]: schema = samples = None if ( @@ -132,20 +132,14 @@ def read_declarations( "Schema features cannot contain the declared target." ) schema = validate_schema( - canonical_loads( - members[MemberName.SCHEMA], - max_bytes=limits.member_bytes(MemberName.SCHEMA), - ), + members[MemberName.SCHEMA].value, manifest.features.names, manifest.verified_claims(), max_bytes=limits.member_bytes(MemberName.SCHEMA), ) if MemberName.SAMPLES in members: samples = validate_samples( - canonical_loads( - members[MemberName.SAMPLES], - max_bytes=limits.member_bytes(MemberName.SAMPLES), - ), + members[MemberName.SAMPLES].value, manifest.features.names, ) return schema, samples diff --git a/src/mlbundle/writing.py b/src/mlbundle/writing.py index a1d552b..e7ff3b1 100644 --- a/src/mlbundle/writing.py +++ b/src/mlbundle/writing.py @@ -20,7 +20,7 @@ ) from ._zip_directory import CENTRAL, END, LOCAL from .canonical import canonical_dumps -from .container import read_container +from .container import ValidatedContainer, read_validated_container from .contract import MemberName from .errors import InvalidContainerError, LimitExceededError from .integrity import hash_member @@ -198,14 +198,14 @@ def _archive_bytes(content: dict[MemberName, bytes], limits: ArchiveLimits) -> b return buffer.getvalue() -def write_container( +def write_validated_container( manifest: Manifest | Mapping[str, object], members: Mapping[MemberName, bytes], *, destination: str | PathLike[str] | None = None, overwrite: bool = False, limits: ArchiveLimits | None = None, -) -> bytes: +) -> tuple[bytes, ValidatedContainer]: """Finish integrity, encode a deterministic ZIP and optionally publish it. Supply only non-manifest member bytes and a manifest or its JSON-ready parts. @@ -236,7 +236,21 @@ def write_container( check_members(content, finished) result = _archive_bytes(content, selected) # One owner for all ZIP/integrity/JSON checks, including actual compressed sizes. - read_container(result, limits=selected) + validated = read_validated_container(result, limits=selected) if destination is not None: _atomic_write(Path(destination), result, overwrite) - return result + return result, validated + + +def write_container( + manifest: Manifest | Mapping[str, object], + members: Mapping[MemberName, bytes], + *, + destination: str | PathLike[str] | None = None, + overwrite: bool = False, + limits: ArchiveLimits | None = None, +) -> bytes: + """Write and validate once, preserving the byte-only container contract.""" + return write_validated_container( + manifest, members, destination=destination, overwrite=overwrite, limits=limits + )[0] diff --git a/tests/canonical_reference.py b/tests/canonical_reference.py new file mode 100644 index 0000000..626dc87 --- /dev/null +++ b/tests/canonical_reference.py @@ -0,0 +1,212 @@ +"""Frozen format-1 codec from 13ce639; only imports/formatting were adjusted.""" + +import json +import math +from typing import Any, cast + +from pydantic import JsonValue + +from mlbundle.errors import InvalidJSONError, LimitExceededError +from mlbundle.limits import JSON_DEPTH, MANIFEST_BYTES + + +def _array_cost(value: Any) -> tuple[int, int, Any]: + return value.ndim, value.size, value.flat if value.dtype.kind == "O" else () + + +def bound_work(value: Any, max_bytes: int, max_depth: int) -> None: + memo: dict[int, tuple[int, int]] = {} + active: set[int] = set() + + def measure(item): + array = ( + type(item).__module__.split(".")[0] == "numpy" + and type(item).__name__ == "ndarray" + ) + if not isinstance(item, (dict, list, tuple)) and not array: + return (len(item) + 2 if isinstance(item, str) else 1), 0 + identity = id(item) + if identity in active: + raise LimitExceededError("Cyclic JSON containers exceed the nesting limit.") + if identity in memo: + return memo[identity] + active.add(identity) + depth, size = 1, 1 + if array: + depth, size, children = _array_cost(item) + elif isinstance(item, dict): + size += sum(len(key) + 3 for key in item if isinstance(key, str)) + children = item.values() + else: + children = item + if size > max_bytes or depth > max_depth: + raise LimitExceededError("Expanded JSON size or depth exceeds the limit.") + for child in children: + child_size, child_depth = measure(child) + size += child_size + 1 + depth = max(depth, child_depth + 1) + if size > max_bytes or depth > max_depth: + raise LimitExceededError( + "Expanded JSON size or depth exceeds the limit." + ) + active.remove(identity) + memo[identity] = size, depth + return size, depth + + measure(value) + + +_FLOAT_TAG = "$float" +_INTEGER_CEILING = 10**4096 + + +def _float(value: float, depth: int, max_depth: int) -> JsonValue: + if math.isfinite(value): + return value + if depth >= max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + token = "nan" if math.isnan(value) else "+inf" if value > 0 else "-inf" + return {_FLOAT_TAG: token} + + +def _numpy(value: object, depth: int, max_depth: int) -> JsonValue: + # Writer-side numpy conversion needs no import in the base package. + if type(value).__module__.split(".")[0] == "numpy": + array = type(value).__name__ == "ndarray" + kind = getattr(getattr(value, "dtype", None), "kind", None) + if kind not in ("b", "i", "u", "f", "U") and not (array and kind == "O"): + raise InvalidJSONError + method = "tolist" if array else "item" + convert = getattr(value, method, None) + if callable(convert): + converted = convert() + if type(converted) is not type(value): + return _normalize(converted, depth, max_depth) + raise InvalidJSONError + + +def _normalize(value: object, depth: int, max_depth: int) -> JsonValue: + if isinstance(value, (dict, list, tuple)) and depth >= max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + if type(value) is int and abs(value) >= _INTEGER_CEILING: + raise InvalidJSONError + if value is None or type(value) in (bool, int, str): + return cast(JsonValue, value) + if type(value) is float: + return _float(value, depth, max_depth) + if isinstance(value, dict): + if any(type(key) is not str for key in value): + raise InvalidJSONError + if _FLOAT_TAG in value and ( + len(value) != 1 or value[_FLOAT_TAG] not in ("nan", "+inf", "-inf") + ): + raise InvalidJSONError + return { + key: _normalize(item, depth + 1, max_depth) for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_normalize(item, depth + 1, max_depth) for item in value] + return _numpy(value, depth, max_depth) + + +def canonical_dumps( + value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> bytes: + bound_work(value, max_bytes, max_depth) + try: + encoded = json.dumps( + _normalize(value, 0, max_depth), + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (ValueError, TypeError, UnicodeError, RecursionError): + raise InvalidJSONError from None + if len(encoded) > max_bytes: + raise LimitExceededError("JSON exceeds the byte limit.") + return encoded + + +def _check_depth(text: str, max_depth: int) -> None: + depth = 0 + quoted = escaped = False + for character in text: + if quoted: + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == '"': + quoted = False + elif character == '"': + quoted = True + elif character in "[{": + depth += 1 + if depth > max_depth: + raise LimitExceededError("JSON nesting exceeds the depth limit.") + elif character in "]}": + depth -= 1 + + +def _object(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} + for key, value in pairs: + if key in result: + raise InvalidJSONError + result[key] = value + if _FLOAT_TAG in result and ( + len(result) != 1 or result[_FLOAT_TAG] not in ("nan", "+inf", "-inf") + ): + raise InvalidJSONError + return result + + +def _finite_float(token: str) -> float: + result = float(token) + if not math.isfinite(result): + raise InvalidJSONError + return result + + +def _constant(_token: str) -> JsonValue: + raise InvalidJSONError + + +def _integer(token: str) -> int: + if len(token.lstrip("-")) > 4096: + raise InvalidJSONError + return int(token) + + +def canonical_loads( + data: bytes, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> JsonValue: + if len(data) > max_bytes: + raise LimitExceededError("JSON exceeds the byte limit.") + try: + text = data.decode("utf-8") + _check_depth(text, max_depth) + value = json.loads( + text, + object_pairs_hook=_object, + parse_constant=_constant, + parse_float=_finite_float, + parse_int=_integer, + ) + # Reject invalid Unicode and enforce the encoder's bounds too. + canonical_dumps(value, max_bytes=max_bytes, max_depth=max_depth) + except (ValueError, TypeError, UnicodeError, RecursionError): + raise InvalidJSONError from None + return cast(JsonValue, value) + + +def canonical_value( + value: object, *, max_bytes: int = MANIFEST_BYTES, max_depth: int = JSON_DEPTH +) -> JsonValue: + """Project native scalars/arrays to the same bounded JSON used for comparison.""" + return canonical_loads( + canonical_dumps(value, max_bytes=max_bytes, max_depth=max_depth), + max_bytes=max_bytes, + max_depth=max_depth, + ) diff --git a/tests/test_canonical_equivalence.py b/tests/test_canonical_equivalence.py new file mode 100644 index 0000000..a3e72a4 --- /dev/null +++ b/tests/test_canonical_equivalence.py @@ -0,0 +1,196 @@ +"""Compare the optimized codec with the format-1 implementation at 13ce639.""" + +import json +import math +import random +import struct + +import numpy as np +import pytest + +import canonical_reference as old +from mlbundle import canonical +from mlbundle.errors import InvalidJSONError, LimitExceededError + + +def outcome(function, value, **limits): + try: + result = function(value, **limits) + if isinstance(result, float): + result = struct.pack("!d", result) + return result + except (InvalidJSONError, LimitExceededError) as error: + return type(error) + + +def generated(rng, depth=0): + atoms = [ + None, + True, + False, + 0, + -1, + 10**80, + -0.0, + 5e-324, + math.inf, + math.nan, + "", + "é", + '\n\t\\"[{}]', + "😀", + "\u0000", + ] + if depth == 4 or rng.randrange(3) == 0: + return rng.choice(atoms) + children = [generated(rng, depth + 1) for _ in range(rng.randrange(5))] + if rng.randrange(2): + return children + return {str(index): value for index, value in enumerate(children)} + + +def test_generated_values_and_wire_spellings_match(): + rng = random.Random(9) + for _ in range(400): + value = generated(rng) + encoded = old.canonical_dumps(value) + assert canonical.canonical_dumps(value) == encoded + decoded = old.canonical_loads(encoded) + for wire in (encoded, json.dumps(decoded, ensure_ascii=True).encode()): + assert canonical.decode_json(wire).canonical == encoded + assert canonical.canonical_loads(wire) == decoded + for limits in ({"max_bytes": max(1, len(encoded) - 1)}, {"max_depth": 2}): + assert outcome(canonical.canonical_dumps, value, **limits) == outcome( + old.canonical_dumps, value, **limits + ) + assert outcome(canonical.canonical_loads, encoded, **limits) == outcome( + old.canonical_loads, encoded, **limits + ) + + +@pytest.mark.parametrize( + "value", + [ + [0.0] * 64, + [math.inf] * 64, + [math.nan] * 64, + [10**4095] * 64, + [10**4096] * 64, + [True, None, 1, 2.0] * 32, + tuple(range(64)), + [np.float32(0.1)] * 64, + np.array([[1.0, math.inf], [-0.0, math.nan]]), + np.array([1, "é", None], dtype=object), + np.array([np.datetime64("NaT")], dtype=object), + {1: "a"}, + {"$float": []}, + object(), + b"bytes", + "\udfff", + ], +) +def test_native_success_and_rejections_match(value): + for limits in ({}, {"max_bytes": 32}, {"max_depth": 1}): + assert outcome(canonical.canonical_dumps, value, **limits) == outcome( + old.canonical_dumps, value, **limits + ) + + +@pytest.mark.parametrize( + "wire", + [ + b'{"a":1,"a":2}', + b'{"x":[{"a":1,"a":2}]}', + b'{"$float":"nan","x":0}', + b'{"$float":false}', + b"NaN", + b"Infinity", + b"-Infinity", + b"1e309", + b"-1e309", + b'"\\ud800"', + b'"\\udfff"', + b'"\\ud800\\u0041"', + b'"\\ud800x\\udc00"', + b'"\\ud800\\ud800"', + b'"\\udc00\\ud800"', + b'"\\ud83d\\ude00"', + b'"\\\\ud800"', + b'"\\\\\\ud800"', + b'{"\\ud800":1}', + b'["\\udfff"]', + b'"\\uXXXX"', + b'"unfinished\\', + b'"unfinished[[[[', + b'"\\"[[[["', + b'"a\\\\"[[[[', + b"[[[0]]]", + b"0e0", + b"1e2", + b"1e-5", + '"é"'.encode(), + b"1" + b"0" * 4096, + b"0." + b"0" * 5000 + b"1", + b"\xff", + ], +) +def test_wire_success_and_rejections_match(wire): + for limits in ({}, {"max_bytes": len(wire)}, {"max_depth": 2}): + assert outcome(canonical.canonical_loads, wire, **limits) == outcome( + old.canonical_loads, wire, **limits + ) + + +def test_numeric_bulk_paths_keep_work_and_type_bounds(monkeypatch): + class Integer(int): + pass + + class Sequence(list): + pass + + class Mapping(dict): + def items(self): + return [("a", 3)] + + for value in ([Integer(1)] * 64, Sequence([0.0] * 64), Mapping(a=2)): + assert outcome(canonical.canonical_dumps, value) == outcome( + old.canonical_dumps, value + ) + monkeypatch.setattr(canonical, "_normalize", lambda *args: pytest.fail("allocated")) + for value in ([0.0] * 64, np.zeros(64), np.empty((1,) * 4)): + with pytest.raises(LimitExceededError): + canonical.canonical_dumps(value, max_bytes=32, max_depth=2) + + +def test_decode_depth_is_checked_before_json_parser(monkeypatch): + monkeypatch.setattr( + canonical.json, "loads", lambda *args, **kw: pytest.fail("parsed") + ) + with pytest.raises(LimitExceededError): + canonical.canonical_loads(b"[[[0]]]", max_depth=2) + + +def test_deferred_comparison_is_bounded_and_exact(monkeypatch): + decoded = canonical.decode_json(b' {"z":1e2,"a":"\\ud83d\\ude00"} ') + assert decoded.canonical == '{"a":"😀","z":100.0}'.encode() + with pytest.raises(LimitExceededError): + canonical.decode_json(b"1e2", max_bytes=3) + monkeypatch.setattr(canonical, "_encode_json", lambda *args: pytest.fail("encoded")) + assert canonical.canonical_loads(b"[1.0,2.0]") == [1.0, 2.0] + + +@pytest.mark.parametrize( + "wire", + [ + b"[" + b",".join([b"1e400"] * 64) + b"]", + b"[" + b",".join([b"-1e400"] * 64) + b"]", + b"[" + b",".join([b"1", b"1e400"] * 32) + b"]", + b"[" + b",".join([b"[1e400]"] * 64) + b"]", + b"[" + b",".join([b"1.0"] * 64) + b"]", + b"[" + b",".join([b"1", b"null", b'"a"'] * 32) + b"]", + ], +) +def test_decoded_bulk_finiteness_matches(wire): + assert outcome(canonical.canonical_loads, wire) == outcome( + old.canonical_loads, wire + ) diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 31a0b55..e7d3de8 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -58,7 +58,7 @@ def test_inspect_no_model_imports(manifest_data, tmp_path): from mlbundle import inspect view = inspect(sys.argv[1]) assert view.manifest.model.library == "sklearn" -for name in ("sklearn", "skops", "xgboost", "onnx", "onnxruntime", "numpy", "pandas", "mlschema"): +for name in ("sklearn", "skops", "xgboost", "catboost", "onnx", "onnxruntime", "numpy", "pandas", "mlschema"): assert not any(key == name or key.startswith(name + ".") for key in sys.modules), name print("inspect imported no model libraries") """ diff --git a/tests/test_json_reuse.py b/tests/test_json_reuse.py new file mode 100644 index 0000000..89e7eb7 --- /dev/null +++ b/tests/test_json_reuse.py @@ -0,0 +1,133 @@ +"""One checked optional-member decode per operation, with exact comparison.""" + +import json +from collections import Counter +from types import SimpleNamespace + +import pytest + +import mlbundle +from mlbundle import canonical, claims, container, model_io +from mlbundle.canonical import canonical_dumps +from mlbundle.contract import MemberName +from mlbundle.schema import reports_for +from mlbundle.training import TrainingDeclaration +from mlbundle.writing import write_container + + +@pytest.fixture +def native(monkeypatch, manifest_data): + extracted = mlbundle.Manifest.from_bytes( + canonical_dumps(manifest_data) + ).verified_claims() + structure = {"family": "linear", "coef": [1.0, -0.0]} + adapter = SimpleNamespace( + library=mlbundle.Library.SKLEARN, + model_member=MemberName.SKOPS, + prepare_export=lambda model, declarations: model, + serialize=lambda model: b"native", + deserialize=lambda data: "native", + extract_claims=lambda model: extracted, + extract_structure=lambda model: structure, + predictor=lambda model: object(), + ) + monkeypatch.setattr(model_io, "find_adapter", lambda model: adapter) + monkeypatch.setattr(model_io, "get_adapter", lambda library: adapter) + schema = { + "schema_version": "1", + "fields": [ + {"kind": "text", "label": name, "mappedTo": name, "required": True} + for name in extracted.feature_names + ], + "reports": reports_for(extracted), + } + samples = [{"a": "é", "b": "😀"}] + training = TrainingDeclaration( + extracted.feature_names, + ["object", "object"], + canonical_dumps(schema), + canonical_dumps(samples), + ) + monkeypatch.setattr(model_io, "prepare_training", lambda *args, **kw: training) + return structure, schema, samples + + +@pytest.mark.parametrize("operation", ["export", "inspect", "load"]) +def test_optional_members_decode_once(native, operation, tmp_path, monkeypatch): + structure, schema, samples = native + destination = tmp_path / "model.mlbundle" + mlbundle.export("native", destination) + decodes = Counter() + encodes = [] + decode = container.decode_json + encode = claims.canonical_dumps + + def counted_decode(data, **kw): + value = decode(data, **kw) + decodes[data] += 1 + return value + + def counted_encode(value, **kw): + if isinstance(value, dict) and value.get("family") == "linear": + encodes.append(value) + return encode(value, **kw) + + monkeypatch.setattr(container, "decode_json", counted_decode) + monkeypatch.setattr(claims, "canonical_dumps", counted_encode) + if operation == "export": + result = mlbundle.export("native", destination, overwrite=True) + else: + result = getattr(mlbundle, operation)(destination) + assert decodes == Counter({canonical_dumps(value): 1 for value in native}) + assert len(encodes) == {"export": 2, "inspect": 0, "load": 1}[operation] + assert result.schema == schema and result.samples == samples + if operation == "load": + assert result.structure == structure + + +@pytest.mark.parametrize( + "changed", + [ + None, + {"family": "linear", "coef": [1, -0.0]}, + {"family": "linear", "coef": [1.0, 0.0]}, + ], +) +def test_reused_comparison_rejects_presence_and_numeric_changes( + native, changed, tmp_path +): + destination = tmp_path / "model.mlbundle" + mlbundle.export("native", destination) + manifest, members = container.read_container(destination) + del members[MemberName.MANIFEST] + members[MemberName.STRUCTURE] = canonical_dumps(changed) + bundle = write_container(manifest, members) + with pytest.raises(mlbundle.ClaimMismatchError, match="structure"): + mlbundle.load(bundle) + + +def test_noncanonical_structure_keeps_original_hash_and_canonical_comparison( + native, tmp_path +): + destination = tmp_path / "model.mlbundle" + mlbundle.export("native", destination) + manifest, members = container.read_container(destination) + del members[MemberName.MANIFEST] + wire = json.dumps(native[0], indent=2).encode() + members[MemberName.STRUCTURE] = wire + bundle = write_container(manifest, members) + loaded = mlbundle.load(bundle) + assert loaded.structure == native[0] + assert container.read_container(bundle)[1][MemberName.STRUCTURE] == wire + assert loaded.manifest.integrity[MemberName.STRUCTURE].size == len(wire) + + +def test_canonical_stored_structure_needs_no_comparison_reencoding( + native, tmp_path, monkeypatch +): + destination = tmp_path / "model.mlbundle" + mlbundle.export("native", destination) + monkeypatch.setattr( + canonical, "_encode_json", lambda *args: pytest.fail("reencoded") + ) + assert mlbundle.load(destination).structure == native[0] From 174385a6bb7af2ac0cc52c0bc76dde45068d0b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 16:26:45 +0200 Subject: [PATCH 13/15] perf: reuse verified tree adapter snapshots (#15) * perf: reuse verified tree adapter snapshots * fix: avoid expanded tree validation buffers --- docs/format-state.md | 16 ++ src/mlbundle/_sklearn/__init__.py | 35 +++- src/mlbundle/_sklearn/payload.py | 25 ++- src/mlbundle/_sklearn/state.py | 54 +++++- src/mlbundle/_sklearn/structure.py | 53 ++++-- src/mlbundle/_xgboost/__init__.py | 20 +- src/mlbundle/_xgboost/native.py | 10 +- src/mlbundle/_xgboost/snapshots.py | 41 +++++ src/mlbundle/_xgboost/structure.py | 44 ++--- src/mlbundle/_xgboost/ubjson.py | 12 +- tests/test_tree_adapter_performance.py | 246 +++++++++++++++++++++++++ 11 files changed, 484 insertions(+), 72 deletions(-) create mode 100644 src/mlbundle/_xgboost/snapshots.py create mode 100644 tests/test_tree_adapter_performance.py diff --git a/docs/format-state.md b/docs/format-state.md index a7254e1..daca194 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -57,6 +57,15 @@ also compares the payload commitment with a fresh skops serialization of the loaded object. Ignored declarations or state that changes during construction are invalid models. Export applies the same rules before publication. +Adapter work belongs to one synchronous export/load, never a global cache. +Sklearn validates and digests each original or freshly reserialized constructed +payload once, then shares those results with claim/structure extraction before +the native model leaves that operation. The received-payload versus constructed- +state comparison remains mandatory; independent public calls start fresh. +Tree constructor checks reuse numeric headers already checked against actual +streamed bytes. Equal digest-only records and scalar encodings +are memoized within that digest, with identical commitment bytes and byte ceilings. + ### Closed scalar and function values `_sklearn/trusted.py` owns these lists for export, projection and load; bundle @@ -213,6 +222,13 @@ categorical splits/default directions and optional dropout weights. Learner base score and objective fields remain verified hyperparameters. Tree dumps alone are insufficient to describe this prediction state. +XGBoost retains bounded, validated immutable UBJSON snapshots within the operation. +Equal payload bytes reuse preflight; structure reuse requires exact native booster +byte-span equality plus feature names/types and the importance setting. Every +native copy and loaded model is still checked through freshly serialized bytes. +Dump JSON is decoded as one byte/depth-bounded array before canonical structure +encoding. Cached canonical structure bytes are decoded afresh for each result. + Booster attribute keys are closed: `mlbundle`, `best_iteration`, `best_score`, `scikit_learn`. The last three are preserved in `hyperparameters.attributes`; the binding is independently checked. Best iteration is a decimal integer with diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index fe04903..083413c 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -27,7 +27,7 @@ from .parameters import parameters from .payload import validate_payload from .prediction import Predictor -from .structure import extract_structure, validate_tree_storage +from .structure import extract_structure, has_structure, validate_tree_storage from .trusted import TRUSTED_TYPES @@ -95,6 +95,18 @@ class Adapter: library = Library.SKLEARN model_member = MemberName.SKOPS + def __init__(self) -> None: + # The registry creates a fresh adapter for every public export/load. + self._states: list[tuple[object, str]] = [] + self._payload: bytes | None = None + self._digest: str | None = None + + def _state(self, model: object) -> str: + for native, digest in self._states: + if native is model: + return digest + return validate_payload(sio.dumps(model)) + def recognizes(self, model: object) -> bool: return ( isinstance(model, BaseEstimator) @@ -140,14 +152,18 @@ def _validate(self, model: Any) -> None: def serialize(self, model: object) -> bytes: try: + self._states = [] + self._payload, self._digest = None, None data = sio.dumps(model) try: - validate_payload(data) + digest = validate_payload(data) except UnsupportedModelError: _untrusted(data) raise _untrusted(data) self._validate(model) + self._states = [(model, digest)] + self._payload, self._digest = data, digest return data except MLBundleError: raise @@ -158,14 +174,19 @@ def serialize(self, model: object) -> bytes: def deserialize(self, data: bytes) -> object: try: - expected_state = validate_payload(data) + expected_state = ( + self._digest if data is self._payload else validate_payload(data) + ) + self._payload, self._digest = None, None _untrusted(data) model = sio.loads(data, trusted=list(TRUSTED_TYPES)) self._validate(model) - if expected_state != validate_payload(sio.dumps(model)): + actual_state = validate_payload(sio.dumps(model)) + if expected_state != actual_state: raise InvalidModelError( "Skops state does not survive native construction." ) + self._states = [*self._states[-1:], (model, actual_state)] return model except MLBundleError: raise @@ -186,9 +207,7 @@ def extract_claims(self, model: Any) -> ModelClaims: estimator_class=type(model).__name__, kind=ModelKind.CLASSIFIER if classifier else ModelKind.REGRESSOR, hyperparameters=parameters(model), - state_sha256=validate_payload(sio.dumps(model)) - if isinstance(model, Pipeline) or extract_structure(model) is None - else None, + state_sha256=self._state(model) if not has_structure(model) else None, n_features=int(model.n_features_in_), n_classes=None if labels is None @@ -212,7 +231,7 @@ def extract_claims(self, model: Any) -> ModelClaims: def extract_structure(self, model: object) -> dict[str, JsonValue] | None: try: self._validate(model) - return extract_structure(model) + return extract_structure(model, state_sha256=self._state(model)) except MLBundleError: raise except Exception: diff --git a/src/mlbundle/_sklearn/payload.py b/src/mlbundle/_sklearn/payload.py index c68d2e0..d560a9a 100644 --- a/src/mlbundle/_sklearn/payload.py +++ b/src/mlbundle/_sklearn/payload.py @@ -70,12 +70,15 @@ def validate_payload(data: bytes) -> str: sparse_sizes[name] = sparse_headers(archive.read(name), remaining) remaining -= sparse_sizes[name] _buffer_budget(references, archive, sparse_sizes) + headers = {} for name in arrays: if name not in names: raise InvalidModelError("Missing skops numeric array member.") - _array_header(archive, name, archive.getinfo(name).file_size) + headers[name] = _array_header( + archive, name, archive.getinfo(name).file_size + ) validate_dtypes(references.nodes, references.resolve, archive) - _tree_constructors(references, archive) + _tree_constructors(references, archive, headers) files = _array_references(schema) | sparse | {"schema.json"} if set(names) != files: raise InvalidModelError("Unreferenced skops member.") @@ -122,7 +125,7 @@ def _buffer_budget( _array_header = array_header -def _tree_constructors(references: References, archive: ZipFile) -> None: +def _tree_constructors(references: References, archive: ZipFile, headers) -> None: seen: set[int] = set() for node in references.nodes: identity = node.get("__id__", id(node)) @@ -137,6 +140,7 @@ def _tree_constructors(references: References, archive: ZipFile) -> None: {**node, "__reduce__": {"args": args}, "content": state}, references.memo, archive, + headers, ) @@ -169,7 +173,7 @@ def _integer(value: JsonValue, memo: dict[int, dict[str, JsonValue]]) -> int: def _numeric_header( - value: JsonValue, memo: dict[int, dict[str, JsonValue]], archive: ZipFile + value: JsonValue, memo: dict[int, dict[str, JsonValue]], headers ) -> tuple[str, tuple[int, ...], np.dtype]: node = _resolve(value, memo) name = node.get("file") @@ -179,12 +183,15 @@ def _numeric_header( or not isinstance(name, str) ): raise InvalidModelError("Invalid skops tree numeric storage.") - shape, dtype = _array_header(archive, name, archive.getinfo(name).file_size) + shape, dtype = headers[name] return name, shape, dtype def _tree_constructor( - node: dict[str, JsonValue], memo: dict[int, dict[str, JsonValue]], archive: ZipFile + node: dict[str, JsonValue], + memo: dict[int, dict[str, JsonValue]], + archive: ZipFile, + headers, ) -> None: reduced = node.get("__reduce__") arguments = reduced.get("args") if isinstance(reduced, dict) else None @@ -200,7 +207,7 @@ def _tree_constructor( width, outputs = _integer(args[0], memo), _integer(args[2], memo) if not 1 <= width <= MAX_FEATURES or not 1 <= outputs <= MAX_OUTPUTS: raise InvalidModelError("Unsafe skops tree constructor dimensions.") - name, shape, dtype = _numeric_header(args[1], memo, archive) + name, shape, dtype = _numeric_header(args[1], memo, headers) if shape != (outputs,) or dtype != np.dtype(np.intp): raise InvalidModelError("Invalid skops tree class-count dimensions.") classes = np.load(BytesIO(archive.read(name)), allow_pickle=False) @@ -216,8 +223,8 @@ def _tree_constructor( raise InvalidModelError("Unknown skops tree state.") count = _integer(attributes.get("node_count"), memo) depth = _integer(attributes.get("max_depth"), memo) - _, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, archive) - _, values_shape, _ = _numeric_header(attributes.get("values"), memo, archive) + _, nodes_shape, _ = _numeric_header(attributes.get("nodes"), memo, headers) + _, values_shape, _ = _numeric_header(attributes.get("values"), memo, headers) if ( count < 1 or not 0 <= depth < count diff --git a/src/mlbundle/_sklearn/state.py b/src/mlbundle/_sklearn/state.py index 688e964..0ebe90a 100644 --- a/src/mlbundle/_sklearn/state.py +++ b/src/mlbundle/_sklearn/state.py @@ -10,7 +10,8 @@ import numpy as np from mlbundle.canonical import canonical_dumps -from mlbundle.errors import InvalidModelError, UnsupportedModelError +from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError +from mlbundle.limits import MANIFEST_BYTES FIELDS = json.loads(files(__package__).joinpath("state_fields.json").read_text()) @@ -41,7 +42,29 @@ def _numeric_digest(array: np.ndarray) -> str: def _hash(domain: bytes, value: Any) -> str: - return hashlib.sha256(domain + canonical_dumps(value)).hexdigest() + if domain == b"scalar:" or (isinstance(value, dict) and "$float" in value): + encoded = canonical_dumps(value) + else: + # These records contain only checked string keys and fixed-width hex digests. + size = ( + sum(len(key) + 70 for key in value) + if isinstance(value, dict) + else len(value) * 67 + if isinstance(value, list) + else 66 + ) + 1 + if size > MANIFEST_BYTES: + raise LimitExceededError("JSON exceeds the byte limit.") + encoded = json.dumps( + value, + sort_keys=True, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > MANIFEST_BYTES: + raise LimitExceededError("JSON exceeds the byte limit.") + return hashlib.sha256(domain + encoded).hexdigest() def validate_state(nodes: list[dict[str, Any]], resolve) -> None: @@ -72,10 +95,28 @@ def validate_state(nodes: list[dict[str, Any]], resolve) -> None: raise InvalidModelError("Sklearn state keys must be strings.") +def _memoized_hash(cache, key, domain: bytes, value) -> str: + if key not in cache: + cache[key] = _hash(domain, value) + return cache[key] + + +def _record_key(value): + if isinstance(value, dict): + return tuple(sorted(value.items())) + return tuple(value) if isinstance(value, list) else value + + def state_digest(schema: Any, archive, resolve) -> str: """Merkle hash all state and buffers, without expanding shared references.""" memo: dict[int, str] = {} buffers: dict[str, str] = {} + scalars: dict[tuple[type, Any], str] = {} + records: dict[tuple, str] = {} + + def record(domain: bytes, value) -> str: + key = (domain, _record_key(value)) + return _memoized_hash(records, key, domain, value) def digest(value: Any) -> str: if isinstance(value, dict): @@ -89,14 +130,15 @@ def digest(value: Any) -> str: if key == "file": if child not in buffers: buffers[child] = _buffer_digest(archive.read(child)) - content[key] = _hash(b"file:", buffers[child]) + content[key] = record(b"file:", buffers[child]) else: content[key] = digest(child) - result = _hash(b"dict:", content) + result = record(b"dict:", content) memo[id(node)] = result return result if isinstance(value, list): - return _hash(b"list:", [digest(child) for child in value]) - return _hash(b"scalar:", value) + return record(b"list:", [digest(child) for child in value]) + key = (type(value), value.hex() if type(value) is float else value) + return _memoized_hash(scalars, key, b"scalar:", value) return digest(schema) diff --git a/src/mlbundle/_sklearn/structure.py b/src/mlbundle/_sklearn/structure.py index c3e96f1..a76ce6f 100644 --- a/src/mlbundle/_sklearn/structure.py +++ b/src/mlbundle/_sklearn/structure.py @@ -1,5 +1,6 @@ """Unmodified native structure, with tree index validation before native traversal.""" +import json import sys from typing import Any, cast @@ -11,7 +12,7 @@ from sklearn.pipeline import Pipeline from sklearn.tree._tree import Tree -from mlbundle.canonical import canonical_value +from mlbundle.canonical import canonical_dumps from mlbundle.errors import InvalidModelError, UnsupportedModelError from mlbundle.limits import MAX_CLASSES, MAX_FEATURES, MAX_OUTPUTS, STRUCTURE_BYTES @@ -52,6 +53,9 @@ def validate_tree(tree: Tree) -> None: raise InvalidModelError("Invalid sklearn tree dimensions.") # Visit each node exactly once: reject cycles, aliases, unreachable nodes and # out-of-range feature/child indices before feature_importances_ or inference. + left_nodes = tree.children_left + right_nodes = tree.children_right + features = tree.feature seen: set[int] = set() pending = [(0, 0)] depth = 0 @@ -61,10 +65,10 @@ def validate_tree(tree: Tree) -> None: raise InvalidModelError("Invalid sklearn tree child indices.") seen.add(node) depth = max(depth, level) - left, right = int(tree.children_left[node]), int(tree.children_right[node]) + left, right = int(left_nodes[node]), int(right_nodes[node]) if left == right == -1: continue - if left < 0 or right < 0 or not 0 <= tree.feature[node] < tree.n_features: + if left < 0 or right < 0 or not 0 <= features[node] < tree.n_features: raise InvalidModelError("Invalid sklearn tree feature or child indices.") pending.extend(((left, level + 1), (right, level + 1))) if len(seen) != count or depth != tree.max_depth: @@ -178,7 +182,30 @@ def _tree(tree: Tree) -> dict[str, object]: } -def extract_structure(model: Any) -> dict[str, JsonValue] | None: +def has_structure(model: Any) -> bool: + if isinstance(model, Pipeline): + return False + if hasattr(model, "tree_"): + return True + if hasattr(model, "estimators_"): + trees = [ + hasattr(estimator, "tree_") + for estimator in np.asarray(model.estimators_, dtype=object).flat + ] + if all(trees): + return True + if any(trees): + raise UnsupportedModelError( + reason="Mixed tree/non-tree ensembles have no supported structure." + ) + return (hasattr(model, "coef_") and hasattr(model, "intercept_")) or getattr( + model, "feature_importances_", None + ) is not None + + +def extract_structure( + model: Any, *, state_sha256: str | None = None +) -> dict[str, JsonValue] | None: # Pipeline internals address transformed inputs; v1 has no transformation # mapping, so it makes no visualization claim for a pipeline. if isinstance(model, Pipeline): @@ -213,13 +240,17 @@ def extract_structure(model: Any) -> dict[str, JsonValue] | None: result["family"] = "feature_importances" return cast( dict[str, JsonValue], - canonical_value( - { - **result, - "state_sha256": validate_payload(sio.dumps(model)), - "prediction_state": prediction_state(model), - }, - max_bytes=STRUCTURE_BYTES, + json.loads( + canonical_dumps( + { + **result, + "state_sha256": state_sha256 + if state_sha256 is not None + else validate_payload(sio.dumps(model)), + "prediction_state": prediction_state(model), + }, + max_bytes=STRUCTURE_BYTES, + ) ), ) diff --git a/src/mlbundle/_xgboost/__init__.py b/src/mlbundle/_xgboost/__init__.py index 5c88cf8..1347501 100644 --- a/src/mlbundle/_xgboost/__init__.py +++ b/src/mlbundle/_xgboost/__init__.py @@ -17,17 +17,10 @@ from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError from mlbundle.limits import HYPERPARAMETER_BYTES -from .native import ( - ATTRIBUTE, - configuration, - dimensions, - parameters, - tree_model, - validate_payload, -) +from .native import ATTRIBUTE, configuration, dimensions, parameters, tree_model from .prediction import Predictor +from .snapshots import Snapshots from .state import attributes -from .structure import extract_structure CLASSES = {"XGBClassifier": xgb.XGBClassifier, "XGBRegressor": xgb.XGBRegressor} @@ -65,6 +58,9 @@ class Adapter: library = Library.XGBOOST model_member = MemberName.UBJSON + def __init__(self) -> None: + self._snapshots = Snapshots() + def recognizes(self, model: object) -> bool: return isinstance(model, (xgb.XGBModel, xgb.Booster)) @@ -165,7 +161,7 @@ def serialize(self, model: Any) -> bytes: try: self._validate(model) data = bytes(model.get_booster().save_raw(raw_format="ubj")) - validate_payload(data) + self._snapshots.payload(data) return data except MLBundleError: raise @@ -176,7 +172,7 @@ def serialize(self, model: Any) -> bytes: def deserialize(self, data: bytes) -> object: try: - validate_payload(data) + self._snapshots.payload(data) booster = xgb.Booster() booster.load_model(bytearray(data)) binding = _binding(booster) @@ -246,7 +242,7 @@ def extract_claims(self, model: Any) -> ModelClaims: def extract_structure(self, model: Any) -> dict[str, JsonValue]: try: self._validate(model) - return extract_structure(model) + return self._snapshots.structure(model) except MLBundleError: raise except Exception: diff --git a/src/mlbundle/_xgboost/native.py b/src/mlbundle/_xgboost/native.py index 5db8f0e..a15e1ad 100644 --- a/src/mlbundle/_xgboost/native.py +++ b/src/mlbundle/_xgboost/native.py @@ -12,7 +12,7 @@ from .arrays import array as _array, tree_indices as _tree_indices from .categories import validate_categories, validate_encodings from .state import attributes, native_keys -from .ubjson import decode +from .ubjson import decode_snapshot ATTRIBUTE = "mlbundle" MAX_TREES = 100_000 @@ -94,12 +94,14 @@ def parameters(learner: dict[str, Any], settings: dict[str, Any]) -> dict[str, A } -def validate_payload(data: bytes) -> None: +def validate_payload(data: bytes) -> tuple[dict[str, Any], memoryview]: if len(data) > ArchiveLimits().max_member_bytes: raise InvalidModelError("XGBoost payload exceeds the byte limit.") if not data.startswith(b"{L\x00\x00\x00\x00\x00\x00\x00\x07learner{"): raise InvalidModelError("Expected a native XGBoost UBJSON model.") - document = decode(data) + document, model_bytes = decode_snapshot(data) + if model_bytes is None: + raise InvalidModelError("Missing XGBoost native model.") native_keys(document) learner = document["learner"] dimensions(learner) @@ -150,6 +152,8 @@ def validate_payload(data: bytes) -> None: "XGBoost categorical bitset bytes exceed the limit." ) + return document, model_bytes + def _iterations(model: dict[str, Any], count: int, learner: dict[str, Any]) -> None: groups = _array(model["tree_info"]) diff --git a/src/mlbundle/_xgboost/snapshots.py b/src/mlbundle/_xgboost/snapshots.py new file mode 100644 index 0000000..f02c7f6 --- /dev/null +++ b/src/mlbundle/_xgboost/snapshots.py @@ -0,0 +1,41 @@ +"""Operation-local immutable native bytes; mutable model identity is never cached.""" + +import json +from typing import Any + +from pydantic import JsonValue + +from .native import validate_payload +from .structure import extract_structure_bytes + + +class Snapshots: + def __init__(self) -> None: + self._payloads: list[tuple[bytes, dict[str, Any], memoryview]] = [] + self._structures: list[tuple[tuple, bytes]] = [] + + def payload(self, data: bytes) -> tuple[dict[str, Any], memoryview]: + for stored, document, model_bytes in self._payloads: + if data == stored: + return document, model_bytes + document, model_bytes = validate_payload(data) + self._payloads = [*self._payloads[-1:], (data, document, model_bytes)] + return document, model_bytes + + def structure(self, model: Any) -> dict[str, JsonValue]: + document, native_bytes = self.payload( + bytes(model.get_booster().save_raw(raw_format="ubj")) + ) + learner = document["learner"] + key = ( + native_bytes, + tuple(learner["feature_names"]), + tuple(learner["feature_types"]), + model.importance_type or "gain", + ) + for stored, encoded in self._structures: + if key == stored: + return json.loads(encoded) + encoded = extract_structure_bytes(model, document=document) + self._structures = [*self._structures[-1:], (key, encoded)] + return json.loads(encoded) diff --git a/src/mlbundle/_xgboost/structure.py b/src/mlbundle/_xgboost/structure.py index 566c20c..5a4809f 100644 --- a/src/mlbundle/_xgboost/structure.py +++ b/src/mlbundle/_xgboost/structure.py @@ -1,45 +1,47 @@ """All native tree dump fields and wrapper feature importances, without truncation.""" +import json from typing import Any, cast from pydantic import JsonValue -from mlbundle.canonical import canonical_loads, canonical_value +from mlbundle.canonical import canonical_dumps, canonical_loads from mlbundle.errors import LimitExceededError from mlbundle.limits import STRUCTURE_BYTES from .native import MAX_TREES, configuration, tree_model, validate_payload from .state import projection -from .ubjson import decode -def extract_structure(model: Any) -> dict[str, JsonValue]: +def extract_structure_bytes( + model: Any, *, document: dict[str, Any] | None = None +) -> bytes: booster = model.get_booster() native = tree_model(configuration(booster)) count = int(native["gbtree_model_param"]["num_trees"]) if not 1 <= count <= MAX_TREES: raise LimitExceededError("XGBoost tree count exceeds the limit.") - data = bytes(booster.save_raw(raw_format="ubj")) - validate_payload(data) - native_model = decode(data)["learner"]["gradient_booster"]["model"] + if document is None: + document, _ = validate_payload(bytes(booster.save_raw(raw_format="ubj"))) + native_model = document["learner"]["gradient_booster"]["model"] dumps = booster.get_dump(dump_format="json", with_stats=True) if ( len(dumps) != count - or sum(len(tree.encode()) for tree in dumps) > STRUCTURE_BYTES + or sum(len(tree.encode()) for tree in dumps) + len(dumps) + 1 > STRUCTURE_BYTES ): raise LimitExceededError("XGBoost structure exceeds the limit.") - return cast( - dict[str, JsonValue], - canonical_value( - { - "family": "xgboost", - "native_model": projection(native_model), - "trees": [ - canonical_loads(tree.encode(), max_bytes=STRUCTURE_BYTES) - for tree in dumps - ], - "feature_importances": model.feature_importances_, - }, - max_bytes=STRUCTURE_BYTES, - ), + return canonical_dumps( + { + "family": "xgboost", + "native_model": projection(native_model), + "trees": canonical_loads( + ("[" + ",".join(dumps) + "]").encode(), max_bytes=STRUCTURE_BYTES + ), + "feature_importances": model.feature_importances_, + }, + max_bytes=STRUCTURE_BYTES, ) + + +def extract_structure(model: Any) -> dict[str, JsonValue]: + return cast(dict[str, JsonValue], json.loads(extract_structure_bytes(model))) diff --git a/src/mlbundle/_xgboost/ubjson.py b/src/mlbundle/_xgboost/ubjson.py index 0f3f6b3..5946025 100644 --- a/src/mlbundle/_xgboost/ubjson.py +++ b/src/mlbundle/_xgboost/ubjson.py @@ -21,6 +21,7 @@ def __init__(self, data: bytes) -> None: self.data = memoryview(data) self.position = 0 self.values = 0 + self.model_bytes: memoryview | None = None def take(self, count: int) -> memoryview: if count < 0 or count > len(self.data) - self.position: @@ -92,7 +93,10 @@ def container(self, marker: str, depth: int) -> Any: if index >= 100_000: raise InvalidModelError("UBJSON object collection exceeds the limit.") key = self.string() if marker == "{" else None + start = self.position value = self.value(depth, typed) + if depth == 2 and key == "gradient_booster": + self.model_bytes = self.data[start : self.position] if marker == "{": if key in result: raise InvalidModelError("Duplicate UBJSON object key.") @@ -103,9 +107,13 @@ def container(self, marker: str, depth: int) -> Any: return result -def decode(data: bytes) -> dict[str, Any]: +def decode_snapshot(data: bytes) -> tuple[dict[str, Any], memoryview | None]: decoder = Decoder(data) value = decoder.value() if not isinstance(value, dict) or decoder.position != len(data): raise InvalidModelError("Invalid UBJSON model document.") - return value + return value, decoder.model_bytes + + +def decode(data: bytes) -> dict[str, Any]: + return decode_snapshot(data)[0] diff --git a/tests/test_tree_adapter_performance.py b/tests/test_tree_adapter_performance.py new file mode 100644 index 0000000..afec1b2 --- /dev/null +++ b/tests/test_tree_adapter_performance.py @@ -0,0 +1,246 @@ +"""Fast adapter paths retain the legacy commitment, checks and native snapshots.""" + +import hashlib +import io +import json +from zipfile import ZipFile + +import numpy as np +import pytest +import skops.io as sio +import xgboost as xgb +from sklearn.ensemble import ( + ExtraTreesClassifier, + GradientBoostingClassifier, + RandomForestClassifier, + RandomForestRegressor, +) +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from mlbundle import InvalidModelError, LimitExceededError +from mlbundle._sklearn import Adapter as SklearnAdapter, state +from mlbundle._sklearn.payload import validate_payload +from mlbundle._sklearn.references import References +from mlbundle._xgboost import Adapter as XGBoostAdapter +from mlbundle._xgboost.state import projection +from mlbundle._xgboost.ubjson import decode +from mlbundle.canonical import canonical_dumps, canonical_value +from mlbundle.declarations import export_declarations +from mlbundle.limits import MANIFEST_BYTES, STRUCTURE_BYTES + + +def _legacy_digest(data): + with ZipFile(io.BytesIO(data)) as archive: + schema = json.loads(archive.read("schema.json")) + references = References(schema) + memo, buffers = {}, {} + + def hashed(domain, value): + return hashlib.sha256(domain + canonical_dumps(value)).hexdigest() + + def digest(value): + if isinstance(value, dict): + node = references.resolve(value) if "__loader__" in value else value + if id(node) in memo: + return memo[id(node)] + content = {} + for key, child in node.items(): + if key == "__id__": + continue + if key == "file": + if child not in buffers: + buffers[child] = state._buffer_digest(archive.read(child)) + content[key] = hashed(b"file:", buffers[child]) + else: + content[key] = digest(child) + result = hashed(b"dict:", content) + memo[id(node)] = result + return result + if isinstance(value, list): + return hashed(b"list:", [digest(child) for child in value]) + return hashed(b"scalar:", value) + + return digest(schema) + + +@pytest.mark.parametrize( + "model", + [ + DecisionTreeClassifier(max_depth=2), + RandomForestClassifier(n_estimators=3, max_depth=2, random_state=1), + ExtraTreesClassifier(n_estimators=3, max_depth=2, random_state=1), + GradientBoostingClassifier(n_estimators=3, max_depth=2, random_state=1), + RandomForestRegressor(n_estimators=3, max_depth=2, random_state=1), + make_pipeline( + StandardScaler(), + RandomForestClassifier(n_estimators=3, max_depth=2, random_state=1), + ), + ], +) +def test_legacy_merkle_bytes_and_round_trip(model): + x = np.random.default_rng(1).normal(size=(30, 3)) + model.fit(x, np.arange(30) % 2) + data = sio.dumps(model) + assert validate_payload(data) == _legacy_digest(data) + adapter = SklearnAdapter() + serialized = adapter.serialize(model) + restored = adapter.deserialize(serialized) + assert adapter.extract_claims(model) == adapter.extract_claims(restored) + assert adapter.extract_structure(model) == adapter.extract_structure(restored) + + +@pytest.mark.parametrize("value", [True, 1, 1.0, 0.0, -0.0, None, "é", "a\n\\b"]) +def test_scalar_encoding_preserves_types_and_signed_zero(value): + data = sio.dumps([value, value]) + assert validate_payload(data) == _legacy_digest(data) + + +@pytest.mark.parametrize( + "domain,value", + [(b"dict:", {'é"': "a" * 64}), (b"list:", ["b" * 64] * 3), (b"file:", "c" * 64)], +) +def test_digest_records_match_canonical_bytes(domain, value): + assert ( + state._hash(domain, value) + == hashlib.sha256(domain + canonical_dumps(value)).hexdigest() + ) + + +def test_digest_record_byte_limit_is_unchanged(): + with pytest.raises(LimitExceededError): + state._hash(b"list:", ["a" * 64] * (MANIFEST_BYTES // 67 + 1)) + + +def test_digest_dictionary_bound_precedes_encoding(monkeypatch): + value = {"x": "a" * 64} + monkeypatch.setattr(state, "MANIFEST_BYTES", len(canonical_dumps(value)) - 1) + + def never_encode(*args, **kwargs): + pytest.fail("Over-budget digest records must fail before encoding") + + monkeypatch.setattr(state.json, "dumps", never_encode) + with pytest.raises(LimitExceededError): + state._hash(b"dict:", value) + + +@pytest.mark.parametrize("pipeline", [False, True]) +def test_sklearn_payload_checked_once_per_native_snapshot( + pipeline, monkeypatch, tmp_path +): + import mlbundle._sklearn as sklearn_adapter + from mlbundle._sklearn import structure + + model = RandomForestClassifier(n_estimators=2, max_depth=2, random_state=1) + if pipeline: + model = make_pipeline(StandardScaler(), model) + model.fit([[0], [1], [2], [3]], [0, 0, 1, 1]) + calls, structures = [], [] + validate, extract = sklearn_adapter.validate_payload, structure.extract_structure + + def checked(data): + calls.append(data) + return validate(data) + + def extracted(*args, **kwargs): + structures.append(True) + return extract(*args, **kwargs) + + monkeypatch.setattr(sklearn_adapter, "validate_payload", checked) + monkeypatch.setattr(sklearn_adapter, "extract_structure", extracted) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + assert len(calls) == 2 + assert len(structures) == 2 + calls.clear() + structures.clear() + mlbundle.load(path) + assert len(calls) == 2 + assert len(structures) == 1 + # Another public operation must not trust state retained from a prior one. + model.extra_ = 1 + with pytest.raises(InvalidModelError, match="Unknown sklearn estimator state"): + mlbundle.export(model, path, overwrite=True) + + +def test_reused_sklearn_adapter_rechecks_new_payload_and_serialization(): + model = DecisionTreeClassifier(max_depth=2).fit([[0], [1]], [0, 1]) + adapter = SklearnAdapter() + adapter.serialize(model) + model.extra_ = 1 + with pytest.raises(InvalidModelError, match="Unknown sklearn estimator state"): + adapter.serialize(model) + with pytest.raises(InvalidModelError, match="Unknown sklearn estimator state"): + adapter.deserialize(sio.dumps(model)) + + +def _xgb_structure(model): + return canonical_value( + { + "family": "xgboost", + "native_model": projection( + decode(bytes(model.get_booster().save_raw(raw_format="ubj")))[ + "learner" + ]["gradient_booster"]["model"] + ), + "trees": [ + json.loads(tree) + for tree in model.get_booster().get_dump( + dump_format="json", with_stats=True + ) + ], + "feature_importances": model.feature_importances_, + }, + max_bytes=STRUCTURE_BYTES, + ) + + +def test_xgboost_immutable_cache_keys_and_unexposed_values(): + model = xgb.XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit( + [[0, 1], [1, 0], [2, 1], [3, 0]], [0, 0, 1, 1] + ) + adapter = XGBoostAdapter() + prepared = adapter.prepare_export(model, export_declarations(None)) + assert adapter.extract_structure(prepared) == _xgb_structure(prepared) + structure = adapter.extract_structure(prepared) + structure["trees"].clear() + assert adapter.extract_structure(prepared) == _xgb_structure(prepared) + prepared.importance_type = "weight" + assert adapter.extract_structure(prepared) == _xgb_structure(prepared) + prepared.get_booster().feature_names = ["first", "second"] + assert adapter.extract_structure(prepared) == _xgb_structure(prepared) + prepared.get_booster().set_attr(extra="bad") + with pytest.raises(InvalidModelError, match="Unknown XGBoost booster attribute"): + adapter.extract_structure(prepared) + + +def test_xgboost_one_structure_and_two_payload_checks_per_export(monkeypatch, tmp_path): + from mlbundle._xgboost import snapshots + + model = xgb.XGBRegressor(n_estimators=3, max_depth=2, n_jobs=1).fit( + [[0], [1], [2], [3]], [0, 0, 1, 1] + ) + calls, structures = [], [] + validate, extract = snapshots.validate_payload, snapshots.extract_structure_bytes + + def checked(data): + calls.append(data) + return validate(data) + + def extracted(*args, **kwargs): + structures.append(True) + return extract(*args, **kwargs) + + monkeypatch.setattr(snapshots, "validate_payload", checked) + monkeypatch.setattr(snapshots, "extract_structure_bytes", extracted) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + assert len(calls) == 2 # Original unbound bytes and copied bound bytes. + assert len(structures) == 1 + calls.clear() + structures.clear() + mlbundle.load(path) + assert len(calls) == 1 + assert len(structures) == 1 From 0981a59e6aee5a6b0e87762c52eca970859d2b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Mon, 12 Oct 2026 16:51:36 +0200 Subject: [PATCH 14/15] feat: support composite native pipelines (#16) * feat: add composite native pipelines * fix(cli): group pipeline metadata before members --- AGENTS.md | 16 +- README.md | 127 +++++----- SECURITY.md | 17 +- docs/format-adapters.md | 15 +- docs/format-catboost.md | 10 +- docs/format-container.md | 9 +- docs/format-pipeline.md | 224 ++++++++++++++++++ docs/format-schema.md | 6 +- docs/format-state.md | 9 +- docs/format.md | 24 +- pyproject.toml | 2 + scripts/package_smoke.py | 40 ++++ src/mlbundle/_composite/__init__.py | 1 + src/mlbundle/_composite/claims.py | 133 +++++++++++ src/mlbundle/_composite/io.py | 158 +++++++++++++ src/mlbundle/_composite/prediction.py | 77 ++++++ src/mlbundle/_container_rules.py | 8 +- src/mlbundle/_export_manifest.py | 78 +++++++ src/mlbundle/_native_io.py | 53 +++++ src/mlbundle/_pipeline_contract.py | 104 +++++++++ src/mlbundle/_sklearn/__init__.py | 72 +----- src/mlbundle/_sklearn/_width_checks.py | 59 +++++ src/mlbundle/_sklearn/column_width.py | 198 ++++++++++++++++ src/mlbundle/_sklearn/encoder_width.py | 123 ++++++++++ src/mlbundle/_sklearn/pipeline_scope.py | 141 +++++++++++ src/mlbundle/_sklearn/polynomial_names.py | 31 +++ src/mlbundle/_sklearn/serialization.py | 80 +++++++ src/mlbundle/_sklearn/transform_names.py | 111 +++++++++ src/mlbundle/_sklearn/transform_width.py | 270 ++++++++++++++++++++++ src/mlbundle/_sklearn/transformers.py | 69 ++++++ src/mlbundle/cli.py | 23 +- src/mlbundle/contract.py | 1 + src/mlbundle/limits.py | 2 +- src/mlbundle/manifest.py | 46 +++- src/mlbundle/model_io.py | 175 +++++--------- src/mlbundle/registry.py | 18 ++ src/mlbundle/writing.py | 6 +- tests/pipeline_helpers.py | 43 ++++ tests/test_catboost_validation.py | 10 +- tests/test_limits.py | 4 +- tests/test_manifest.py | 1 + tests/test_pipeline.py | 260 +++++++++++++++++++++ tests/test_pipeline_names.py | 194 ++++++++++++++++ tests/test_pipeline_scope.py | 175 ++++++++++++++ tests/test_pipeline_tampering.py | 252 ++++++++++++++++++++ tests/test_pipeline_width.py | 263 +++++++++++++++++++++ tests/test_readme.py | 6 +- tests/test_sklearn.py | 12 +- 48 files changed, 3445 insertions(+), 311 deletions(-) create mode 100644 docs/format-pipeline.md create mode 100644 src/mlbundle/_composite/__init__.py create mode 100644 src/mlbundle/_composite/claims.py create mode 100644 src/mlbundle/_composite/io.py create mode 100644 src/mlbundle/_composite/prediction.py create mode 100644 src/mlbundle/_export_manifest.py create mode 100644 src/mlbundle/_native_io.py create mode 100644 src/mlbundle/_pipeline_contract.py create mode 100644 src/mlbundle/_sklearn/_width_checks.py create mode 100644 src/mlbundle/_sklearn/column_width.py create mode 100644 src/mlbundle/_sklearn/encoder_width.py create mode 100644 src/mlbundle/_sklearn/pipeline_scope.py create mode 100644 src/mlbundle/_sklearn/polynomial_names.py create mode 100644 src/mlbundle/_sklearn/serialization.py create mode 100644 src/mlbundle/_sklearn/transform_names.py create mode 100644 src/mlbundle/_sklearn/transform_width.py create mode 100644 src/mlbundle/_sklearn/transformers.py create mode 100644 tests/pipeline_helpers.py create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_pipeline_names.py create mode 100644 tests/test_pipeline_scope.py create mode 100644 tests/test_pipeline_tampering.py create mode 100644 tests/test_pipeline_width.py diff --git a/AGENTS.md b/AGENTS.md index c006fe9..f90c248 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,11 +218,11 @@ claims. `native_model` is an OnnxModel with original bytes and a CPU session. Kind is inferred from supported outputs; native labels are verified, absent labels use count-checked declarations. Structure is null. See the normative ONNX section linked from `docs/format.md` for exact layouts and resource bounds. -CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. Sklearn pipelines containing XGBoost/CatBoost estimators are rejected; export the estimator directly. -The closed member list is: required `manifest.json`, exactly -one of `model.skops`, `model.ubj`, `model.cbm` or `model.onnx`, optional `schema.json`, and +CatBoost supports exact numeric single-target symmetric classifiers/regressors, with native state commitments and in-memory loading. Exact outer sklearn Pipelines ending in XGBoost/CatBoost use separately checked skops/native components; other native placements remain rejected. +The closed member list is: required `manifest.json`, one +native member, or declared `model.skops` + `model.ubj`/`model.cbm` pair, optional `schema.json`, and `structure.json` when the supported model has extractable structure. Optional -`samples.json` requires explicit user intent. No other members are allowed. +`samples.json` requires explicit user intent. The ceiling is six members; no others are allowed. The manifest must record format name/version, bundle id, UTC creation time and writer version; model library, estimator class, classifier/regressor kind, @@ -261,11 +261,12 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/contract.py` — closed identifiers and native member/format pairing. - `src/mlbundle/limits.py` — shared archive ceilings and specific member caps. - `src/mlbundle/canonical.py` and `_canonical_work.py` — deterministic JSON and bounded decoding/expansion. -- `src/mlbundle/model_io.py` — library-independent public export/load, manifest construction and round-trip verification. +- `src/mlbundle/model_io.py`, `_native_io.py`, `_export_manifest.py` — public I/O, native round trips and manifest construction. +- `src/mlbundle/_composite/`, `_pipeline_contract.py` — strict recipe, separate I/O, closed reconstruction/claims/prediction and input/inner boundaries. Read `docs/format-pipeline.md` when changing composites. - `src/mlbundle/training.py` — lazy local DataFrame alignment and schema inference. - `src/mlbundle/schema.py` and `_schema_fields.py` — closed schema/report declarations and reader cross-checks without mlschema. - `src/mlbundle/samples.py` — explicit feature-row selection and bounded cell conversion. -- `src/mlbundle/_sklearn/` — closed dtype/type/function trust, skops loading, MLP state, parameter/claim extraction, native structure, nested payload bounds and fitted categorical prediction. +- `src/mlbundle/_sklearn/` — owned trust/skops/state/parameters/structure, transformer route and closed fitted-width/name proofs. Composite commitments/native structure bind the rebuilt Pipeline; all-sklearn structure stays null. - `src/mlbundle/_xgboost/` — closed wrappers/objectives, native UBJSON preflight/loading, label binding, recoverable config, complete tree structure and validated prediction. - `src/mlbundle/_catboost/` — closed numeric symmetric wrappers/losses, CBM preflight/in-memory loading, complete native state/structure and validated prediction. - `src/mlbundle/_onnx/` — bounded protobuf/graph validation, unchanged bytes, native signatures/labels, CPU sessions and tabular prediction. @@ -276,13 +277,12 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/environment.py` — training declarations and distribution metadata capture. - `src/mlbundle/adapters.py` and `_prediction_input.py` — adapter/predictor protocols, output types and shared input checks. - `src/mlbundle/registry.py` — closed lazy resolution of `_sklearn`, `_xgboost`, `_catboost` and `_onnx` adapters. -- `src/mlbundle/results.py` — metadata-only and loaded reader result types. - `src/mlbundle/_container_rules.py` — shared source type, closed member sets/order and ceilings. - `src/mlbundle/_zip_directory.py` — bounded ZIP/ZIP64 directory and extra-field validation. - `src/mlbundle/_zip_members.py` — local headers/descriptors and bounded decompression with CRC. - `src/mlbundle/container.py` — source bounds, validated in-memory reading and integrity checks. - `src/mlbundle/writing.py` — integrity ownership, deterministic ZIP and atomic publication. -- `src/mlbundle/inspection.py` — public metadata-only inspection without adapter resolution. +- `src/mlbundle/inspection.py`, `results.py` — metadata-only inspection and result records without adapter resolution. - `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. - `scripts/package_smoke.py` — fixture, isolated install and artifact-content checks. diff --git a/README.md b/README.md index 6f8f25d..f2dbd77 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,8 @@ from sklearn.tree import DecisionTreeClassifier from mlbundle import export, inspect, load rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] -model = DecisionTreeClassifier(random_state=7).fit( - rows, ["cold", "warm", "cold", "warm"] -) +model = DecisionTreeClassifier(random_state=7).fit(rows, ["cold", "warm"] * 2) export(model, "trained.mlbundle", feature_names=["temperature", "humidity"]) - bundle = load("trained.mlbundle") assert isinstance(bundle.native_model, DecisionTreeClassifier) assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() @@ -63,12 +60,7 @@ from mlbundle import export, load rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] model = XGBClassifier(n_estimators=3, max_depth=2, n_jobs=1).fit(rows, [0, 1, 0, 1]) -export( - model, - "xgboost.mlbundle", - feature_names=["temperature", "humidity"], - class_labels=[["cold", "warm"]], -) +export(model, "xgboost.mlbundle", class_labels=[["cold", "warm"]]) bundle = load("xgboost.mlbundle") assert isinstance(bundle.native_model, XGBClassifier) assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() @@ -76,10 +68,10 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() assert bundle.class_labels == [["cold", "warm"]] ``` -The adapter binds labels/settings to a native **copy**, leaving your model -unchanged. Load verifies that binding, not historical truth. Omitted labels use -encoded indices or an existing binding; native `predict` still returns indices. -Sklearn, CatBoost and regressors reject `class_labels=`. +Labels/settings bind to a native **copy**. Load verifies consistency, not history. +Omitted labels use encoded indices/an existing binding; native predict stays encoded. +Standalone sklearn, CatBoost and regressors reject `class_labels=`; composites +delegate labels to the final native adapter. Only recoverable native configuration is recorded; learning rate, max depth, seed and thread count do not survive native XGBoost I/O and are not reconstructed. @@ -103,14 +95,45 @@ assert bundle.class_labels == [["cold", "warm"]] assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() ``` -Export a pipeline's estimator as `export(pipeline.named_steps["est"], ...)`. -CatBoost pipelines, `class_labels=`, categorical/text/embedding features, +`class_labels=`, categorical/text/embedding features, Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances are verified on load. [The CatBoost contract](docs/format-catboost.md) details wrapper recovery, native-parser limits and the private in-memory serializer. +## Composite pipelines + +Install `[sklearn,xgboost]` or `[sklearn,catboost]`. Export the fitted Pipeline +as it is; transformers use skops and the final estimator uses its native adapter: + +```python +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import StandardScaler +from xgboost import XGBClassifier +from mlbundle import export, load + +rows = [[0.0, 1.0], [1.0, 0.0], [0.1, 0.9], [0.9, 0.1]] +model = make_pipeline(StandardScaler(), XGBClassifier(n_estimators=3, n_jobs=1)) +model.fit(rows, [0, 1, 0, 1]) +export(model, "pipeline.mlbundle", class_labels=[["cold", "warm"]]) +bundle = load("pipeline.mlbundle") +assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() +assert bundle.native_model.predict(rows).tolist() == model.predict(rows).tolist() +assert bundle.class_labels == [["cold", "warm"]] +``` + +Load rebuilds a real Pipeline after verifying both parts. Bundle probability labels +use XGBoost's bound originals; native classes/predict stay encoded. CatBoost keeps +native labels. Prefixes include scalers, PCA, SimpleImputer (indicators/empty columns), +KBinsDiscretizer, PolynomialFeatures, univariate selectors, OneHotEncoder/OrdinalEncoder, +identity/closed NumPy FunctionTransformer, nested native-free Pipeline and ColumnTransformer. +Each needs a fitted, data-free width proof. Unknown widths, FeatureUnion/KNNImputer, +slice/callable/scalar selectors, caching, metadata routing and other native placements +remain rejected. Step names/outer options are declarations; trees address transformed +features, without original-feature attribution. Schema/dtypes/samples describe original +inputs; six members permit schema plus explicit samples. See [the pipeline contract](docs/format-pipeline.md). + ## Existing ONNX files Install the `onnx` extra. This example assumes your existing `trained.onnx` is @@ -148,11 +171,8 @@ from sklearn.tree import DecisionTreeClassifier from mlbundle import export, inspect, load training = pd.DataFrame( - { - "temperature": [0.0, 1.0, 0.1, 0.9], - "humidity": [1.0, 0.0, 0.9, 0.1], - "target": ["cold", "warm", "cold", "warm"], - } + [[0.0, 1.0, "cold"], [1.0, 0.0, "warm"]] * 2, + columns=["temperature", "humidity", "target"], ) features = training.drop(columns="target") model = DecisionTreeClassifier(random_state=7).fit(features, training["target"]) @@ -162,15 +182,8 @@ assert view.manifest.features.dtypes == ["float64", "float64"] assert view.schema["reports"][0]["labels"] == ["cold", "warm"] assert view.samples is None assert load("schema.mlbundle").schema == view.schema - # Samples are real data: explicitly include the first two feature rows. -export( - model, - "sampled.mlbundle", - training_data=training, - target_name="target", - sample_rows=2, -) +export(model, "sampled.mlbundle", training_data=features, sample_rows=2) assert inspect("sampled.mlbundle").samples == features.head(2).to_dict("records") ``` @@ -196,8 +209,6 @@ metadata or structure matches the model. `load` additionally deserializes throug the closed adapter and recomputes native claims and structure before returning the model and predictor. Export checks the same native round trip before writing. -After the sklearn example above, these commands work: - ```shell mlbundle inspect trained.mlbundle mlbundle inspect trained.mlbundle --json @@ -212,61 +223,45 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. ## Supported models and limits -Support requires a faithful closed-adapter round trip; a catalog entry does not guarantee every fitted configuration exports. - - **Sklearn:** fitted classifiers exposing `predict_proba` and regressors with recoverable feature/output counts. Tested families include trees/ensembles, linear/regularized/robust/GLM models, SVMs, PLSRegression, naive Bayes, LDA/QDA, dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. - MLPClassifier/Regressor (adam, sgd, lbfgs) retain optimizer state, alone/in pipelines, with null structure and complete commitments; see [MLP tests](tests/test_sklearn_mlp.py). SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, univariate selectors and closed NumPy FunctionTransformer functions round-trip, including nested ColumnTransformer pipelines; see [the exact lists](docs/format-state.md). - Pipeline structure stays null because format 1 does not map transformed features. See + All-sklearn Pipeline structure stays null; composite trees expose the inner boundary. See [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). - **Sklearn rejections:** custom estimators/subclasses, functions/types outside the closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without - probabilities, multilabel/ClassifierChain layouts, XGBoost/CatBoost pipeline steps, + probabilities, multilabel/ClassifierChain layouts, unsupported native placements, and untrusted nested storage such as fitted sparse neighbor arrays. HistGradientBoosting uses untrusted `TreePredictor` storage and is rejected. - **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. - Unknown classes/attributes fail export/load. The committed sklearn version - must match at export/load; other matching-version configurations can work - when they satisfy the catalog. No permissive fallback or advertised broader - range. See [state ownership](docs/format-state.md). -- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native `gbtree` - storage (including XGBoost 3.4.1 `dart` dropout normalized to gbtree). - Classifiers: `binary:logistic`, `multi:softprob`. Regressors: - `reg:squarederror`, `reg:absoluteerror`, `reg:pseudohubererror`, - `reg:squaredlogerror`, `reg:logistic`, `count:poisson`, `reg:gamma`, - `reg:tweedie`. Raw boosters, RF wrappers, subclasses, gblinear, legacy dart - storage, ranking/survival/custom objectives and multiple targets are rejected. - At most 100,000 trees and depth 14; exact native state and complete dumps are - required. See [the XGBoost mapping](docs/format-adapters.md#implemented-xgboost-mapping). -- **ONNX:** rank-two numeric matrix or width-one inputs, fixed width, - float32/float64/int32/int64; dynamic or one-row batches. One target: float - regression `[batch]`/`[batch,1]` or tensor/ZipMap probabilities, optional labels. - Classification requires native graph evidence. Ambiguous/image/string layouts, - larger fixed batches, multiple targets, external tensors, custom domains/local - functions and training/runtime configuration fail before CPU sessions. - Tested with ONNX 1.23.0/Runtime 1.30.0; operators/opsets must pass both. - Large unpacked ensembles/ZipMap and multiline producer versions work. One wire - validation per export/load; field/scalar ceilings follow 512 MiB, producer strings - allow 65,536 characters. See [ONNX bounds](docs/format-onnx.md). - -Archive ceilings: **5 members**, **512 MiB** archive/compressed/member bytes, + Unknown state fails; export/load sklearn versions must match. Configurations + must satisfy the catalog and round trip; see [state ownership](docs/format-state.md). +- **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native gbtree + (including XGBoost 3.4.1 dart normalized to gbtree). Closed probability/regression + objectives; raw boosters, RF wrappers, subclasses, ranking/survival/custom + objectives and unsupported storage are rejected. At most 100,000 trees/depth 14, + with complete state/dumps; see [the mapping](docs/format-adapters.md#implemented-xgboost-mapping). +- **ONNX:** fixed-width rank-two numeric inputs; one regression/probability target, + optional native labels and dynamic/one-row batches. Unsupported layouts, + external data/custom domains/functions and runtime/training configuration fail + before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](docs/format-onnx.md). + +Archive ceilings: **6 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). -Callers may lower `ArchiveLimits`, never raise these ceilings. Native work has -additional [state](docs/format-state.md) and [ONNX](docs/format-onnx.md) bounds; -these byte budgets do not cap total process memory, prediction rows or CPU. +Callers may only lower `ArchiveLimits`. Native [state](docs/format-state.md)/[ONNX](docs/format-onnx.md) +bounds do not cap process memory, prediction rows or CPU. ## Bundle contents and security -Every bundle contains `manifest.json` and exactly one of `model.skops`, -`model.ubj`, `model.cbm`, `model.onnx`. Optional members are `schema.json`, `structure.json` +Every bundle contains `manifest.json` and one native member, or the declared +`model.skops` + `model.ubj`/`model.cbm` composite pair. Single-step composites use only the leaf. Optional members are `schema.json`, `structure.json` (required when the adapter extracts structure) and explicitly requested `samples.json`. No training DataFrame or arbitrary files are included. The manifest records identity/time/writer, model parameters/counts/state, diff --git a/SECURITY.md b/SECURITY.md index 300a081..93c422c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -101,9 +101,16 @@ make the native parser a sandbox. The installed skops load path builds nodes, audits them and only then constructs objects; NumPy buffers use `allow_pickle=False`. No pickle/joblib loading fallback is used. Skops imports `pickle` only to recognize an unrelated save-side exception; -this does not deserialize pickle bytes. Custom estimators/functions and XGBoost/CatBoost -estimators inside sklearn pipelines are rejected. CatBoost rejections name the unsupported -type and advise exporting the estimator directly; neither adapter unwraps pipelines. HistGradientBoosting's separate +this does not deserialize pickle bytes. Custom estimators/functions and unsupported +native placements are rejected. Exact outer Pipelines ending in an admitted native +wrapper split transformer skops bytes from UBJSON/CBM, retaining each adapter's +preflight, trust, state/claims and structure checks; no booster type enters skops. +Closed fitted-state width proofs never use data. Shared bounded names/parameters/ +commitments and inner tree coordinates are recomputed before closed Pipeline +reconstruction is returned. Cache/metadata routing/unknown outer state fail; +step names and outer options are declarations, not authenticated training history. +See [the composite contract](docs/format-pipeline.md). +HistGradientBoosting's separate `TreePredictor` storage is also excluded. Broadening the supplemental list requires source review and hostile-payload tests, never author approval or bundle metadata. @@ -111,8 +118,8 @@ Both export and load recompute the same model claims and structures and compare canonical bytes, preserving class-label types and order. Export additionally compares the original and reloaded native models before publication. Loaded results contain the native model for runtime explanations; mutating it invalidates the -relationship to the verified metadata. Pipeline structure is null because v1 -does not describe its input transformations. +relationship to the verified metadata. All-sklearn Pipeline structure stays null; +composites expose checked native trees in transformed feature coordinates. The closed sklearn class/attribute catalog is audited against 1.9.1 with skops 0.16.0. Other versions/configurations can be rejected for unknown state. The diff --git a/docs/format-adapters.md b/docs/format-adapters.md index 0b50d30..986a3be 100644 --- a/docs/format-adapters.md +++ b/docs/format-adapters.md @@ -4,6 +4,7 @@ Normative sections of [the format contract](format.md). ## `structure.json` +Composites add only the checked `pipeline` boundary sibling defined in [pipelines](format-pipeline.md); native indices address transformed features. Structure is never embedded in the manifest. It is a JSON object with required `family` and `feature_importances` fields, plus exactly the family fields below. `feature_importances` is null or an ordered numeric array of length `n_features`. @@ -40,7 +41,7 @@ ensemble. Unsupported or oversized mandatory structure fails export rather than being silently omitted. ONNX graph visualizations and additional structure families require a deliberate future contract change. -Sklearn `Pipeline` returns null structure in version 1. Its final estimator +All-scikit-learn `Pipeline` returns null structure in version 1. Its final estimator operates on transformed features; version 1 has no input transformation mapping, so no final-step tree or coefficient member is emitted. Claims, names and predictions still describe the complete pipeline; `model.state_sha256` also @@ -91,11 +92,13 @@ the exact score/NumPy function names in [the state contract](format-state.md). It adds no arbitrary class, callable or I/O constructor. Skops' own defaults remain part of the dependency trust boundary. Dumped bytes are audited with `get_untrusted_types`; every type outside the list is rejected -at export and independently at load. XGBoost estimators inside sklearn pipelines -are rejected (`xgboost.core.Booster`, `xgboost.sklearn.XGBClassifier` are untrusted). -CatBoost pipeline steps are also rejected, with unsupported types listed and -an instruction to export `pipeline.named_steps["est"]` directly. Native adapters -own standalone XGBoost and CatBoost loading; neither is implicitly unwrapped. +at export and independently at load. Exact outer Pipelines ending in a supported +XGBoost/CatBoost wrapper use the [composite route](format-pipeline.md): prefix-only +skops, native leaf bytes through the unchanged preflight/adapter, and closed Pipeline +reconstruction. Transformer parameters/state, data-free widths/names, leaf claims +and extended native structure are recomputed. Other native placements fail with +bounded unsupported types. Native adapters never receive skops-contained booster +state, and their trusted types stay excluded. All-sklearn pipelines stay unchanged. MLPClassifier/MLPRegressor support adam, sgd and lbfgs, alone or in a Pipeline. MLP structure is null; layer weights and complete fitted/optimizer state are bound by the manifest state commitment. Optimizers permit ObjectNode only diff --git a/docs/format-catboost.md b/docs/format-catboost.md index 9d2bd2f..fdba021 100644 --- a/docs/format-catboost.md +++ b/docs/format-catboost.md @@ -25,9 +25,10 @@ and independently before native load. Categorical CBM round trips work natively, but CTR storage has an unchecked native loader and needs its own preflight. There is no implicit input encoding or model conversion in this adapter. -A sklearn Pipeline containing CatBoost is unsupported. The skops trust list -does not admit CatBoost; export lists the unsupported types and instructs the -caller to export `pipeline.named_steps["est"]` directly. No implicit unwrapping. +An exact outer sklearn Pipeline may end in an admitted CatBoost wrapper via the +[composite representation](format-pipeline.md). The prefix uses checked skops bytes; +CBM passes through this adapter's unchanged preflight/claims/structure checks. +Other native placements are rejected; CatBoost remains outside skops trust. `class_labels=` is rejected even if equal: native `classes_` owns the labels. Class arrays retain native scalar types and probability-column order and are output-major, including `[[0, 1]]` for binary classification. Int and string @@ -36,7 +37,8 @@ binary/multiclass labels have tested exact round trips. ## Native I/O and wrapper recovery Library/serialization/member pairing is `catboost` / `cbm` / `model.cbm`. -There is exactly one native member per bundle; no pickle or skops fallback. +Standalone bundles have one native member; composites may also have a skops prefix. +CBM never goes through skops; there is no pickle fallback. Public `save_model(path, format="cbm")` writes in an export-only temporary directory that is removed before publication. Public saving has no in-memory API. Public `load_model(blob=data)` consumes bytes without reader-side disk I/O. diff --git a/docs/format-container.md b/docs/format-container.md index af98118..afdbb54 100644 --- a/docs/format-container.md +++ b/docs/format-container.md @@ -56,7 +56,7 @@ One MiB is 1,048,576 bytes. `ArchiveLimits` defines these defaults and ceilings: | Limit | Maximum | | --- | ---: | -| Member count, including manifest | 5 (minimum configured value 2) | +| Member count, including manifest | 6 (minimum configured value 2) | | Entire archive, including headers | 512 MiB | | Sum of compressed member sizes | 512 MiB | | Any uncompressed member | 512 MiB | @@ -79,6 +79,13 @@ Callers may lower archive limits, never raise the ceilings in version 1. Consumers still need process memory, CPU and prediction limits; a 1 GiB byte budget is not a guarantee that a parsed model uses at most 1 GiB of RAM. +Before manifest parsing, admit one model member or the two candidates +`model.skops` + `model.ubj`/`model.cbm`. Other combinations fail. After strict +manifest parsing, the declared standalone/composite recipe and actual members +must agree exactly; undeclared pairs fail. [Pipeline rules](format-pipeline.md) +own the one-step exception and required structure. Six members accommodate +manifest, two models, structure, schema and explicit samples. + ### Container functions and inspection The internal functions are `mlbundle.container.read_container(source, *, diff --git a/docs/format-pipeline.md b/docs/format-pipeline.md new file mode 100644 index 0000000..9fcf174 --- /dev/null +++ b/docs/format-pipeline.md @@ -0,0 +1,224 @@ +# Version 1 composite pipelines + +Normative section of [the format contract](format.md). This pre-release format +extension preserves reading of standalone bundles. Older readers reject composites. + +## Representation and declarations + +Only an exact outer `sklearn.pipeline.Pipeline` ending in a supported exact +XGBClassifier/XGBRegressor or CatBoostClassifier/CatBoostRegressor is composite. +All-scikit-learn pipelines retain their single skops member and null structure. +Every native adapter's standalone scope, preflight, claims and structure rules apply. +No native estimator or booster type is added to the skops trust list. + +`model.library` is `sklearn`, `serialization` is `pipeline`, `estimator_class` +is `Pipeline`, and `state_sha256` is null. Kind, class/output counts and target +labels equal the native component claims. `model.n_features` and manifest +features describe original inputs. Outer hyperparameters have exactly `memory` +(null), `verbose` (boolean), `transform_input` (null). ONNX fields are absent/null. + +`model.pipeline` is required exactly for this serialization. Its strict frozen +records reject unknown fields and coercions. The record contains: + +- `step_names`: 1–128 unique strings, 1–256 characters, without `__` or any + of `steps`, `memory`, `verbose`, `transform_input`. Names are data. +- `transformers`: null for one step; otherwise `member="model.skops"`, + `estimator_class="Pipeline"`, `hyperparameters` and non-null `state_sha256`. + Parameters use the existing sklearn projection, including `$estimator_ref` + in constructor collections and full records in named deep keys, with the + existing 4,096-key, 1 MiB and depth-16 bounds. +- `estimator`: `library` (`xgboost` or `catboost`), paired `serialization` and + `member`, plus the complete shared `ModelClaims` record under `claims`. + No ONNX fields or absent classifier labels are admitted. +- `transformed_features`: `n_features` (1–100,000), ordered unique `names` + (1–1,024 characters each), `origin` (`transformer`, `model`, `input`, `generated`). + Width equals the native component feature count. Generated names are exactly + `transformed_feature_1` through `transformed_feature_N`. + +Final step names and outer options are reconstruction declarations. Prefix +names/order/options are also verified against the loaded skops member. Rebuilding +declarations preserves them; it does not authenticate their historical values. +No manifest class name, parameter projection or step name selects loading code. + +Example (native claims and transformer parameters shortened for readability): + +```json +{ + "library":"sklearn", "serialization":"pipeline", "estimator_class":"Pipeline", + "kind":"classifier", "n_features":3, "n_classes":[2], "n_outputs":1, + "state_sha256":null, + "hyperparameters":{"memory":null,"verbose":false,"transform_input":null}, + "pipeline":{ + "step_names":["scale","est"], + "transformers":{ + "member":"model.skops", "estimator_class":"Pipeline", + "hyperparameters":{"steps":[["scale",{"$estimator_ref":"StandardScaler"}]]}, + "state_sha256":"506feb7a8fd45118e204d24081be7ec4d8b8998e73fa4581863741fb1852533f" + }, + "estimator":{"library":"xgboost","serialization":"ubjson","member":"model.ubj", + "claims":{"estimator_class":"XGBClassifier","n_features":3}}, + "transformed_features":{"n_features":3,"names":["a","b","c"],"origin":"transformer"} + } +} +``` + +## Members and component I/O + +Standalone bundles have exactly one of the four model members. A composite +with a prefix has exactly `model.skops` plus `model.ubj` or `model.cbm`. +A single-step composite has only its native member and null `transformers`. +Passthrough/None preceding steps still require the prefix member to preserve +the recipe. A pair without its declaration, a missing declared member, ONNX +pairs, two boosters or three model members fail. Manifest integrity covers +exactly the declared model members and referenced optional members. + +Composite `structure.json` is required. Schema and explicit samples are optional; +six members permit both with the two model members and manifest. Every other +archive ceiling is unchanged. Callers may lower member count and other limits. + +Export gates scope/options before serialization. The leaf adapter receives +`ExportDeclarations`, prepares its native copy where required, serializes and +deserializes bytes with its own preflight. Shared claims and native structure +must survive that round trip. The prefix is a transformer-only exact Pipeline: +the shared skops byte route applies the owned trust/catalog, payload/buffer +bounds and payload-versus-reserialized commitment comparison. It checks fitted +transformers and projects their parameters with the standalone owner. Each component +reuses verified snapshots within the synchronous operation, as defined in +[state verification](format-state.md); public calls never share that state. + +Shared composite extraction verifies fitted widths and names, recomputes the +recipe and outer claims for both original and restored components, and compares +canonical bytes. The reconstructed Pipeline and extended native structure must +survive the full comparison before atomic publication. Nothing fits a model, +transforms sample/dummy rows as verification, or changes the caller's pipeline. + +Load first validates container/manifest/integrity and decodes each JSON member +once. Schema/samples remain core-owned. The native leaf and optional prefix +load from bytes through their own checked routes. A constant imported Pipeline +class combines the loaded steps; no manifest-directed import or constructor is +used. The writer's extraction recomputes every component/outer claim and the +extended structure. Only exact canonical agreement permits a loaded result. + +## Fitted widths and feature names + +Widths derive from fitted state without data. Every effective transformer input +width agrees with the preceding output. Intermediate/final widths are positive +and bounded by 100,000; an empty ColumnTransformer branch contributes zero. +Nesting is at most 16. Input width/names come from the first effective fitted +transformer, or the native leaf for an identity-only prefix/single-step pipeline. + +The closed exact classes and rules are: + +| Transformer | Width rule and consistency checks | +| --- | --- | +| StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizer | Preserve width; fitted vectors/options and feature dimensions agree. | +| FunctionTransformer | Identity or the nine owned NumPy elementwise functions in [state](format-state.md); preserves width. Nonempty `kw_args`/`inv_kw_args` are excluded. | +| PCA | `components_.shape[0]` equals bounded `n_components_`; component input width and fitted mean/variance/singular-value vectors agree. | +| VarianceThreshold | Count true entries in the validated fitted support mask; variance vector has input width. | +| SelectKBest, SelectPercentile, SelectFpr, SelectFdr, SelectFwe, GenericUnivariateSelect | Count the checked support mask; fitted scores/p-values align; score functions remain the owned closed list. | +| SimpleImputer | Keep input width when `keep_empty_features`; otherwise count non-NaN fitted statistics, including dropped empty columns. Add the checked MissingIndicator feature count when requested. | +| KBinsDiscretizer | `ordinal` preserves width; onehot/onehot-dense sum fitted `n_bins_`. Edge lengths/order and the internal encoder counts/categories/sparsity agree. | +| PolynomialFeatures | Bounded combinatorial count from degree range, interaction-only and bias options; checked against both fitted output counts. No powers array expansion for width proof. | +| OneHotEncoder | Sum category counts after checked infrequent grouping and drop state; cross-check cached output counts, mapping lengths/indices, and original/grouped drop indices. | +| OrdinalEncoder | Preserve width; categories, missing indices and infrequent mappings align. | +| Pipeline, ColumnTransformer | Recursively compose/sum the closed rules. ColumnTransformer selectors, cached input indices, remainder complement, branch names/order, declared/fitted parameters, sparsity flag and output slices agree. | +| `"passthrough"`, None | Preserve width and recipe position. | + +Other transformers (including KNNImputer and FeatureUnion) have no composite +width rule. Existing skops/parameter restrictions also apply: callable selectors, +scalar/slice-valued constructor selectors and arbitrary function/type parameters have +no supported projection. Catalog admission alone does not guarantee export. + +Effective original names use the existing model/dataframe/declared/generated +rules. Checked `get_feature_names_out` methods propagate them; known elementwise +operations propagate directly. Otherwise use verified native leaf names, then +generated transformed names. Names must match width and remain unique/bounded. Polynomial names are generated +without dense powers_ arrays, with at most 100,000 term visits. ColumnTransformer +verbose_feature_names_out must be boolean; arbitrary format expansion is excluded. +Native names/dtypes stay separate in leaf claims: null XGBoost names or positional +CatBoost names are preserved. Native tree dumps are never renamed. This supplies +display coordinates, not inverse transformations or original-feature attribution. + +## Structure, prediction and inspection + +All native structure fields remain unchanged, with one sibling `pipeline`: + +```json +"pipeline": { + "step_name":"est", + "features":{"n_features":3,"names":["a","b","c"],"origin":"transformer"} +} +``` + +Family remains xgboost/catboost. Tree indices/importances address transformed +features. This extension is recomputed and allowed only for composites. +Stored structure bytes are compared with fresh canonical extraction through +the existing single-decode owner. Legacy all-sklearn structure remains null. + +`LoadedBundle.native_model` is a real Pipeline. Bundle input features/schema/ +dtypes/samples describe original inputs. The composite predictor checks original +rank/width and numeric cells, admitting strings only for supported fitted encoders; +it transforms once, checks unchanged row count and delegates to a cached validated +native predictor. Native input/output checks, probability tolerances and prediction +settings remain authoritative. Single-step prediction delegates directly, preserving +native categorical admission. Transform uses default global output and disabled +metadata routing while retaining explicit per-step default/pandas output. + +For XGBoost, bundle probability columns use bound original labels while the native +Pipeline's classes/predict remain encoded. CatBoost retains its native labels and +rejects `class_labels=`. Mutating a loaded native object invalidates verification. + +Inspect imports no model library and attests only metadata/integrity. CLI inspect +and verify display escaped/bounded steps, final estimator, input/transformed widths +and transformed names. Inspect retains its unverified-model notice; JSON includes +the complete typed recipe. Verify loads and checks both parts and structure. + +## Rejections + +Existing skops/native/schema/sample/resource messages retain their owners. +Recipe/pairing failures use `InvalidManifestError: Invalid mlbundle manifest.` +Candidate model sets use `InvalidContainerError: Invalid model member combination.` +Actual/reference disagreement remains `Members disagree with manifest references.` +Canonical disagreement uses ClaimMismatchError with ordinary outer fields, +`pipeline.transformers`, `pipeline.step_names`, `pipeline.estimator.`, +`pipeline.transformed_features`, or `structure`. Unsupported native placements +list sanitized unsupported types after the placement message below. + +The new scope, state and prediction messages are: + +- `Composite models require an exact outer Pipeline with 1 to 128 steps.` +- `Pipeline step names must be unique bounded sklearn names.` +- `Unknown outer Pipeline state.` +- `Composite Pipeline memory must be None; caching is unsupported.` +- `Composite Pipeline verbose must be a boolean.` +- `Composite Pipeline transform_input and metadata routing are unsupported.` +- `Composite export requires default global transform_output; use per-step set_output.` +- `Composite transformers support only default or pandas set_output.` +- `Native estimators are supported only as the last step of the outermost Pipeline.` +- `Composite final estimator must be a supported XGBoost or CatBoost wrapper.` +- `Transformer member must be a nonempty exact Pipeline.` +- `Transformer component contains a non-transformer.` +- `Composite transformers must be fitted.` +- `Composite input width cannot be recovered from fitted state.` +- `Transformer output width cannot be verified without data.` +- `Transformer nesting exceeds the limit.` +- `Transformer feature-name work exceeds the limit.` +- `Invalid transformer feature width.` +- `Transformer input widths disagree.` +- `Transformer fitted dimensions disagree.` +- `Invalid ColumnTransformer input mapping.` +- `Invalid ColumnTransformer output mapping.` +- `ColumnTransformer declared and fitted transformers disagree.` +- `Transformer output width disagrees with final estimator.` +- `Invalid transformed feature names.` +- `Transformed names and width disagree.` +- `Invalid composite Pipeline state.` (bounded unexpected-state wrapper) +- `Composite Pipeline transformation failed.` +- `Composite transformation must preserve prediction rows.` + +Native estimators in ColumnTransformer, FeatureUnion, stacking, voting, +calibration, search, TransformedTargetRegressor, nested pipelines or any nonfinal +position remain excluded. Caching, metadata requests, non-null transform_input, +unknown outer state and unverifiable fitted widths fail before publication. +Separate byte preflights do not sandbox native parsers or prediction. Host process, +memory/CPU/row limits and dependency maintenance remain necessary. diff --git a/docs/format-schema.md b/docs/format-schema.md index 89754b5..77957ba 100644 --- a/docs/format-schema.md +++ b/docs/format-schema.md @@ -141,8 +141,10 @@ arbitrary objects and unsupported NumPy values fail without stringification. Schema and samples each have the existing 16 MiB cap and JSON depth 32, further lowerable through `ArchiveLimits`. Sample rows are encoded incrementally within -the byte budget; no row is truncated. The five-member ceiling already permits -manifest, model, schema, structure and samples together. +the byte budget; no row is truncated. Six members permit a composite with +manifest, skops prefix, native leaf, structure, schema and explicit samples. +Composite schema, training dtypes and samples describe original inputs, never +the transformed boundary; target reports use the final adapter's verified labels. ## Error boundaries diff --git a/docs/format-state.md b/docs/format-state.md index daca194..258c043 100644 --- a/docs/format-state.md +++ b/docs/format-state.md @@ -108,7 +108,9 @@ values and fitted selector statistics participate in `state_sha256`. `state_sha256` is a lowercase SHA-256 digest. A sklearn structure object requires this field. Sklearn models with null structure, including Pipeline, instead put -the digest in `model.state_sha256`; CatBoost also requires a native commitment there. This optional +the digest in `model.state_sha256`; CatBoost also requires a native commitment there. +Composite prefix digests instead live in `model.pipeline.transformers.state_sha256`; +the outer digest is null, with native leaf claims/state and structure verified separately. This optional manifest field defaults to null, and is part of the verified claim projection. Missing commitments on an opaque sklearn model fail full load comparison. @@ -165,10 +167,11 @@ selection inferred from attribute suffixes. The explicit checks supplement it: | GLMs | Coefficients/intercepts plus loss/link are structure/composition. Fitted loss/link class and class count must agree with the parameter-derived loss. | | SVC / NuSVC / SVR / NuSVR | Kernel parameters, support data, native/private coefficients, sparse flag, effective gamma and calibration are committed. Linear coefficients and calibration are structure/composition when available. Public and inference dual coefficients/intercepts must agree, including sklearn's binary sign reversal. Support counts/indices, coefficient/calibration dimensions and sparse storage agree. | | Naive Bayes / QDA / neighbors / nearest centroid / MLP / semi-supervised / opaque meta-estimators | Their complete fitted statistics, data, class/output mappings, distance/activation state and nested models are committed in the manifest when structure is null. Statistics/likelihood/prior dimensions agree with classes/features; neighbor sample counts agree with fitted arrays. MLP layer sizes, output counts/activation and label-binarizer classes agree with public parameters/labels. Existing probability/count/trust constraints still apply. | -| Pipeline | All fitted steps, transformations, final estimator, order, names, classes and parameters are committed/claimed. Structure stays null; no transformed-feature visualization is inferred. | +| All-sklearn Pipeline | All fitted steps/order/names/parameters are committed; structure stays null. | +| Composite Pipeline | Prefix skops/state/parameters use the same owners as standalone sklearn. Leaf claims and complete native structure use its adapter. Shared data-free widths/names and checked tree-boundary record describe the reconstructed Pipeline; outer options/final name are declarations. See [pipelines](format-pipeline.md). | Unsupported custom types, functions/types outside these lists, HistGradientBoosting storage, -XGBoost/CatBoost pipeline steps, multilabel/chain probability layouts and untrusted nested internals remain +unsupported XGBoost/CatBoost placements, multilabel/chain probability layouts and untrusted nested internals remain rejections. For example, actual fitted `csr_matrix` neighbor state is outside the unchanged trust list; merely declaring its loader as the trusted base `spmatrix` does not make its state survive the native construction comparison. diff --git a/docs/format.md b/docs/format.md index 30e505b..e6f4502 100644 --- a/docs/format.md +++ b/docs/format.md @@ -13,29 +13,30 @@ The implemented ONNX mapping and validation rules are in [ONNX](format-onnx.md). The implemented CBM mapping and validation rules are in [CatBoost](format-catboost.md). The normative schema/declaration rules are in [Schema and samples](format-schema.md). [Prediction state and bounded work](format-state.md) is also normative. -These sections are part of this version 1 contract. +[Composite pipelines](format-pipeline.md) is normative for the split representation. ## Members | Member | Rule and contents | | --- | --- | | `manifest.json` | Required; the manifest described below. | -| `model.skops` | Exactly one model member; scikit-learn, skops format. | -| `model.ubj` | Exactly one model member; XGBoost native UBJSON format. | -| `model.cbm` | Exactly one model member; CatBoost native CBM binary format. | -| `model.onnx` | Exactly one model member; original, self-contained ONNX bytes, unchanged. | +| `model.skops` | Standalone model member; scikit-learn, skops format. | +| `model.ubj` | Standalone model member; XGBoost native UBJSON format. | +| `model.cbm` | Standalone model member; CatBoost native CBM binary format. | +| `model.onnx` | Standalone model member; original, self-contained ONNX bytes, unchanged. | | `schema.json` | Optional; exact object with `schema_version`, `fields` and `reports` from local inference. See [Schema and samples](format-schema.md). No training DataFrame. | | `structure.json` | Optional only for families without extractable structure; object as defined below. | | `samples.json` | Optional; array of row objects keyed by feature name, in declared feature order when consumed. Only explicit user intent may enable it. Values use the canonical JSON value rules. Samples are real user data. | +Model members are one standalone member, or the declared skops/UBJSON or skops/CBM +composite pair; single-step composites use only the leaf. See [pipelines](format-pipeline.md). Presence of schema, structure or samples must match both the manifest reference and an integrity record. There is no member for arbitrary user files. A training DataFrame must never be included. For samples, each row must contain exactly the declared feature names, without a target column or arbitrary extra columns; the byte/depth limits apply even when no rows are present. -For version 1, structure extraction is mandatory for supported scikit-learn -models exposing `tree_`, tree ensembles with constituent `tree_` estimators, +Version 1 requires structure for supported scikit-learn models exposing `tree_`, tree ensembles with constituent `tree_` estimators, models exposing `coef_`/`intercept_`, and models exposing `feature_importances_`; and for supported XGBoost and CatBoost tree boosters. A member is required whenever the adapter's extraction returns an object. Other supported sklearn families @@ -51,8 +52,7 @@ All objects representing typed records reject unknown keys. Types are strict: booleans do not stand for integers, strings do not stand for numbers and enum values are exact. JSON object keys are strings. Arrays are ordered. Null is distinct from an empty array/object. Unless marked optional, fields are required. -Optional fields can be omitted or null; writers emit defaults explicitly. -`user_metadata` may be omitted and defaults to `{}`. +Optional fields can be omitted/null; writers emit defaults. `user_metadata` defaults to `{}`. ### Identity @@ -65,13 +65,12 @@ Optional fields can be omitted or null; writers emit defaults explicitly. | `writer_version` | nonempty string, at most 256 characters | mlbundle package version that wrote the bundle. Must equal `environment.packages.mlbundle`. | Identity fields are declarations, not proof of provenance. - ### `model` | Field | Type | Meaning / verification | | --- | --- | --- | | `library` | `"sklearn"`, `"xgboost"`, `"catboost"`, `"onnx"` | Closed adapter identifier; validated against serialization/member pairing. | -| `serialization` | `"skops"`, `"ubjson"`, `"cbm"`, `"onnx"` | Must pair respectively with the four libraries and model members above. | +| `serialization` | `"skops"`, `"ubjson"`, `"cbm"`, `"onnx"`, `"pipeline"` | Standalone formats pair with their libraries; pipeline requires sklearn and a strict recipe. | | `estimator_class` | nonempty string, at most 256 characters | Verified class name (not a Python import path); ONNX uses the adapter's fixed `"ONNX"` identity. Never instantiate from this field. | | `kind` | `"classifier"`, `"regressor"` | Verified prediction kind. | | `hyperparameters` | object, at most 4,096 top-level keys | Verified JSON projection of the supported estimator's parameters. Sklearn uses `get_params(deep=True)`; other adapters use their recoverable native parameter representation. ONNX records graph signatures and metadata properties as defined in [ONNX](format-onnx.md), without inventing training settings. At most 1 MiB canonical bytes and depth 16. | @@ -79,6 +78,9 @@ Identity fields are declarations, not proof of provenance. | `n_classes` | null or array of integers 1–100,000 | Verified per-output class counts; classifier array length equals `n_outputs`, regressor value is null. | | `n_outputs` | integer 1–1,024 | Verified target output count. Probability columns are classes, not separate target outputs. | +`model.pipeline` defaults to null for old bundles and is required exactly for +`serialization="pipeline"`. Its records, cross-checks and declaration semantics +are specified in [Composite pipelines](format-pipeline.md); standalone writers omit this null extension to preserve existing wire bytes. Hyperparameter values preserve JSON scalars, nested containers and numeric tags. Do not stringify unsupported objects or call `repr` as a fallback. If an adapter supports nested estimators in parameters, their representation is an object diff --git a/pyproject.toml b/pyproject.toml index 531f487..9c15a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,8 @@ select = [ ignore = ["E501", "PLR0913", "PLR2004", "RET504"] [tool.ruff.lint.per-file-ignores] +"src/mlbundle/model_io.py" = ["PLC0415"] # Lazy optional adapter imports. +"src/mlbundle/registry.py" = ["PLC0415"] "tests/**/*.py" = ["ARG", "PLR2004", "PLR0124", "PLC0415", "S101", "DTZ"] # The base/artifact checks must run without importing the sklearn fixture producer. "scripts/package_smoke.py" = ["PLC0415"] diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index 441e6ca..d9c0201 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -69,6 +69,44 @@ def _base(path: Path) -> None: raise AssertionError("base installation unexpectedly loaded sklearn") assert not _OPTIONAL & sys.modules.keys() sys.stdout.write("base wheel: import/help/inspect OK; optional libraries absent\n") + composite = path.with_name(path.stem + "-composite.mlbundle") + if composite.exists(): + view = mlbundle.inspect(composite) + assert view.manifest.model.pipeline is not None + assert "Boundary: input=2, transformed=2" in _console("inspect", str(composite)) + assert json.loads( + _console("inspect", str(composite), "--json") + ) == view.manifest.model_dump(mode="json", by_alias=True) + try: + mlbundle.load(composite) + except mlbundle.MissingOptionalDependencyError as error: + assert error.extra == "sklearn" + else: + raise AssertionError("base installation unexpectedly loaded a composite") + assert not _OPTIONAL & sys.modules.keys() + sys.stdout.write( + "base wheel: composite inspect OK; bounded missing sklearn dependency\n" + ) + + +def _composite_fixture(path: Path) -> None: + from sklearn.pipeline import make_pipeline + from sklearn.preprocessing import StandardScaler + from xgboost import XGBClassifier + + import mlbundle + + rows = [[0, 1], [1, 0], [0, 2], [2, 0]] + model = make_pipeline( + StandardScaler(), XGBClassifier(n_estimators=2, n_jobs=1) + ).fit(rows, [0, 1, 0, 1]) + composite = path.with_name(path.stem + "-composite.mlbundle") + mlbundle.export(model, composite) + assert ( + mlbundle.load(composite).predict_proba(rows) + == model.predict_proba(rows).tolist() + ) + sys.stdout.write("composite fixture: separate skops/native members OK\n") def _contents(directory: Path) -> None: @@ -109,6 +147,8 @@ def main() -> None: absent = _OPTIONAL - {"sklearn", "skops", "numpy", "scipy"} assert all(find_spec(name) is None for name in absent) _sklearn(args.path) + if args.action == "create": + _composite_fixture(args.path) if __name__ == "__main__": diff --git a/src/mlbundle/_composite/__init__.py b/src/mlbundle/_composite/__init__.py new file mode 100644 index 0000000..63304e2 --- /dev/null +++ b/src/mlbundle/_composite/__init__.py @@ -0,0 +1 @@ +"""Separate checked components, closed sklearn reconstruction.""" diff --git a/src/mlbundle/_composite/claims.py b/src/mlbundle/_composite/claims.py new file mode 100644 index 0000000..0db6ec0 --- /dev/null +++ b/src/mlbundle/_composite/claims.py @@ -0,0 +1,133 @@ +"""One composite extraction for export and load.""" + +from typing import Any + +from pydantic import JsonValue +from sklearn.pipeline import Pipeline + +from mlbundle._pipeline_contract import ( + NativeComponentInfo, + PipelineInfo, + TransformedFeatures, +) +from mlbundle._sklearn import transformers +from mlbundle._sklearn.pipeline_scope import validate_components +from mlbundle._sklearn.transform_names import input_boundary, output_names +from mlbundle._sklearn.transform_width import output_width +from mlbundle.canonical import canonical_dumps +from mlbundle.claims import ModelClaims +from mlbundle.contract import MODEL_MEMBERS, Library +from mlbundle.errors import ClaimMismatchError, InvalidModelError, MLBundleError + + +def outer_claims( + prefix: Pipeline | None, leaf: ModelClaims, verbose: bool +) -> ModelClaims: + count, names = input_boundary(prefix, leaf) + return ModelClaims( + estimator_class="Pipeline", + kind=leaf.kind, + hyperparameters={"memory": None, "verbose": verbose, "transform_input": None}, + n_features=count, + n_classes=leaf.n_classes, + n_outputs=leaf.n_outputs, + feature_names=names, + feature_dtypes=leaf.feature_dtypes if prefix is None else None, + class_labels=leaf.class_labels, + ) + + +def extract( + prefix: Pipeline | None, + leaf: ModelClaims, + library: Library, + step_names: list[str], + verbose: bool, + *, + names: list[str], + transformer_adapter: transformers.Adapter, +) -> PipelineInfo: + try: + if library not in (Library.XGBOOST, Library.CATBOOST): + raise InvalidModelError("Invalid composite Pipeline state.") + outer = outer_claims(prefix, leaf, verbose) + count = ( + outer.n_features + if prefix is None + else output_width(prefix, outer.n_features) + ) + if count != leaf.n_features: + raise InvalidModelError( + "Transformer output width disagrees with final estimator." + ) + transformed = names if prefix is None else output_names(prefix, names) + origin = "input" if prefix is None else "transformer" + if transformed is None: + transformed = leaf.feature_names + origin = "model" + if transformed is None: + transformed = [f"transformed_feature_{i}" for i in range(1, count + 1)] + origin = "generated" + if len(transformed) != count: + raise InvalidModelError("Transformed names and width disagree.") + if prefix is not None: + validate_components(prefix) + return PipelineInfo( + step_names=step_names, + transformers=None if prefix is None else transformer_adapter.claims(prefix), + estimator=NativeComponentInfo( + library=library, + serialization=MODEL_MEMBERS[library][0], + member=MODEL_MEMBERS[library][1], + claims=leaf, + ), + transformed_features=TransformedFeatures( + n_features=count, names=transformed, origin=origin + ), + ) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid composite Pipeline state.") from None + + +def verify(expected: PipelineInfo, actual: PipelineInfo) -> None: + for field in ("step_names", "transformers", "transformed_features"): + if canonical_dumps(expected.model_dump(mode="json")[field]) != canonical_dumps( + actual.model_dump(mode="json")[field] + ): + raise ClaimMismatchError("pipeline." + field) + left = expected.estimator.model_dump(mode="json") + right = actual.estimator.model_dump(mode="json") + for field, value in left["claims"].items(): + if canonical_dumps(value) != canonical_dumps(right["claims"][field]): + raise ClaimMismatchError("pipeline.estimator." + field) + if canonical_dumps(left) != canonical_dumps(right): + raise ClaimMismatchError("pipeline.estimator") + + +def structure( + native: dict[str, JsonValue] | None, recipe: PipelineInfo +) -> dict[str, JsonValue]: + if native is None: + raise InvalidModelError("Invalid composite Pipeline state.") + return { + **native, + "pipeline": { + "step_name": recipe.step_names[-1], + "features": recipe.transformed_features.model_dump(mode="json"), + }, + } + + +def rebuild( + prefix: Pipeline | None, leaf: Any, step_names: list[str], verbose: bool +) -> Pipeline: + if prefix is not None and [name for name, _ in prefix.steps] != step_names[:-1]: + raise ClaimMismatchError("pipeline.transformers") + return Pipeline( + [*([] if prefix is None else prefix.steps), (step_names[-1], leaf)], + memory=None, + verbose=verbose, + transform_input=None, + ) diff --git a/src/mlbundle/_composite/io.py b/src/mlbundle/_composite/io.py new file mode 100644 index 0000000..0b914a0 --- /dev/null +++ b/src/mlbundle/_composite/io.py @@ -0,0 +1,158 @@ +"""Composite orchestration; each byte member belongs to its existing loader.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import JsonValue +from sklearn.pipeline import Pipeline + +from mlbundle._native_io import NativeExport, prepare_native +from mlbundle._pipeline_contract import PipelineInfo +from mlbundle._sklearn import transformers +from mlbundle._sklearn.pipeline_scope import validate_components +from mlbundle.claims import ModelClaims, verify_claims, verify_structure +from mlbundle.contract import Library, MemberName, SerializationFormat +from mlbundle.declarations import ExportDeclarations +from mlbundle.errors import LimitExceededError, UnsupportedModelError +from mlbundle.limits import ArchiveLimits +from mlbundle.registry import get_adapter + +from .claims import extract, outer_claims, rebuild, structure, verify +from .prediction import Predictor + + +@dataclass(frozen=True) +class CompositeExport: + leaf: NativeExport + transformer_adapter: transformers.Adapter + prefix: Pipeline | None + restored_prefix: Pipeline | None + step_names: list[str] + verbose: bool + claims: ModelClaims + members: dict[MemberName, bytes] + library: Library = Library.SKLEARN + serialization: SerializationFormat = SerializationFormat.PIPELINE + + @property + def structure(self) -> dict[str, JsonValue] | None: + return self.leaf.structure + + def finish(self, names: list[str]) -> tuple[PipelineInfo, dict[str, JsonValue]]: + expected = extract( + self.prefix, + self.leaf.claims, + self.leaf.library, + self.step_names, + self.verbose, + names=names, + transformer_adapter=self.transformer_adapter, + ) + actual_leaf = self.leaf.adapter.extract_claims(self.leaf.restored) + actual_model = rebuild( + self.restored_prefix, self.leaf.restored, self.step_names, self.verbose + ) + validate_components(actual_model) + actual_claims = outer_claims(self.restored_prefix, actual_leaf, self.verbose) + verify_claims(self.claims, actual_claims) + actual = extract( + self.restored_prefix, + actual_leaf, + self.leaf.library, + self.step_names, + self.verbose, + names=names, + transformer_adapter=self.transformer_adapter, + ) + verify(expected, actual) + original = structure(self.leaf.structure, expected) + encoded = verify_structure( + original, + structure(self.leaf.adapter.extract_structure(self.leaf.restored), actual), + ) + self.members[MemberName.STRUCTURE] = encoded + return expected, original + + +def prepare( + model: Any, declarations: ExportDeclarations, limits: ArchiveLimits +) -> CompositeExport: + validate_components(model, exporting=True) + leaf_model = model.steps[-1][1] + roots = {base.__module__.split(".")[0] for base in type(leaf_model).__mro__} + library = Library.XGBOOST if "xgboost" in roots else Library.CATBOOST + adapter = get_adapter(library) + if not adapter.recognizes(leaf_model): + raise UnsupportedModelError( + reason="Composite final estimator must be a supported XGBoost or CatBoost wrapper." + ) + leaf = prepare_native(leaf_model, adapter, declarations, limits) + prefix = restored = None + transformer_adapter = transformers.Adapter() + members = dict(leaf.members) + if len(model.steps) > 1: + prefix = Pipeline( + list(model.steps[:-1]), + memory=None, + verbose=model.verbose, + transform_input=None, + ) + data = transformer_adapter.serialize(prefix) + if len(data) > limits.member_bytes(MemberName.SKOPS): + raise LimitExceededError("Model member size limit exceeded.") + members[MemberName.SKOPS] = data + restored = transformer_adapter.deserialize(data) + claims = outer_claims(prefix, leaf.claims, model.verbose) + return CompositeExport( + leaf, + transformer_adapter, + prefix, + restored, + [name for name, _ in model.steps], + model.verbose, + claims, + members, + ) + + +def load_components( + recipe: PipelineInfo, + members: dict[MemberName, bytes], + names: list[str], + verbose: bool, +) -> tuple[Pipeline, Predictor, ModelClaims, dict[str, JsonValue]]: + adapter = get_adapter(recipe.estimator.library) + transformer_adapter = transformers.Adapter() + leaf = adapter.deserialize(members[adapter.model_member]) + prefix = ( + None + if recipe.transformers is None + else transformer_adapter.deserialize(members[MemberName.SKOPS]) + ) + native_claims = adapter.extract_claims(leaf) + model = rebuild(prefix, leaf, recipe.step_names, verbose) + validate_components(model) + actual = extract( + prefix, + native_claims, + adapter.library, + recipe.step_names, + verbose, + names=names, + transformer_adapter=transformer_adapter, + ) + verify(recipe, actual) + claims = outer_claims(prefix, native_claims, verbose) + extracted = structure(adapter.extract_structure(leaf), actual) + settings = native_claims.hyperparameters.get("prediction") + categorical = ( + adapter.library == Library.XGBOOST + and isinstance(settings, dict) + and settings.get("enable_categorical") is True + ) + return ( + model, + Predictor(prefix, adapter.predictor(leaf), claims, categorical=categorical), + claims, + extracted, + ) diff --git a/src/mlbundle/_composite/prediction.py b/src/mlbundle/_composite/prediction.py new file mode 100644 index 0000000..ac7fe8d --- /dev/null +++ b/src/mlbundle/_composite/prediction.py @@ -0,0 +1,77 @@ +"""Transform original inputs, then delegate to the cached native predictor.""" + +from typing import Any + +from sklearn import config_context +from sklearn.pipeline import Pipeline + +from mlbundle._prediction_input import tabular +from mlbundle._sklearn.prediction import _string_inputs +from mlbundle.adapters import ( + Predictor as NativePredictor, + ProbabilityOutput, + RegressionOutput, +) +from mlbundle.claims import ModelClaims +from mlbundle.contract import ModelKind +from mlbundle.errors import InvalidModelError, MLBundleError, PredictionInputError + + +class Predictor: + def __init__( + self, + prefix: Pipeline | None, + native: NativePredictor, + claims: ModelClaims, + *, + categorical: bool = False, + ) -> None: + self.prefix, self.native, self.claims = prefix, native, claims + self.strings = prefix is not None and _string_inputs(prefix) + self.categorical = categorical + + def _transform(self, data: Any) -> Any: + # A single-step composite preserves the leaf's categorical admission. + if self.prefix is None: + return data + data, rows = tabular( + data, + self.claims.n_features, + strings=self.strings, + categorical=self.categorical, + ) + names = getattr(data, "columns", None) + if ( + names is not None + and self.claims.feature_names is not None + and list(names) != self.claims.feature_names + ): + raise PredictionInputError( + "Prediction columns must match model feature names and order." + ) + try: + with config_context( + transform_output="default", enable_metadata_routing=False + ): + result = self.prefix.transform(data) + if len(result.shape) != 2 or result.shape[0] != rows: + raise InvalidModelError( + "Composite transformation must preserve prediction rows." + ) + return result + except MLBundleError: + raise + except Exception: + raise InvalidModelError( + "Composite Pipeline transformation failed." + ) from None + + def predict(self, data: object) -> RegressionOutput: + if self.claims.kind != ModelKind.REGRESSOR: + raise ValueError("predict requires a regressor.") + return self.native.predict(self._transform(data)) + + def predict_proba(self, data: object) -> ProbabilityOutput: + if self.claims.kind != ModelKind.CLASSIFIER: + raise ValueError("predict_proba requires a classifier.") + return self.native.predict_proba(self._transform(data)) diff --git a/src/mlbundle/_container_rules.py b/src/mlbundle/_container_rules.py index a843b35..ed625ba 100644 --- a/src/mlbundle/_container_rules.py +++ b/src/mlbundle/_container_rules.py @@ -42,8 +42,12 @@ def check_members( if MemberName.MANIFEST not in present: raise InvalidContainerError("Missing manifest.json.") models = {pair[1] for pair in MODEL_MEMBERS.values()} - if len(present & models) != 1: - raise InvalidContainerError("Exactly one model member is required.") + selected = present & models + if len(selected) != 1 and selected not in ( + {MemberName.SKOPS, MemberName.UBJSON}, + {MemberName.SKOPS, MemberName.CBM}, + ): + raise InvalidContainerError("Invalid model member combination.") if manifest is not None and present != {MemberName.MANIFEST, *manifest.integrity}: raise InvalidContainerError("Members disagree with manifest references.") diff --git a/src/mlbundle/_export_manifest.py b/src/mlbundle/_export_manifest.py new file mode 100644 index 0000000..a2f610c --- /dev/null +++ b/src/mlbundle/_export_manifest.py @@ -0,0 +1,78 @@ +"""Manifest construction shared by standalone and composite export.""" + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from importlib.metadata import version +from uuid import uuid4 + +from pydantic import JsonValue + +from .claims import ModelClaims +from .contract import ( + FORMAT_NAME, + FORMAT_VERSION, + FeatureOrigin, + Library, + MemberName, + SerializationFormat, +) +from .environment import capture_environment +from .training import TrainingDeclaration + + +def manifest_parts( + claims: ModelClaims, + library: Library, + serialization: SerializationFormat, + *, + structure: dict[str, JsonValue] | None, + feature_names: Sequence[str] | None, + training: TrainingDeclaration | None, + target_name: str | None, + metadata: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + names = claims.feature_names + origin = FeatureOrigin.MODEL + if names is None: + origin = ( + FeatureOrigin.DECLARED + if feature_names is not None + else FeatureOrigin.GENERATED + ) + names = ( + list(feature_names) + if feature_names is not None + else [f"feature_{index}" for index in range(1, claims.n_features + 1)] + ) + if training is not None: + names, origin = training.names, FeatureOrigin.DATAFRAME + model = claims.model_dump( + mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} + ) + model.update(library=library.value, serialization=serialization.value) + return { + "format_name": FORMAT_NAME, + "format_version": FORMAT_VERSION, + "bundle_id": str(uuid4()), + "created_at": datetime.now(UTC).isoformat(), + "writer_version": version("mlbundle"), + "model": model, + "features": { + "names": names, + "dtypes": [None] * claims.n_features + if training is None + else training.dtypes, + "model_dtypes": claims.feature_dtypes, + "origin": origin.value, + }, + "target": {"class_labels": claims.class_labels, "name": target_name}, + "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), + "references": { + "structure": None if structure is None else MemberName.STRUCTURE.value, + "schema": None if training is None else MemberName.SCHEMA.value, + "samples": None + if training is None or training.samples is None + else MemberName.SAMPLES.value, + }, + "user_metadata": {} if metadata is None else dict(metadata), + } diff --git a/src/mlbundle/_native_io.py b/src/mlbundle/_native_io.py new file mode 100644 index 0000000..64d2a6a --- /dev/null +++ b/src/mlbundle/_native_io.py @@ -0,0 +1,53 @@ +"""Shared native preparation and full round-trip verification.""" + +from dataclasses import dataclass + +from pydantic import JsonValue + +from .adapters import Adapter +from .claims import ModelClaims, verify_claims, verify_structure +from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat +from .declarations import ExportDeclarations, resolve_labels +from .errors import LimitExceededError +from .limits import ArchiveLimits + + +@dataclass(frozen=True) +class NativeExport: + adapter: Adapter + model: object + restored: object + claims: ModelClaims + structure: dict[str, JsonValue] | None + members: dict[MemberName, bytes] + + @property + def library(self) -> Library: + return self.adapter.library + + @property + def serialization(self) -> SerializationFormat: + return MODEL_MEMBERS[self.library][0] + + +def prepare_native( + model: object, + adapter: Adapter, + declarations: ExportDeclarations, + limits: ArchiveLimits, +) -> NativeExport: + model = adapter.prepare_export(model, declarations) + data = adapter.serialize(model) + if len(data) > limits.member_bytes(adapter.model_member): + raise LimitExceededError("Model member size limit exceeded.") + claims = resolve_labels(adapter.extract_claims(model), declarations) + structure = adapter.extract_structure(model) + restored = adapter.deserialize(data) + verify_claims( + claims, resolve_labels(adapter.extract_claims(restored), declarations) + ) + structure_bytes = verify_structure(structure, adapter.extract_structure(restored)) + members = {adapter.model_member: data} + if structure is not None: + members[MemberName.STRUCTURE] = structure_bytes + return NativeExport(adapter, model, restored, claims, structure, members) diff --git a/src/mlbundle/_pipeline_contract.py b/src/mlbundle/_pipeline_contract.py new file mode 100644 index 0000000..c63d3a6 --- /dev/null +++ b/src/mlbundle/_pipeline_contract.py @@ -0,0 +1,104 @@ +"""Core-only composite records; declarations never select Python constructors.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from pydantic import Field, JsonValue, field_validator, model_validator + +from ._base import ContractModel +from .canonical import canonical_dumps +from .claims import FeatureNames, ModelClaims, validate_labels +from .contract import MODEL_MEMBERS, Library, MemberName, SerializationFormat +from .limits import HYPERPARAMETER_BYTES, MAX_FEATURES + +StepName = Annotated[str, Field(min_length=1, max_length=256)] +Digest = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")] +OPTIONS = {"steps", "memory", "verbose", "transform_input"} + + +def valid_step_names(names: list[str]) -> bool: + return len(set(names)) == len(names) and all( + "__" not in name and name not in OPTIONS for name in names + ) + + +class TransformerInfo(ContractModel): + member: Literal["model.skops"] + estimator_class: Literal["Pipeline"] + hyperparameters: dict[str, JsonValue] = Field(max_length=4096) + state_sha256: Digest + + @field_validator("hyperparameters") + @classmethod + def bounded_parameters(cls, value: dict[str, JsonValue]) -> dict[str, JsonValue]: + canonical_dumps(value, max_bytes=HYPERPARAMETER_BYTES, max_depth=16) + return value + + +class NativeComponentInfo(ContractModel): + library: Literal[Library.XGBOOST, Library.CATBOOST] + serialization: SerializationFormat + member: MemberName + claims: ModelClaims + + @model_validator(mode="after") + def native_pairing(self) -> NativeComponentInfo: + if (self.serialization, self.member) != MODEL_MEMBERS[self.library]: + raise ValueError("Invalid native component pairing.") + classes = { + Library.XGBOOST: {"XGBClassifier", "XGBRegressor"}, + Library.CATBOOST: {"CatBoostClassifier", "CatBoostRegressor"}, + } + if self.claims.estimator_class not in classes[self.library]: + raise ValueError("Invalid native component class.") + if self.claims.onnx is not None: + raise ValueError("Composite components cannot declare ONNX fields.") + validate_labels(self.claims, self.claims.class_labels) + return self + + +class TransformedFeatures(ContractModel): + n_features: int = Field(gt=0, le=MAX_FEATURES) + names: FeatureNames + origin: Literal["transformer", "model", "input", "generated"] + + @model_validator(mode="after") + def aligned_names(self) -> TransformedFeatures: + if len(self.names) != self.n_features or len(set(self.names)) != len( + self.names + ): + raise ValueError("Invalid transformed names.") + if self.origin == "generated" and self.names != [ + f"transformed_feature_{i}" for i in range(1, self.n_features + 1) + ]: + raise ValueError("Invalid generated transformed names.") + return self + + +class PipelineInfo(ContractModel): + step_names: list[StepName] = Field(min_length=1, max_length=128) + transformers: TransformerInfo | None + estimator: NativeComponentInfo + transformed_features: TransformedFeatures + + @model_validator(mode="after") + def consistent_recipe(self) -> PipelineInfo: + if not valid_step_names(self.step_names): + raise ValueError("Invalid Pipeline names.") + if (len(self.step_names) > 1) != (self.transformers is not None): + raise ValueError("Transformer recipe and steps disagree.") + if self.transformed_features.n_features != self.estimator.claims.n_features: + raise ValueError("Composite boundary widths disagree.") + return self + + +def expected_model_members( + library: Library, pipeline: PipelineInfo | None +) -> set[MemberName]: + if pipeline is None: + return {MODEL_MEMBERS[library][1]} + members = {pipeline.estimator.member} + if pipeline.transformers is not None: + members.add(MemberName.SKOPS) + return members diff --git a/src/mlbundle/_sklearn/__init__.py b/src/mlbundle/_sklearn/__init__.py index 083413c..1e14fa5 100644 --- a/src/mlbundle/_sklearn/__init__.py +++ b/src/mlbundle/_sklearn/__init__.py @@ -3,7 +3,6 @@ from typing import Any, cast import numpy as np -import skops.io as sio from pydantic import JsonValue, ValidationError from sklearn.base import BaseEstimator, is_classifier, is_regressor from sklearn.exceptions import NotFittedError @@ -27,24 +26,9 @@ from .parameters import parameters from .payload import validate_payload from .prediction import Predictor +from .serialization import SkopsIO from .structure import extract_structure, has_structure, validate_tree_storage -from .trusted import TRUSTED_TYPES - - -def _untrusted(data: bytes) -> None: - unsupported = tuple( - sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) - ) - if unsupported: - if any(name.startswith("catboost.") for name in unsupported): - raise UnsupportedModelError( - unsupported, - reason=( - f"{UnsupportedModelError(unsupported)} " - 'Export the estimator directly: export(pipeline.named_steps["est"], ...).' - ), - ) - raise UnsupportedModelError(unsupported) +from .trusted import TRUSTED_TYPES as TRUSTED_TYPES def _outputs(model: Any, classifier: bool) -> tuple[int, ClassLabels | None]: @@ -96,16 +80,11 @@ class Adapter: model_member = MemberName.SKOPS def __init__(self) -> None: - # The registry creates a fresh adapter for every public export/load. - self._states: list[tuple[object, str]] = [] - self._payload: bytes | None = None - self._digest: str | None = None + # Each registry call creates fresh synchronous operation state. + self._io = SkopsIO(self._validate, validate_payload) def _state(self, model: object) -> str: - for native, digest in self._states: - if native is model: - return digest - return validate_payload(sio.dumps(model)) + return self._io.state(model) def recognizes(self, model: object) -> bool: return ( @@ -151,47 +130,10 @@ def _validate(self, model: Any) -> None: validate_tree_storage(model) def serialize(self, model: object) -> bytes: - try: - self._states = [] - self._payload, self._digest = None, None - data = sio.dumps(model) - try: - digest = validate_payload(data) - except UnsupportedModelError: - _untrusted(data) - raise - _untrusted(data) - self._validate(model) - self._states = [(model, digest)] - self._payload, self._digest = data, digest - return data - except MLBundleError: - raise - except Exception: - raise UnsupportedModelError( - reason="Model cannot be serialized safely with skops." - ) from None + return self._io.serialize(model) def deserialize(self, data: bytes) -> object: - try: - expected_state = ( - self._digest if data is self._payload else validate_payload(data) - ) - self._payload, self._digest = None, None - _untrusted(data) - model = sio.loads(data, trusted=list(TRUSTED_TYPES)) - self._validate(model) - actual_state = validate_payload(sio.dumps(model)) - if expected_state != actual_state: - raise InvalidModelError( - "Skops state does not survive native construction." - ) - self._states = [*self._states[-1:], (model, actual_state)] - return model - except MLBundleError: - raise - except Exception: - raise InvalidModelError("Invalid skops model payload.") from None + return self._io.deserialize(data) def extract_claims(self, model: Any) -> ModelClaims: try: diff --git a/src/mlbundle/_sklearn/_width_checks.py b/src/mlbundle/_sklearn/_width_checks.py new file mode 100644 index 0000000..b399063 --- /dev/null +++ b/src/mlbundle/_sklearn/_width_checks.py @@ -0,0 +1,59 @@ +"""Strict fitted dimensions shared by the closed width validators.""" + +from typing import Any + +import numpy as np + +from mlbundle.errors import InvalidModelError +from mlbundle.limits import MAX_FEATURES + + +def width(value: Any, *, zero: bool = False) -> int: + if ( + isinstance(value, (bool, np.bool_)) + or not isinstance(value, (int, np.integer)) + or not (0 if zero else 1) <= value <= MAX_FEATURES + ): + raise InvalidModelError("Invalid transformer feature width.") + return int(value) + + +def dimensions(condition: bool) -> None: + if not condition: + raise InvalidModelError("Transformer fitted dimensions disagree.") + + +def vector( + model: Any, name: str, count: int, *, required: bool = True +) -> np.ndarray | None: + value = getattr(model, name, None) + if value is None: + dimensions(not required) + return None + result = np.asarray(value) + dimensions(result.shape == (count,)) + return result + + +def input_width(model: Any, count: int) -> None: + native = getattr(model, "n_features_in_", None) + if native is not None and width(native) != count: + raise InvalidModelError("Transformer input widths disagree.") + names = getattr(model, "feature_names_in_", None) + if names is not None: + values = np.asarray(names) + dimensions( + values.shape == (count,) + and all(type(x) is str and 1 <= len(x) <= 1024 for x in values) + and len(set(values)) == count + ) + + +def indices(value: Any, count: int) -> list[int]: + array = np.asarray(value) + dimensions(array.ndim == 1 and array.dtype.kind in "iu" and len(array) <= count) + result = [int(item) for item in array] + dimensions( + all(0 <= item < count for item in result) and len(set(result)) == len(result) + ) + return result diff --git a/src/mlbundle/_sklearn/column_width.py b/src/mlbundle/_sklearn/column_width.py new file mode 100644 index 0000000..163cfb1 --- /dev/null +++ b/src/mlbundle/_sklearn/column_width.py @@ -0,0 +1,198 @@ +"""Cross-check ColumnTransformer selectors, fitted clones and output slices.""" + +from collections.abc import Callable +from typing import Any + +import numpy as np +from sklearn.preprocessing import FunctionTransformer + +from mlbundle.canonical import canonical_dumps +from mlbundle.errors import InvalidModelError + +from ._width_checks import width +from .parameters import parameters + + +def require(condition: bool, message: str) -> None: + if not condition: + raise InvalidModelError(message) + + +def selector(value: Any, count: int, names: list[str] | None) -> list[int]: + message = "Invalid ColumnTransformer input mapping." + + def position(item: Any) -> int: + if type(item) in (str, np.str_): + require(names is not None and item in names, message) + return (names or []).index(item) + require( + isinstance(item, (int, np.integer)) + and not isinstance(item, (bool, np.bool_)), + message, + ) + result = int(item) + result = result + count if result < 0 else result + require(0 <= result < count, message) + return result + + if isinstance(value, slice): + if type(value.start) is str or type(value.stop) is str: + require(value.step is None and names is not None, message) + start = 0 if value.start is None else position(value.start) + stop = count if value.stop is None else position(value.stop) + 1 + return list(range(start, stop)) + require( + all( + item is None or type(item) is int + for item in (value.start, value.stop, value.step) + ) + and value.step != 0, + message, + ) + return list(range(count))[value] + if type(value) in (str, np.str_) or isinstance(value, (int, np.integer)): + return [position(value)] + require(isinstance(value, (list, tuple, np.ndarray)), message) + array = np.asarray(value) + require(array.ndim == 1 and len(array) <= 100_000, message) + if array.dtype.kind == "b": + require(len(array) == count, message) + return np.flatnonzero(array).tolist() + return [position(item) for item in value] + + +def same_transformer(declared: Any, fitted: Any) -> bool: + if type(declared) is str: + if declared == "drop": + return type(fitted) is str and fitted == "drop" + return ( + declared == "passthrough" + and type(fitted) is FunctionTransformer + and fitted.func is None + and fitted.inverse_func is None + and fitted.feature_names_out == "one-to-one" + and fitted.validate is False + ) + return type(declared) is type(fitted) and canonical_dumps( + parameters(declared) + ) == canonical_dumps(parameters(fitted)) + + +def column_width( + model: Any, count: int, depth: int, output_width: Callable[..., int] +) -> int: + + mapping_error = "Invalid ColumnTransformer input mapping." + clone_error = "ColumnTransformer declared and fitted transformers disagree." + output_error = "Invalid ColumnTransformer output mapping." + declared = model.transformers + fitted = model.transformers_ + require( + isinstance(declared, (list, tuple)) + and isinstance(fitted, list) + and len(declared) <= 128 + and len(model._columns) == len(declared), + mapping_error, + ) + require( + all( + isinstance(item, (tuple, list)) and len(item) == 3 + for item in [*declared, *fitted] + ), + mapping_error, + ) + declared_names = [item[0] for item in declared] + require( + all( + type(name) is str + and 1 <= len(name) <= 256 + and name != "remainder" + and "__" not in name + for name in declared_names + ) + and len(set(declared_names)) == len(declared_names), + mapping_error, + ) + names = getattr(model, "feature_names_in_", None) + names = None if names is None else list(names) + mapping = model._transformer_to_input_indices + require( + isinstance(mapping, dict) and set(mapping) == {*declared_names, "remainder"}, + mapping_error, + ) + for selected in mapping.values(): + require( + isinstance(selected, list) + and all(type(i) is int and 0 <= i < count for i in selected), + mapping_error, + ) + used = set() + for (name, _, columns), cached in zip(declared, model._columns, strict=True): + resolved = selector(columns, count, names) + require( + resolved == selector(cached, count, names) and resolved == mapping[name], + mapping_error, + ) + used.update(resolved) + remaining = sorted(set(range(count)) - used) + require(mapping["remainder"] == remaining, mapping_error) + remainder = model._remainder + require( + isinstance(remainder, tuple) + and len(remainder) == 3 + and remainder[0] == "remainder" + and selector(remainder[2], count, names) == remaining, + mapping_error, + ) + left, right = model.remainder, remainder[1] + require( + type(left) is type(right) + and ( + canonical_dumps(left) == canonical_dumps(right) + if type(left) is str + else canonical_dumps(parameters(left)) == canonical_dumps(parameters(right)) + ), + clone_error, + ) + expected = [*declared, *((remainder,) if remaining else ())] + require(len(fitted) == len(expected), clone_error) + require(type(model.sparse_output_) is bool, output_error) + outputs = model.output_indices_ + require( + isinstance(outputs, dict) and set(outputs) == {*declared_names, "remainder"}, + output_error, + ) + total = 0 + for (name, original, columns), (actual_name, transformer, actual_columns) in zip( + expected, fitted, strict=True + ): + require( + name == actual_name + and selector(columns, count, names) + == selector(actual_columns, count, names), + mapping_error, + ) + selected = mapping[name] + if not selected: + require( + type(original) is type(transformer) + or same_transformer(original, transformer), + clone_error, + ) + require(outputs[name] == slice(0, 0), output_error) + continue + require(same_transformer(original, transformer), clone_error) + if type(transformer) is str and transformer == "drop": + require(outputs[name] == slice(0, 0), output_error) + continue + require( + not isinstance(columns, (str, np.str_, int, np.integer)) + and not isinstance(actual_columns, (str, np.str_, int, np.integer)), + mapping_error, + ) + result = output_width(transformer, len(selected), depth=depth + 1) + require(outputs[name] == slice(total, total + result), output_error) + total = width(total + result) + if not remaining: + require(outputs["remainder"] == slice(0, 0), output_error) + return width(total) diff --git a/src/mlbundle/_sklearn/encoder_width.py b/src/mlbundle/_sklearn/encoder_width.py new file mode 100644 index 0000000..cd8055b --- /dev/null +++ b/src/mlbundle/_sklearn/encoder_width.py @@ -0,0 +1,123 @@ +"""Validate fitted category/drop/grouping dimensions before deriving width.""" + +from typing import Any + +import numpy as np +from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder + +from mlbundle.canonical import canonical_dumps + +from ._width_checks import dimensions, indices, width + + +def encoder_width(model: Any, count: int) -> int: + categories = model.categories_ + dimensions(isinstance(categories, list) and len(categories) == count) + lengths = [] + for values in categories: + dimensions(type(values) is np.ndarray) + array = np.asarray(values) + dimensions(array.ndim == 1 and 1 <= len(array) <= 100_000) + encoded = [canonical_dumps(item) for item in array] + dimensions(len(set(encoded)) == len(encoded)) + lengths.append(len(array)) + enabled = model._infrequent_enabled + dimensions( + type(enabled) is bool + and enabled + == (model.min_frequency is not None or model.max_categories is not None) + ) + missing = model._missing_indices if type(model) is OrdinalEncoder else {} + dimensions( + isinstance(missing, dict) + and all(type(i) is int and 0 <= i < count for i in missing) + ) + for i, index in missing.items(): + dimensions(width(index, zero=True) == lengths[i] - 1) + dimensions(bool(np.asarray(categories[i][-1] != categories[i][-1]))) + grouped = grouped_counts(model, lengths, missing, count) + if type(model) is not OneHotEncoder: + return count + dropped_counts(model, lengths, grouped, count) + cached = model._n_features_outs + dimensions(isinstance(cached, list) and len(cached) == count) + dimensions([width(item, zero=True) for item in cached] == grouped) + return width(sum(grouped)) + + +def grouped_counts( + model: Any, lengths: list[int], missing: dict, count: int +) -> list[int]: + grouped = lengths.copy() + enabled = model._infrequent_enabled + if enabled: + infrequent = model._infrequent_indices + mappings = model._default_to_infrequent_mappings + dimensions( + isinstance(infrequent, list) + and isinstance(mappings, list) + and len(infrequent) == len(mappings) == count + ) + for i, (selection, mapping) in enumerate( + zip(infrequent, mappings, strict=True) + ): + if selection is None: + dimensions(mapping is None) + continue + size = lengths[i] - int(i in missing) + selected = indices(selection, size) + dimensions(bool(selected) and selected == sorted(selected)) + grouped[i] -= len(selected) - 1 + array = np.asarray(mapping) + dimensions(array.shape == (size,) and array.dtype.kind in "iu") + expected = np.empty(size, dtype=int) + frequent = sorted(set(range(size)) - set(selected)) + expected[frequent] = np.arange(len(frequent)) + expected[selected] = len(frequent) + dimensions(np.array_equal(array, expected)) + return grouped + + +def dropped_counts( + model: Any, lengths: list[int], grouped: list[int], count: int +) -> None: + enabled = model._infrequent_enabled + drop = model.drop_idx_ + after = model._drop_idx_after_grouping + dimensions((drop is None) == (after is None) == (model.drop is None)) + if drop is not None: + dimensions( + type(drop) is np.ndarray + and type(after) is np.ndarray + and drop.dtype.kind == after.dtype.kind == "O" + ) + dimensions(np.asarray(drop).shape == np.asarray(after).shape == (count,)) + for i, (raw_original, raw_transformed) in enumerate( + zip(drop, np.asarray(after), strict=True) + ): + dimensions((raw_original is None) == (raw_transformed is None)) + if raw_original is None: + dimensions(model.drop == "if_binary" and grouped[i] != 2) + continue + original = width(raw_original, zero=True) + transformed = width(raw_transformed, zero=True) + dimensions(original < lengths[i] and transformed < grouped[i]) + mapping = model._default_to_infrequent_mappings[i] if enabled else None + dimensions( + transformed == (original if mapping is None else int(mapping[original])) + ) + if type(model.drop) is str: + dimensions( + transformed == 0 + and ( + model.drop == "first" + or (model.drop == "if_binary" and grouped[i] == 2) + ) + ) + else: + dimensions(np.asarray(model.drop).shape == (count,)) + dimensions( + canonical_dumps(np.asarray(model.drop)[i]) + == canonical_dumps(model.categories_[i][original]) + ) + grouped[i] -= 1 diff --git a/src/mlbundle/_sklearn/pipeline_scope.py b/src/mlbundle/_sklearn/pipeline_scope.py new file mode 100644 index 0000000..9fc03bc --- /dev/null +++ b/src/mlbundle/_sklearn/pipeline_scope.py @@ -0,0 +1,141 @@ +"""Bounded native-placement discovery and closed Pipeline options.""" + +from typing import Any + +import numpy as np +from sklearn import get_config +from sklearn.base import BaseEstimator +from sklearn.pipeline import Pipeline + +from mlbundle._pipeline_contract import OPTIONS, valid_step_names +from mlbundle.errors import InvalidModelError, LimitExceededError, UnsupportedModelError + + +def objects(root: Any): + pending = [root] + seen = set() + visits = 0 + while pending: + value = pending.pop() + visits += 1 + if visits > 100_000: + raise LimitExceededError("Pipeline state work exceeds the limit.") + if id(value) in seen: + continue + seen.add(id(value)) + if isinstance(value, dict): + children = value.values() + elif isinstance(value, (list, tuple)): + children = value + elif isinstance(value, np.ndarray) and value.dtype.kind == "O": + children = value.flat + else: + yield value + children = vars(value).values() if isinstance(value, BaseEstimator) else () + if len(pending) + len(children) > 100_000: + raise LimitExceededError("Pipeline state work exceeds the limit.") + pending.extend(children) + + +def native_types(root: Any) -> tuple[str, ...]: + return tuple( + sorted( + { + f"{type(value).__module__}.{type(value).__name__}" + for value in objects(root) + if {base.__module__.split(".")[0] for base in type(value).__mro__} + & {"xgboost", "catboost"} + } + ) + ) + + +def composite_candidate(model: Any) -> bool: + found = native_types(model) + if not found: + return False + if ( + isinstance(model, Pipeline) + and model.steps + and {base.__module__.split(".")[0] for base in type(model.steps[-1][1]).__mro__} + & {"xgboost", "catboost"} + ): + if any(native_types(step) for _, step in model.steps[:-1]): + reject_placement(found) + validate_options(model) + return True + reject_placement(found) + return False + + +def reject_placement(types: tuple[str, ...]) -> None: + raise UnsupportedModelError( + types, + reason=( + "Native estimators are supported only as the last step of the outermost Pipeline. " + + str(UnsupportedModelError(types)) + ), + ) + + +def validate_options(model: Any) -> None: + if ( + type(model) is not Pipeline + or not isinstance(model.steps, (list, tuple)) + or not 1 <= len(model.steps) <= 128 + ): + raise UnsupportedModelError( + reason="Composite models require an exact outer Pipeline with 1 to 128 steps." + ) + if "_metadata_request" in vars(model) or model.transform_input is not None: + raise UnsupportedModelError( + reason="Composite Pipeline transform_input and metadata routing are unsupported." + ) + if set(vars(model)) != OPTIONS: + raise InvalidModelError("Unknown outer Pipeline state.") + if any( + not isinstance(item, (list, tuple)) or len(item) != 2 for item in model.steps + ): + raise UnsupportedModelError( + reason="Pipeline step names must be unique bounded sklearn names." + ) + names = [name for name, _ in model.steps] + if any( + type(name) is not str or not 1 <= len(name) <= 256 for name in names + ) or not valid_step_names(names): + raise UnsupportedModelError( + reason="Pipeline step names must be unique bounded sklearn names." + ) + if model.memory is not None: + raise UnsupportedModelError( + reason="Composite Pipeline memory must be None; caching is unsupported." + ) + if type(model.verbose) is not bool: + raise UnsupportedModelError( + reason="Composite Pipeline verbose must be a boolean." + ) + + +def validate_components(model: Pipeline, *, exporting: bool = False) -> None: + validate_options(model) + if exporting and get_config()["transform_output"] != "default": + raise UnsupportedModelError( + reason="Composite export requires default global transform_output; use per-step set_output." + ) + for value in objects(model): + if isinstance(value, BaseEstimator): + if "_metadata_request" in vars(value): + raise UnsupportedModelError( + reason="Composite Pipeline transform_input and metadata routing are unsupported." + ) + config = vars(value).get("_sklearn_output_config", {}) + if ( + not isinstance(config, dict) + or set(config) - {"transform"} + or config.get("transform", "default") not in ("default", "pandas") + ): + raise UnsupportedModelError( + reason="Composite transformers support only default or pandas set_output." + ) + if isinstance(value, Pipeline) and value is not model: + validate_options(value) diff --git a/src/mlbundle/_sklearn/polynomial_names.py b/src/mlbundle/_sklearn/polynomial_names.py new file mode 100644 index 0000000..2778be7 --- /dev/null +++ b/src/mlbundle/_sklearn/polynomial_names.py @@ -0,0 +1,31 @@ +"""Bounded polynomial names without sklearn's dense powers_ allocation.""" + +from collections import Counter +from itertools import combinations, combinations_with_replacement +from typing import Any + +from mlbundle.errors import InvalidModelError, LimitExceededError + + +def polynomial_names(model: Any, names: list[str]) -> list[str]: + # Count term visits before constructing tuples; unlike powers_, work follows + # emitted monomials, not output width multiplied by original feature width. + maximum = ( + min(model._max_degree, len(names)) + if model.interaction_only + else model._max_degree + ) + if model.n_output_features_ * max(1, maximum) > 100_000: + raise LimitExceededError("Transformer feature-name work exceeds the limit.") + choose = combinations if model.interaction_only else combinations_with_replacement + result = ["1"] if model.include_bias else [] + for degree in range(max(1, model._min_degree), maximum + 1): + for term in choose(range(len(names)), degree): + parts = [ + names[index] + ("" if power == 1 else f"^{power}") + for index, power in sorted(Counter(term).items()) + ] + if sum(map(len, parts)) + len(parts) - 1 > 1024: + raise InvalidModelError("Invalid transformed feature names.") + result.append(" ".join(parts)) + return result diff --git a/src/mlbundle/_sklearn/serialization.py b/src/mlbundle/_sklearn/serialization.py new file mode 100644 index 0000000..ae09135 --- /dev/null +++ b/src/mlbundle/_sklearn/serialization.py @@ -0,0 +1,80 @@ +"""One owned skops byte boundary for estimators and transformer components.""" + +from collections.abc import Callable + +import skops.io as sio + +from mlbundle.errors import InvalidModelError, MLBundleError, UnsupportedModelError + +from .payload import validate_payload +from .trusted import TRUSTED_TYPES + + +def audit_types(data: bytes) -> None: + unsupported = tuple( + sorted(set(sio.get_untrusted_types(data=data)) - set(TRUSTED_TYPES)) + ) + if unsupported: + raise UnsupportedModelError(unsupported) + + +class SkopsIO: + """Reuse checked snapshots inside one operation; never across public calls.""" + + def __init__( + self, + validate: Callable[[object], None], + payload: Callable[[bytes], str] = validate_payload, + ) -> None: + self.validate = validate + self.payload = payload + self._states: list[tuple[object, str]] = [] + self._payload: bytes | None = None + self._digest: str | None = None + + def state(self, model: object) -> str: + for native, digest in self._states: + if native is model: + return digest + return self.payload(sio.dumps(model)) + + def serialize(self, model: object) -> bytes: + try: + self._states = [] + self._payload, self._digest = None, None + data = sio.dumps(model) + try: + digest = self.payload(data) + except UnsupportedModelError: + audit_types(data) + raise + audit_types(data) + self.validate(model) + self._states = [(model, digest)] + self._payload, self._digest = data, digest + return data + except MLBundleError: + raise + except Exception: + raise UnsupportedModelError( + reason="Model cannot be serialized safely with skops." + ) from None + + def deserialize(self, data: bytes) -> object: + try: + expected = self._digest if data is self._payload else self.payload(data) + self._payload, self._digest = None, None + audit_types(data) + model = sio.loads(data, trusted=list(TRUSTED_TYPES)) + self.validate(model) + actual = self.payload(sio.dumps(model)) + if expected != actual: + raise InvalidModelError( + "Skops state does not survive native construction." + ) + self._states = [*self._states[-1:], (model, actual)] + return model + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid skops model payload.") from None diff --git a/src/mlbundle/_sklearn/transform_names.py b/src/mlbundle/_sklearn/transform_names.py new file mode 100644 index 0000000..c6f2f03 --- /dev/null +++ b/src/mlbundle/_sklearn/transform_names.py @@ -0,0 +1,111 @@ +"""Input and inner display names, separately from native estimator names.""" + +from typing import Any + +import numpy as np +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import FunctionTransformer, OneHotEncoder, PolynomialFeatures + +from mlbundle.claims import ModelClaims +from mlbundle.errors import InvalidModelError, UnsupportedModelError + +from ._width_checks import input_width, width +from .polynomial_names import polynomial_names +from .transform_width import identity + + +def _first_boundary(prefix: Pipeline) -> tuple[int, list[str] | None] | None: + for _, step in prefix.steps: + if identity(step): + continue + if type(step) is Pipeline: + result = _first_boundary(step) + if result is not None: + return result + continue + count = getattr(step, "n_features_in_", None) + if count is not None: + count = width(count) + input_width(step, count) + names = getattr(step, "feature_names_in_", None) + return count, None if names is None else list(names) + if type(step) is not FunctionTransformer: + raise UnsupportedModelError( + reason="Composite input width cannot be recovered from fitted state." + ) + return None + + +def input_boundary( + prefix: Pipeline | None, leaf: ModelClaims +) -> tuple[int, list[str] | None]: + result = None if prefix is None else _first_boundary(prefix) + return (leaf.n_features, leaf.feature_names) if result is None else result + + +def output_names(model: Any, names: list[str]) -> list[str] | None: + if identity(model): + return names + native = getattr(model, "feature_names_in_", None) + if native is not None and list(native) != names: + raise InvalidModelError("Invalid transformed feature names.") + if type(model) is Pipeline: + for _, step in model.steps: + result = output_names(step, names) + if result is None: + return None + names = result + return names + return leaf_names(model, names) + + +def leaf_names(model: Any, names: list[str]) -> list[str] | None: + if type(model) is FunctionTransformer and model.feature_names_out is None: + return names + if type(model) is PolynomialFeatures: + return checked_names(polynomial_names(model, names)) + if type(model) is ColumnTransformer: + return column_names(model, names) + if type(model) is OneHotEncoder: + # Check string conversion/concatenation bounds before sklearn allocates names. + for categories in model.categories_: + for category in categories: + if len(str(category)) > 1024: + raise InvalidModelError("Invalid transformed feature names.") + method = getattr(model, "get_feature_names_out", None) + if method is None: + return None + return checked_names(method(names)) + + +def checked_names(value: Any) -> list[str]: + result = np.asarray(value) + if ( + result.ndim != 1 + or any( + type(name) not in (str, np.str_) or not 1 <= len(name) <= 1024 + for name in result + ) + or len(set(result)) != len(result) + ): + raise InvalidModelError("Invalid transformed feature names.") + return [str(name) for name in result] + + +def column_names(model: ColumnTransformer, names: list[str]) -> list[str] | None: + if type(model.verbose_feature_names_out) is not bool: + raise InvalidModelError("Invalid transformed feature names.") + result = [] + for name, transformer, _ in model.transformers_: + selected = model._transformer_to_input_indices[name] + if not selected or (type(transformer) is str and transformer == "drop"): + continue + branch = output_names(transformer, [names[i] for i in selected]) + if branch is None: + return None + result.extend( + f"{name}__{item}" if model.verbose_feature_names_out else item + for item in branch + ) + return checked_names(result) diff --git a/src/mlbundle/_sklearn/transform_width.py b/src/mlbundle/_sklearn/transform_width.py new file mode 100644 index 0000000..22314f0 --- /dev/null +++ b/src/mlbundle/_sklearn/transform_width.py @@ -0,0 +1,270 @@ +"""Closed, data-free fitted width proof; no samples or dummy predictions.""" + +from math import comb +from typing import Any + +import numpy as np +from sklearn.compose import ColumnTransformer +from sklearn.decomposition import PCA +from sklearn.feature_selection import ( + GenericUnivariateSelect, + SelectFdr, + SelectFpr, + SelectFwe, + SelectKBest, + SelectPercentile, + VarianceThreshold, +) +from sklearn.impute import MissingIndicator, SimpleImputer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + KBinsDiscretizer, + MaxAbsScaler, + MinMaxScaler, + Normalizer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + RobustScaler, + StandardScaler, +) +from sklearn.utils._mask import _get_mask + +from mlbundle.errors import UnsupportedModelError + +from ._width_checks import dimensions, indices, input_width, vector, width +from .column_width import column_width +from .encoder_width import encoder_width +from .trusted import NUMPY_FUNCTIONS + +SELECTORS = ( + SelectKBest, + SelectPercentile, + SelectFpr, + SelectFdr, + SelectFwe, + GenericUnivariateSelect, +) + + +def identity(model: Any) -> bool: + return model is None or (type(model) is str and model == "passthrough") + + +def output_width(model: Any, count: int, *, depth: int = 0) -> int: + width(count) + if depth > 16: + raise UnsupportedModelError(reason="Transformer nesting exceeds the limit.") + if identity(model): + return count + if type(model) is Pipeline: + for _, step in model.steps: + count = output_width(step, count, depth=depth + 1) + return count + input_width(model, count) + if type(model) is not FunctionTransformer or model.validate: + dimensions(width(getattr(model, "n_features_in_", None)) == count) + if type(model) is ColumnTransformer: + return column_width(model, count, depth, output_width) + return leaf_width(model, count) + + +def leaf_width(model: Any, count: int) -> int: + rule = WIDTH_RULES.get(type(model)) + if rule is None: + raise UnsupportedModelError( + (f"{type(model).__module__}.{type(model).__name__}",), + reason="Transformer output width cannot be verified without data.", + ) + return rule(model, count) + + +def scaler_width(model: Any, count: int) -> int: + if type(model) is StandardScaler: + for name, required in ( + ("mean_", model.with_mean or model.with_std), + ("var_", model.with_std), + ("scale_", model.with_std), + ): + value = vector(model, name, count, required=bool(required)) + dimensions(required or value is None) + samples = np.asarray(model.n_samples_seen_) + dimensions(samples.ndim == 0 or samples.shape == (count,)) + return count + fields = { + MinMaxScaler: ("min_", "scale_", "data_min_", "data_max_", "data_range_"), + MaxAbsScaler: ("scale_", "max_abs_"), + RobustScaler: (), + Normalizer: (), + } + if type(model) in fields: + for name in fields[type(model)]: + vector(model, name, count) + if type(model) is RobustScaler: + for name, required in ( + ("center_", model.with_centering), + ("scale_", model.with_scaling), + ): + value = vector(model, name, count, required=bool(required)) + dimensions(required or value is None) + return count + return count + + +def function_width(model: Any, count: int) -> int: + if any( + func is not None and all(func is not native for native in NUMPY_FUNCTIONS) + for func in (model.func, model.inverse_func) + ): + raise UnsupportedModelError( + reason="Transformer output width cannot be verified without data." + ) + if model.kw_args not in (None, {}) or model.inv_kw_args not in (None, {}): + raise UnsupportedModelError( + reason="Transformer output width cannot be verified without data." + ) + return count + + +def pca_width(model: Any, count: int) -> int: + result = width(model.n_components_) + dimensions( + result <= count and np.asarray(model.components_).shape == (result, count) + ) + for name in ( + "explained_variance_", + "explained_variance_ratio_", + "singular_values_", + ): + vector(model, name, result) + vector(model, "mean_", count) + return result + + +def selector_width(model: Any, count: int) -> int: + if type(model) is VarianceThreshold: + vector(model, "variances_", count) + else: + vector(model, "scores_", count) + vector(model, "pvalues_", count, required=False) + support = np.asarray(model.get_support()) + dimensions(support.shape == (count,) and support.dtype.kind == "b") + return width(np.count_nonzero(support)) + + +def imputer_width(model: SimpleImputer, count: int) -> int: + statistics = vector(model, "statistics_", count) + dimensions( + type(model.keep_empty_features) is bool and type(model.add_indicator) is bool + ) + result = ( + count + if model.keep_empty_features + else int( + np.count_nonzero(np.logical_not(np.asarray(_get_mask(statistics, np.nan)))) + ) + ) + indicator = model.indicator_ + dimensions((indicator is not None) == model.add_indicator) + if indicator is not None: + dimensions( + type(indicator) is MissingIndicator + and indicator._precomputed is True + and indicator.features == "missing-only" + and indicator.error_on_new is False + ) + dimensions(width(indicator._n_features) == count) + selected = indices(indicator.features_, count) + dimensions(selected == sorted(selected)) + result += len(selected) + return width(result) + + +def bins_width(model: KBinsDiscretizer, count: int) -> int: + bins = vector(model, "n_bins_", count) + dimensions( + bins is not None and bins.dtype.kind in "iu" and len(model.bin_edges_) == count + ) + counts = [width(item) for item in np.asarray(bins)] + for edges, size in zip(model.bin_edges_, counts, strict=True): + values = np.asarray(edges) + dimensions( + values.shape == (size + 1,) + and values.dtype.kind == "f" + and not np.isnan(values).any() + and bool(np.all(np.diff(values) > 0)) + ) + if model.encode == "ordinal": + dimensions(not hasattr(model, "_encoder")) + return count + dimensions( + model.encode in ("onehot", "onehot-dense") + and type(model._encoder) is OneHotEncoder + ) + input_width(model._encoder, count) + dimensions( + all( + np.array_equal(cats, np.arange(size)) + for cats, size in zip(model._encoder.categories_, counts, strict=True) + ) + ) + result = encoder_width(model._encoder, count) + dimensions( + result == sum(counts) + and model._encoder.sparse_output == (model.encode == "onehot") + ) + return width(result) + + +def polynomial_width(model: PolynomialFeatures, count: int) -> int: + minimum, maximum = model._min_degree, model._max_degree + dimensions( + type(minimum) is int + and type(maximum) is int + and 0 <= minimum <= maximum <= 100_000 + ) + degrees = model.degree if isinstance(model.degree, tuple) else (0, model.degree) + dimensions( + tuple(degrees) == (minimum, maximum) + and type(model.include_bias) is bool + and type(model.interaction_only) is bool + ) + + # Check the bounded count before any combinations or powers_ expansion. + def total(start: int) -> int: + result = int(model.include_bias) + for degree in range( + max(1, start), + min(maximum, count) + 1 if model.interaction_only else maximum + 1, + ): + result += ( + comb(count, degree) + if model.interaction_only + else comb(count + degree - 1, degree) + ) + width(result) + return width(result) + + result = total(minimum) + dimensions( + width(model.n_output_features_) == result + and width(model._n_out_full) == total(0) + ) + return result + + +WIDTH_RULES = { + **dict.fromkeys( + (StandardScaler, MinMaxScaler, MaxAbsScaler, RobustScaler, Normalizer), + scaler_width, + ), + FunctionTransformer: function_width, + PCA: pca_width, + **dict.fromkeys((VarianceThreshold, *SELECTORS), selector_width), + OneHotEncoder: encoder_width, + OrdinalEncoder: encoder_width, + SimpleImputer: imputer_width, + KBinsDiscretizer: bins_width, + PolynomialFeatures: polynomial_width, +} diff --git a/src/mlbundle/_sklearn/transformers.py b/src/mlbundle/_sklearn/transformers.py new file mode 100644 index 0000000..0e3f5d2 --- /dev/null +++ b/src/mlbundle/_sklearn/transformers.py @@ -0,0 +1,69 @@ +"""Transformer-only component using the estimator's skops/state/parameter owners.""" + +from typing import Any, cast + +from sklearn.exceptions import NotFittedError +from sklearn.pipeline import Pipeline +from sklearn.utils.validation import check_is_fitted + +from mlbundle._pipeline_contract import TransformerInfo +from mlbundle.errors import UnsupportedModelError + +from .parameters import parameters +from .payload import validate_payload +from .pipeline_scope import native_types, reject_placement, validate_components +from .serialization import SkopsIO +from .structure import validate_tree_storage + + +def validate(model: Any) -> None: + if type(model) is not Pipeline or not model.steps: + raise UnsupportedModelError( + reason="Transformer member must be a nonempty exact Pipeline." + ) + validate_components(model) + found = native_types(model) + if found: + reject_placement(found) + for _, step in model.steps: + if step is None or (type(step) is str and step == "passthrough"): + continue + if not callable(getattr(step, "transform", None)): + raise UnsupportedModelError( + reason="Transformer component contains a non-transformer." + ) + try: + check_is_fitted(step) + except NotFittedError: + raise UnsupportedModelError( + reason="Composite transformers must be fitted." + ) from None + validate_tree_storage(model) + parameters(model) + + +class Adapter: + """Transformer snapshot owner for one composite export/load.""" + + def __init__(self) -> None: + self._io = SkopsIO(validate, validate_payload) + + def serialize(self, model: Pipeline) -> bytes: + if type(model) is not Pipeline or not model.steps: + raise UnsupportedModelError( + reason="Transformer member must be a nonempty exact Pipeline." + ) + validate_components(model) + return self._io.serialize(model) + + def deserialize(self, data: bytes) -> Pipeline: + return cast(Pipeline, self._io.deserialize(data)) + + def claims(self, model: Pipeline) -> TransformerInfo: + validate(model) + return TransformerInfo( + member="model.skops", + estimator_class="Pipeline", + hyperparameters=parameters(model), + state_sha256=self._io.state(model), + ) diff --git a/src/mlbundle/cli.py b/src/mlbundle/cli.py index 6ed5c10..8483880 100644 --- a/src/mlbundle/cli.py +++ b/src/mlbundle/cli.py @@ -11,7 +11,7 @@ from . import inspect, load from .contract import MemberName from .errors import MissingOptionalDependencyError, MLBundleError -from .results import MetadataView +from .results import LoadedBundle, MetadataView _DISPLAY_LIMIT = 512 _NOTICE = ( @@ -54,8 +54,9 @@ def _summary(view: MetadataView) -> str: f"Counts: features={model.n_features}, outputs={model.n_outputs}, classes={_display(model.n_classes)}", f"Features ({manifest.features.origin.value}): {_display(manifest.features.names)}", f"Class labels (output-major): {_display(manifest.target.class_labels)}", - "Members:", ] + lines.extend(_pipeline_summary(view)) + lines.append("Members:") for member in view.members: record = manifest.integrity.get(member) if record is None: @@ -89,6 +90,21 @@ def _summary(view: MetadataView) -> str: return "\n".join(lines) +def _pipeline_summary(view: MetadataView | LoadedBundle) -> list[str]: + model = view.manifest.model + recipe = model.pipeline + if recipe is None: + return [] + leaf = recipe.estimator + boundary = recipe.transformed_features + return [ + f"Steps: {_display(recipe.step_names)}", + f"Estimator: {leaf.library.value} {_display(leaf.claims.estimator_class)} ({leaf.serialization.value})", + f"Boundary: input={model.n_features}, transformed={boundary.n_features}", + f"Transformed features ({boundary.origin}): {_display(boundary.names)}", + ] + + def _parser() -> argparse.ArgumentParser: parser = _Parser(prog="mlbundle", description=_NOTICE) commands = parser.add_subparsers(dest="command", required=True) @@ -156,6 +172,9 @@ def main(argv: Sequence[str] | None = None) -> int: f"Valid bundle: {bundle.library.value} {_display(bundle.class_name)}; " "verified format, integrity, schema/samples and native model claims/structure." ) + details = _pipeline_summary(bundle) + if details: + output += "\n" + "\n".join(details) _write_output(output) except MissingOptionalDependencyError as error: return _write_error( diff --git a/src/mlbundle/contract.py b/src/mlbundle/contract.py index 3501fdb..9db9dcd 100644 --- a/src/mlbundle/contract.py +++ b/src/mlbundle/contract.py @@ -24,6 +24,7 @@ class SerializationFormat(StrEnum): UBJSON = "ubjson" CBM = "cbm" ONNX = "onnx" + PIPELINE = "pipeline" class FeatureOrigin(StrEnum): diff --git a/src/mlbundle/limits.py b/src/mlbundle/limits.py index 689014c..3f3b660 100644 --- a/src/mlbundle/limits.py +++ b/src/mlbundle/limits.py @@ -17,7 +17,7 @@ class ArchiveLimits(ContractModel): - max_members: int = Field(default=5, ge=2, le=5) + max_members: int = Field(default=6, ge=2, le=6) max_archive_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) max_compressed_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) max_member_bytes: int = Field(default=512 * MIB, gt=0, le=512 * MIB) diff --git a/src/mlbundle/manifest.py b/src/mlbundle/manifest.py index f899d58..b934a1a 100644 --- a/src/mlbundle/manifest.py +++ b/src/mlbundle/manifest.py @@ -10,6 +10,7 @@ from pydantic import Field, JsonValue, ValidationError, field_validator, model_validator from ._base import ContractModel, Name, ShortString +from ._pipeline_contract import PipelineInfo, expected_model_members from .canonical import canonical_dumps, canonical_loads from .claims import ( ClassLabels, @@ -46,10 +47,31 @@ class ModelInfo(EstimatorInfo): library: Library serialization: SerializationFormat + pipeline: PipelineInfo | None = None @model_validator(mode="after") def native_serialization(self) -> ModelInfo: - if self.serialization != MODEL_MEMBERS[self.library][0]: + if self.serialization == SerializationFormat.PIPELINE: + if self.library != Library.SKLEARN or self.pipeline is None: + raise ValueError( + "Composite representation requires sklearn and a recipe." + ) + leaf = self.pipeline.estimator.claims + if ( + self.estimator_class != "Pipeline" + or self.state_sha256 is not None + or (self.kind, self.n_classes, self.n_outputs) + != (leaf.kind, leaf.n_classes, leaf.n_outputs) + or set(self.hyperparameters) != {"memory", "verbose", "transform_input"} + or self.hyperparameters["memory"] is not None + or self.hyperparameters["transform_input"] is not None + or type(self.hyperparameters["verbose"]) is not bool + ): + raise ValueError("Invalid composite outer claims.") + elif ( + self.pipeline is not None + or self.serialization != MODEL_MEMBERS[self.library][0] + ): raise ValueError("Library and serialization must agree.") return self @@ -96,7 +118,7 @@ class Manifest(ContractModel): features: FeatureInfo target: TargetInfo environment: TrainingEnvironment - integrity: dict[MemberName, IntegrityRecord] = Field(min_length=1, max_length=4) + integrity: dict[MemberName, IntegrityRecord] = Field(min_length=1, max_length=5) references: MemberReferences user_metadata: dict[str, JsonValue] = Field(default_factory=dict, max_length=1024) @@ -135,7 +157,15 @@ def consistent_manifest(self) -> Manifest: if len(self.features.names) != self.model.n_features: raise ValueError("Feature count and names must agree.") validate_labels(self.model, self.target.class_labels) - expected = {MODEL_MEMBERS[self.model.library][1]} + expected = expected_model_members(self.model.library, self.model.pipeline) + if self.model.pipeline is not None and ( + canonical_dumps(self.target.class_labels) + != canonical_dumps(self.model.pipeline.estimator.claims.class_labels) + or self.references.structure is None + ): + raise ValueError( + "Composite labels and structure must agree with the recipe." + ) expected.update( MemberName(member) for member in self.references.model_dump().values() @@ -156,7 +186,7 @@ def consistent_manifest(self) -> Manifest: return self def verified_claims(self) -> ModelClaims: - fields = self.model.model_dump(exclude={"library", "serialization"}) + fields = self.model.model_dump(exclude={"library", "serialization", "pipeline"}) return ModelClaims( **fields, feature_names=( @@ -169,8 +199,14 @@ def verified_claims(self) -> ModelClaims: onnx=self.environment.onnx, ) + def _wire_fields(self) -> dict[str, JsonValue]: + fields = self.model_dump(mode="json", by_alias=True) + if self.model.pipeline is None: + fields["model"].pop("pipeline") + return fields + def to_bytes(self) -> bytes: - return canonical_dumps(self.model_dump(mode="json", by_alias=True)) + return canonical_dumps(self._wire_fields()) @classmethod def from_bytes(cls, data: bytes) -> Manifest: diff --git a/src/mlbundle/model_io.py b/src/mlbundle/model_io.py index 7ad2535..715036e 100644 --- a/src/mlbundle/model_io.py +++ b/src/mlbundle/model_io.py @@ -1,102 +1,28 @@ """Shared export and validating load; adapters own all native-model behavior.""" from collections.abc import Mapping, Sequence -from datetime import UTC, datetime -from importlib.metadata import version from os import PathLike from typing import cast -from uuid import uuid4 from pydantic import JsonValue from ._container_rules import MEMBER_ORDER, BundleSource, effective_limits -from .claims import ( - Label, - ModelClaims, - verify_claims, - verify_structure, - verify_structure_bytes, -) +from ._export_manifest import manifest_parts +from ._native_io import prepare_native +from .claims import Label, verify_claims, verify_structure_bytes from .container import read_validated_container -from .contract import ( - FORMAT_NAME, - FORMAT_VERSION, - MODEL_MEMBERS, - FeatureOrigin, - Library, - MemberName, - SerializationFormat, -) +from .contract import Library, MemberName from .declarations import ExportDeclarations, export_declarations, resolve_labels -from .environment import capture_environment -from .errors import ClaimMismatchError, LimitExceededError +from .errors import ClaimMismatchError from .inspection import metadata_view from .limits import ArchiveLimits -from .registry import find_adapter, get_adapter +from .registry import find_adapter, get_adapter, is_composite from .results import LoadedBundle, MetadataView from .schema import read_declarations -from .training import TrainingDeclaration, prepare_training +from .training import prepare_training from .writing import write_validated_container -def _manifest_parts( - claims: ModelClaims, - library: Library, - serialization: SerializationFormat, - *, - structure: dict[str, JsonValue] | None, - feature_names: Sequence[str] | None, - training: TrainingDeclaration | None, - target_name: str | None, - metadata: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - names = claims.feature_names - origin = FeatureOrigin.MODEL - if names is None: - origin = ( - FeatureOrigin.DECLARED - if feature_names is not None - else FeatureOrigin.GENERATED - ) - names = ( - list(feature_names) - if feature_names is not None - else [f"feature_{index}" for index in range(1, claims.n_features + 1)] - ) - if training is not None: - names, origin = training.names, FeatureOrigin.DATAFRAME - model = claims.model_dump( - mode="json", exclude={"feature_names", "feature_dtypes", "class_labels", "onnx"} - ) - model.update(library=library.value, serialization=serialization.value) - return { - "format_name": FORMAT_NAME, - "format_version": FORMAT_VERSION, - "bundle_id": str(uuid4()), - "created_at": datetime.now(UTC).isoformat(), - "writer_version": version("mlbundle"), - "model": model, - "features": { - "names": names, - "dtypes": [None] * claims.n_features - if training is None - else training.dtypes, - "model_dtypes": claims.feature_dtypes, - "origin": origin.value, - }, - "target": {"class_labels": claims.class_labels, "name": target_name}, - "environment": capture_environment(onnx=claims.onnx).model_dump(mode="json"), - "references": { - "structure": None if structure is None else MemberName.STRUCTURE.value, - "schema": None if training is None else MemberName.SCHEMA.value, - "samples": None - if training is None or training.samples is None - else MemberName.SAMPLES.value, - }, - "user_metadata": {} if metadata is None else dict(metadata), - } - - def export( model: object, destination: str | PathLike[str], @@ -129,12 +55,14 @@ def export( """ selected = effective_limits(limits) declarations = export_declarations(class_labels) - adapter = find_adapter(model) - model = adapter.prepare_export(model, declarations) - data = adapter.serialize(model) - if len(data) > selected.member_bytes(adapter.model_member): - raise LimitExceededError("Model member size limit exceeded.") - claims = resolve_labels(adapter.extract_claims(model), declarations) + composite = None + if is_composite(model): + from ._composite.io import prepare + + prepared = composite = prepare(model, declarations, selected) + else: + prepared = prepare_native(model, find_adapter(model), declarations, selected) + claims = prepared.claims training = prepare_training( training_data, claims, @@ -143,29 +71,27 @@ def export( sample_rows=sample_rows, limits=selected, ) - structure = adapter.extract_structure(model) - restored = adapter.deserialize(data) - verify_claims( - claims, resolve_labels(adapter.extract_claims(restored), declarations) - ) - structure_bytes = verify_structure(structure, adapter.extract_structure(restored)) - members = {adapter.model_member: data} - if structure is not None: - members[MemberName.STRUCTURE] = structure_bytes + members = prepared.members + structure = prepared.structure if training is not None: members[MemberName.SCHEMA] = training.schema if training.samples is not None: members[MemberName.SAMPLES] = training.samples - parts = _manifest_parts( + parts = manifest_parts( claims, - adapter.library, - MODEL_MEMBERS[adapter.library][0], + prepared.library, + prepared.serialization, structure=structure, feature_names=feature_names, training=training, target_name=target_name, metadata=metadata, ) + if composite is not None: + recipe, structure = composite.finish( + cast(list[str], cast(dict, parts["features"])["names"]) + ) + cast(dict, parts["model"])["pipeline"] = recipe.model_dump(mode="json") _, validated = write_validated_container( parts, members, destination=destination, overwrite=overwrite, limits=selected ) @@ -178,26 +104,39 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded container = read_validated_container(source, limits=selected) manifest, members = container.manifest, container.members schema, samples = read_declarations(manifest, container.json, selected) - adapter = get_adapter(manifest.model.library) - model = adapter.deserialize(members[adapter.model_member]) - actual = adapter.extract_claims(model) - expected = manifest.verified_claims() - # Check native dimensions before using any label declaration from the bundle. - verify_claims( - expected.model_copy(update={"class_labels": actual.class_labels}), actual - ) - actual = resolve_labels( - actual, - ExportDeclarations( - class_labels=manifest.target.class_labels - if actual.class_labels is None - else None - ), - ) - verify_claims(expected, actual) + if manifest.model.pipeline is not None: + get_adapter(Library.SKLEARN) + from ._composite.io import load_components + + model, predictor, actual, extracted = load_components( + manifest.model.pipeline, + members, + manifest.features.names, + cast(bool, manifest.model.hyperparameters["verbose"]), + ) + verify_claims(manifest.verified_claims(), actual) + else: + adapter = get_adapter(manifest.model.library) + model = adapter.deserialize(members[adapter.model_member]) + actual = adapter.extract_claims(model) + expected = manifest.verified_claims() + # Check native dimensions before using any label declaration from the bundle. + verify_claims( + expected.model_copy(update={"class_labels": actual.class_labels}), actual + ) + actual = resolve_labels( + actual, + ExportDeclarations( + class_labels=manifest.target.class_labels + if actual.class_labels is None + else None + ), + ) + verify_claims(expected, actual) + predictor = adapter.predictor(model) + extracted = adapter.extract_structure(model) decoded = container.json.get(MemberName.STRUCTURE) structure = None if decoded is None else decoded.value - extracted = adapter.extract_structure(model) if (MemberName.STRUCTURE in members) != (extracted is not None): raise ClaimMismatchError("structure") verify_structure_bytes(b"null" if decoded is None else decoded, extracted) @@ -205,7 +144,7 @@ def load(source: BundleSource, *, limits: ArchiveLimits | None = None) -> Loaded manifest=manifest, members=tuple(name for name in MEMBER_ORDER if name in members), native_model=model, - predictor=adapter.predictor(model), + predictor=predictor, schema=schema, structure=cast(dict[str, JsonValue] | None, structure), samples=samples, diff --git a/src/mlbundle/registry.py b/src/mlbundle/registry.py index eacdd8a..f2561f1 100644 --- a/src/mlbundle/registry.py +++ b/src/mlbundle/registry.py @@ -9,7 +9,9 @@ from .contract import MODEL_MEMBERS, Library from .errors import ( AdapterNotImplementedError, + InvalidModelError, MissingOptionalDependencyError, + MLBundleError, UnsupportedModelError, ) @@ -83,3 +85,19 @@ def find_adapter(model: object) -> Adapter: ): raise error raise UnsupportedModelError + + +def is_composite(model: object) -> bool: + # Ancestry only gates the constant sklearn route, never a model-supplied path. + roots = {base.__module__.split(".")[0] for base in type(model).__mro__} + if "sklearn" not in roots or roots & {"xgboost", "catboost"}: + return False + get_adapter(Library.SKLEARN) + from ._sklearn.pipeline_scope import composite_candidate + + try: + return composite_candidate(model) + except MLBundleError: + raise + except Exception: + raise InvalidModelError("Invalid composite Pipeline state.") from None diff --git a/src/mlbundle/writing.py b/src/mlbundle/writing.py index e7ff3b1..39395b1 100644 --- a/src/mlbundle/writing.py +++ b/src/mlbundle/writing.py @@ -42,8 +42,8 @@ def write(self, data: bytes) -> int: def _finish_manifest( manifest: Manifest | Mapping[str, object], members: dict[MemberName, bytes] ) -> Manifest: - value = ( - manifest.model_dump(mode="json", by_alias=True) + value: dict[str, object] = ( + dict(manifest._wire_fields()) if isinstance(manifest, Manifest) else dict(manifest) ) @@ -228,7 +228,7 @@ def write_validated_container( raise LimitExceededError("Total member size limit exceeded.") finished = _finish_manifest(manifest, content) content[MemberName.MANIFEST] = canonical_dumps( - finished.model_dump(mode="json", by_alias=True), + finished._wire_fields(), max_bytes=selected.member_bytes(MemberName.MANIFEST), ) if sum(map(len, content.values())) > selected.max_total_bytes: diff --git a/tests/pipeline_helpers.py b/tests/pipeline_helpers.py new file mode 100644 index 0000000..798f791 --- /dev/null +++ b/tests/pipeline_helpers.py @@ -0,0 +1,43 @@ +"""Small deterministic composite fixtures, never owner's model files.""" + +import catboost as cb +import numpy as np +import pandas as pd +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from xgboost import XGBClassifier, XGBRegressor + +from mlbundle.container import read_container + + +def data(): + rng = np.random.default_rng(7) + frame = pd.DataFrame(rng.uniform(0.2, 2, (24, 3)), columns=list("abc")) + return frame, (frame.a > 1).astype(int) + + +def estimator(library="xgboost", regression=False): + if library == "xgboost": + return (XGBRegressor if regression else XGBClassifier)( + n_estimators=2, max_depth=2, n_jobs=1 + ) + return (cb.CatBoostRegressor if regression else cb.CatBoostClassifier)( + iterations=2, depth=2, thread_count=1, verbose=False, allow_writing_files=False + ) + + +def pipeline(library="xgboost", regression=False, prefix=True): + frame, y = data() + model = Pipeline( + [ + *(([("scale", StandardScaler())]) if prefix else []), + ("est", estimator(library, regression)), + ] + ) + return model.fit(frame, frame.a if regression else y), frame + + +def parts(source): + manifest, members = read_container(source) + members.pop("manifest.json") + return manifest.model_dump(mode="json", by_alias=True), members diff --git a/tests/test_catboost_validation.py b/tests/test_catboost_validation.py index 97d7dc8..0950ef7 100644 --- a/tests/test_catboost_validation.py +++ b/tests/test_catboost_validation.py @@ -36,13 +36,15 @@ class CustomClassifier(cb.CatBoostClassifier): assert not list(tmp_path.iterdir()) -def test_pipeline_rejection_lists_types_and_direct_export(tmp_path): +def test_nested_pipeline_rejection_lists_types_and_placement(tmp_path): model = classifier() with pytest.raises(mlbundle.UnsupportedModelError) as error: - mlbundle.export(Pipeline([("est", model)]), tmp_path / "pipeline.mlbundle") + mlbundle.export( + Pipeline([("inner", Pipeline([("est", model)]))]), + tmp_path / "pipeline.mlbundle", + ) assert "catboost.core.CatBoostClassifier" in error.value.untrusted_types - assert "Export the estimator directly" in str(error.value) - assert 'pipeline.named_steps["est"]' in str(error.value) + assert "last step of the outermost Pipeline" in str(error.value) mlbundle.export(model, tmp_path / "estimator.mlbundle") diff --git a/tests/test_limits.py b/tests/test_limits.py index ae5256e..a5f7dd6 100644 --- a/tests/test_limits.py +++ b/tests/test_limits.py @@ -8,7 +8,7 @@ def test_limits(): limits = ArchiveLimits() - assert limits.max_members == 5 + assert limits.max_members == 6 assert limits.member_bytes(MemberName.SKOPS) == 512 * 1024**2 assert limits.member_bytes(MemberName.STRUCTURE) == 256 * 1024**2 assert ( @@ -19,7 +19,7 @@ def test_limits(): @pytest.mark.parametrize( "fields", [ - {"max_members": 6}, + {"max_members": 7}, {"max_compression_ratio": 201}, {"max_total_bytes": 0}, {"max_structure_bytes": "10"}, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7b03b64..d60837c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -21,6 +21,7 @@ def read(data: dict) -> Manifest: def test_round_trip_and_claim_projection(manifest_data): manifest = read(manifest_data) assert Manifest.from_bytes(manifest.to_bytes()) == manifest + assert manifest.model.pipeline is None # Old wire records omit this extension. assert manifest.to_bytes() == canonical_dumps(manifest_data) claims = manifest.verified_claims() assert claims.feature_names == ["a", "b"] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..16bfed5 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,260 @@ +"""Composite public round trips, declarations, inspection and prediction errors.""" + +import builtins +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest +from sklearn import config_context +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.tree import DecisionTreeClassifier + +import mlbundle +from conftest import build_zip +from mlbundle._sklearn.trusted import TRUSTED_TYPES +from mlbundle.canonical import canonical_dumps +from pipeline_helpers import data, estimator, parts, pipeline + +pytestmark = [ + pytest.mark.filterwarnings("ignore:X does not have valid feature names"), + pytest.mark.filterwarnings("ignore:X has feature names"), +] + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +@pytest.mark.parametrize("regression", [False, True]) +@pytest.mark.parametrize("prefix", [False, True]) +def test_composite_roundtrip(library, regression, prefix, tmp_path): + model, frame = pipeline(library, regression, prefix) + before = vars(model).copy() + final_before = vars(model.steps[-1][1]).copy() + destination = tmp_path / "composite.mlbundle" + view = mlbundle.export(model, destination) + bundle = mlbundle.load(destination) + assert vars(model) == before + assert vars(model.steps[-1][1]).keys() == final_before.keys() + assert bundle.manifest == view.manifest + assert bundle.library == "sklearn" and bundle.class_name == "Pipeline" + assert type(bundle.native_model) is Pipeline + recipe = bundle.manifest.model.pipeline + assert recipe.estimator.library == library + assert (recipe.transformers is not None) == prefix + assert recipe.transformed_features.n_features == 3 + assert bundle.structure["family"] == library + assert bundle.structure["pipeline"]["features"]["n_features"] == 3 + for values in [frame, frame.to_numpy(), frame.to_numpy().tolist()]: + predict = "predict" if regression else "predict_proba" + expected = getattr(model, predict)(values) + assert np.array_equal(getattr(bundle, predict)(values), expected) + assert np.array_equal(getattr(bundle.native_model, predict)(values), expected) + with pytest.raises(ValueError, match="requires"): + getattr(bundle, "predict_proba" if regression else "predict")(frame) + with pytest.raises(FileExistsError): + mlbundle.export(model, destination) + mlbundle.export(model, destination, overwrite=True) + assert not any(name.startswith(("xgboost.", "catboost.")) for name in TRUSTED_TYPES) + + +def test_schema_samples_lowered_limits_and_labels(tmp_path): + model, frame = pipeline() + destination = tmp_path / "with-data.mlbundle" + mlbundle.export( + model, + destination, + training_data=frame, + sample_rows=2, + class_labels=[["no", "yes"]], + ) + bundle = mlbundle.load(destination) + assert len(bundle.members) == 6 + assert bundle.samples == frame.head(2).to_dict("records") + assert bundle.class_labels == [["no", "yes"]] + assert list(bundle.native_model.classes_) == [0, 1] + assert "model.skops" in bundle.manifest.integrity + for limits in [ + mlbundle.ArchiveLimits(max_members=5), + mlbundle.ArchiveLimits(max_member_bytes=1), + mlbundle.ArchiveLimits(max_structure_bytes=1), + ]: + with pytest.raises(mlbundle.LimitExceededError): + mlbundle.export( + model, + tmp_path / "never.mlbundle", + training_data=frame, + sample_rows=2, + limits=limits, + ) + assert not (tmp_path / "never.mlbundle").exists() + model, _ = pipeline("catboost") + with pytest.raises( + mlbundle.UnsupportedModelError, match="class_labels is unsupported" + ): + mlbundle.export(model, tmp_path / "never.mlbundle", class_labels=[[0, 1]]) + + +def test_inspect_import_isolation_and_cli(tmp_path, monkeypatch): + model, _ = pipeline() + destination = tmp_path / "composite.mlbundle" + mlbundle.export(model, destination) + original_import = builtins.__import__ + + def guarded(name, *args, **kwargs): + assert name.split(".")[0] not in { + "sklearn", + "skops", + "xgboost", + "catboost", + "numpy", + "pandas", + } + return original_import(name, *args, **kwargs) + + with monkeypatch.context() as patch: + patch.setattr(builtins, "__import__", guarded) + assert mlbundle.inspect(destination).manifest.model.pipeline.step_names == [ + "scale", + "est", + ] + executable = Path(sys.executable).with_name( + "mlbundle.exe" if sys.platform == "win32" else "mlbundle" + ) + for command in ("inspect", "verify"): + result = subprocess.run( + [executable, command, str(destination)], + capture_output=True, + text=True, + check=True, + ) + assert not result.stderr + assert '"Pipeline"' in result.stdout and '"XGBClassifier"' in result.stdout + assert ( + "Steps:" in result.stdout + and "Boundary: input=3, transformed=3" in result.stdout + ) + + +@pytest.mark.parametrize("sentinel", [None, "passthrough"]) +def test_identity_prefix_and_array_feature_declarations(sentinel, tmp_path): + frame, y = data() + model = Pipeline([("identity", sentinel), ("est", estimator())]).fit( + frame.to_numpy(), y + ) + path = tmp_path / "identity.mlbundle" + mlbundle.export(model, path, feature_names=list("abc")) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline.transformed_features.names == list("abc") + assert np.array_equal( + bundle.predict_proba(frame.to_numpy()), model.predict_proba(frame.to_numpy()) + ) + + +def test_per_step_pandas_output_and_global_prediction_context(tmp_path): + frame, y = data() + model = Pipeline( + [ + ("scale", StandardScaler().set_output(transform="pandas")), + ("est", estimator()), + ] + ).fit(frame, y) + path = tmp_path / "pandas.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline.estimator.claims.feature_names == list("abc") + with config_context(transform_output="pandas", enable_metadata_routing=True): + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + assert mlbundle.load(path).feature_names == tuple("abc") + + +def test_prediction_failures_are_bounded(tmp_path, monkeypatch): + model, frame = pipeline() + path = tmp_path / "prediction.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + for values in [[[1]], [["secret", 1, 2]], [[np.inf, 1, 2]], frame[list("bac")]]: + with pytest.raises(mlbundle.InvalidModelError) as error: + bundle.predict_proba(values) + assert "secret" not in str(error.value) + prefix = bundle.predictor.prefix + monkeypatch.setattr(prefix, "transform", lambda _: np.zeros((1, 3))) + with pytest.raises(mlbundle.InvalidModelError, match="preserve prediction rows"): + bundle.predict_proba(frame) + + def fail(_): + raise RuntimeError("secret") + + monkeypatch.setattr(prefix, "transform", fail) + with pytest.raises(mlbundle.InvalidModelError, match="transformation failed"): + bundle.predict_proba(frame) + + +def test_old_standalone_and_all_sklearn_representation(tmp_path): + frame, y = data() + for model in [ + estimator().fit(frame, y), + Pipeline([("scale", StandardScaler()), ("tree", DecisionTreeClassifier())]).fit( + frame, y + ), + ]: + path = tmp_path / "old.mlbundle" + mlbundle.export(model, path, overwrite=True) + bundle = mlbundle.load(path) + assert bundle.manifest.model.pipeline is None + record, members = parts(path) + record["model"].pop("pipeline") + legacy = build_zip( + [("manifest.json", canonical_dumps(record)), *members.items()] + ) + assert mlbundle.load(legacy).manifest.model.pipeline is None + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + if type(model) is Pipeline: + assert ( + bundle.structure is None + and bundle.manifest.model.serialization == "skops" + ) + + +def test_full_export_roundtrip_rejects_component_loss(tmp_path, monkeypatch): + from mlbundle._sklearn import serialization + + model, _ = pipeline() + original = serialization.sio.loads + + def changed(data, **kwargs): + restored = original(data, **kwargs) + restored.steps[0][1].mean_[0] += 1 + return restored + + monkeypatch.setattr(serialization.sio, "loads", changed) + with pytest.raises(mlbundle.InvalidModelError, match="state does not survive"): + mlbundle.export(model, tmp_path / "never.mlbundle") + assert not list(tmp_path.iterdir()) + + +def test_transformer_snapshots_are_local_to_each_public_operation( + tmp_path, monkeypatch +): + from mlbundle._sklearn import transformers + + model, _ = pipeline() + calls = [] + original = transformers.validate_payload + + def checked(data): + calls.append(data) + return original(data) + + monkeypatch.setattr(transformers, "validate_payload", checked) + path = tmp_path / "model.mlbundle" + mlbundle.export(model, path) + assert len(calls) == 2 + calls.clear() + mlbundle.load(path) + assert len(calls) == 2 + model.steps[0][1].extra_ = 1 + with pytest.raises( + mlbundle.InvalidModelError, match="Unknown sklearn estimator state" + ): + mlbundle.export(model, path, overwrite=True) diff --git a/tests/test_pipeline_names.py b/tests/test_pipeline_names.py new file mode 100644 index 0000000..dae89d7 --- /dev/null +++ b/tests/test_pipeline_names.py @@ -0,0 +1,194 @@ +"""Bounded names, selector dimensionality and categorical native boundaries.""" + +import subprocess +import sys + +import numpy as np +import pandas as pd +import pytest +from sklearn.compose import ColumnTransformer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + StandardScaler, +) + +import mlbundle +from mlbundle._sklearn.transform_names import output_names +from mlbundle._sklearn.transform_width import output_width +from pipeline_helpers import data, estimator, pipeline + + +@pytest.mark.parametrize("degree", [1, 2, (2, 3)]) +@pytest.mark.parametrize("interaction", [False, True]) +def test_polynomial_names_match_native_without_powers(degree, interaction, monkeypatch): + frame, _ = data() + model = PolynomialFeatures(degree, interaction_only=interaction).fit(frame) + expected = model.get_feature_names_out().tolist() + monkeypatch.setattr( + PolynomialFeatures, + "powers_", + property(lambda _: pytest.fail("dense powers_ allocation")), + ) + assert output_names(model, list("abc")) == expected + + +def test_large_polynomial_names_and_name_work_budget(monkeypatch): + model = PolynomialFeatures(1, include_bias=False).fit(np.zeros((1, 100_000))) + monkeypatch.setattr( + PolynomialFeatures, + "powers_", + property(lambda _: pytest.fail("80 GB powers_ allocation")), + ) + names = [f"x{i}" for i in range(100_000)] + assert output_width(model, 100_000) == 100_000 + assert output_names(model, names) == names + model = PolynomialFeatures(500).fit([[1]]) + with pytest.raises(mlbundle.LimitExceededError, match="feature-name work"): + output_names(model, ["x"]) + model = PolynomialFeatures(100_000, interaction_only=True, include_bias=False).fit( + [[1]] + ) + assert output_names(model, ["x"]) == ["x"] + + +@pytest.mark.parametrize("formatter", ["{feature_name:>10000000000}", "{feature_name}"]) +def test_formatter_rejected_before_expansion(formatter, tmp_path, monkeypatch): + frame, y = data() + transform = ColumnTransformer( + [("sc", StandardScaler(), [0])], verbose_feature_names_out=formatter + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + monkeypatch.setattr( + ColumnTransformer, + "get_feature_names_out", + lambda *_: pytest.fail("unbounded formatter"), + ) + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid transformed feature names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_numpy_string_selectors_and_scalar_cache_rejection(tmp_path): + frame, y = data() + transform = ColumnTransformer([("sc", StandardScaler(), np.array(["a", "b"]))]).fit( + frame + ) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + path = tmp_path / "numpy-selector.mlbundle" + mlbundle.export(model, path) + assert np.array_equal( + mlbundle.load(path).predict_proba(frame), model.predict_proba(frame) + ) + transform = ColumnTransformer([("sc", StandardScaler(), [0])]).fit(frame) + model.steps[0] = ("ct", transform) + model.steps[1] = ("est", estimator().fit(transform.transform(frame), y)) + transform.transformers[0] = ("sc", transform.transformers[0][1], 0) + transform.transformers_[0] = ("sc", transform.transformers_[0][1], 0) + transform._columns = [0] + with pytest.raises( + mlbundle.InvalidModelError, match="ColumnTransformer input mapping" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_missing_ordinal_cache_is_rejected(tmp_path): + frame, y = data() + model = Pipeline([("enc", OrdinalEncoder()), ("est", estimator())]).fit(frame, y) + del model.steps[0][1]._missing_indices + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid composite Pipeline state" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_fitted_selector_rank_and_manual_name_boundaries(tmp_path): + frame, y = data() + transform = ColumnTransformer([("sc", StandardScaler(), [0])]).fit(frame) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + transform.transformers_[0] = ("sc", transform.transformers_[0][1], 0) + with pytest.raises( + mlbundle.InvalidModelError, match="ColumnTransformer input mapping" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + for transform in [ + PolynomialFeatures(2), + ColumnTransformer([("sc", StandardScaler(), [0])]), + ]: + model = Pipeline( + [ + ("sc", StandardScaler().set_output(transform="pandas")), + ("tr", transform), + ("est", estimator()), + ] + ).fit(frame, y) + transform.feature_names_in_ = np.array(list("xyz"), dtype=object) + with pytest.raises( + mlbundle.InvalidModelError, match="Invalid transformed feature names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_encoder_drop_cache_native_storage_is_required(tmp_path): + frame, y = data() + model = Pipeline([("enc", OneHotEncoder(drop="first")), ("est", estimator())]).fit( + frame, y + ) + model.steps[0][1]._drop_idx_after_grouping = model.steps[0][ + 1 + ]._drop_idx_after_grouping.tolist() + with pytest.raises(mlbundle.InvalidModelError, match="fitted dimensions disagree"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +@pytest.mark.parametrize("identity", [None, "passthrough", FunctionTransformer()]) +def test_categorical_xgboost_identity_prefix(identity, tmp_path): + frame, y = data() + frame["b"] = pd.Categorical(np.where(y, "high", "low")) + model = Pipeline([("identity", identity), ("est", estimator())]).fit(frame, y) + path = tmp_path / "categorical.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + assert np.array_equal( + bundle.native_model.predict_proba(frame), model.predict_proba(frame) + ) + + +@pytest.mark.parametrize("dependency", ["sklearn", "skops"]) +def test_missing_composite_dependency_is_bounded(dependency, tmp_path): + model, _ = pipeline() + path = tmp_path / "dependency.mlbundle" + mlbundle.export(model, path) + script = """ +import builtins, sys +original = builtins.__import__ +def blocked(name, *args, **kwargs): + if name.split(".")[0] == sys.argv[2]: + raise ModuleNotFoundError(name=sys.argv[2]) + return original(name, *args, **kwargs) +builtins.__import__ = blocked +import mlbundle +view = mlbundle.inspect(sys.argv[1]) +assert view.manifest.model.pipeline is not None +try: + mlbundle.load(sys.argv[1]) +except mlbundle.MissingOptionalDependencyError as error: + assert error.extra == "sklearn" +else: + raise AssertionError("missing dependency was loaded") +""" + subprocess.run( + [sys.executable, "-c", script, str(path), dependency], + capture_output=True, + text=True, + check=True, + ) diff --git a/tests/test_pipeline_scope.py b/tests/test_pipeline_scope.py new file mode 100644 index 0000000..7c30759 --- /dev/null +++ b/tests/test_pipeline_scope.py @@ -0,0 +1,175 @@ +"""Supported prefix scope and every forbidden native placement/outer option.""" + +import numpy as np +import pytest +from sklearn import config_context +from sklearn.calibration import CalibratedClassifierCV +from sklearn.compose import ColumnTransformer, TransformedTargetRegressor +from sklearn.ensemble import StackingClassifier, VotingClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.model_selection import GridSearchCV +from sklearn.pipeline import FeatureUnion, Pipeline +from sklearn.preprocessing import FunctionTransformer, StandardScaler + +import mlbundle +from mlbundle._sklearn import transformers +from pipeline_helpers import data, estimator, pipeline + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +@pytest.mark.parametrize( + "placement", + [ + "nonfinal", + "column", + "union", + "stacking", + "voting", + "calibration", + "search", + "target", + "nested", + ], +) +def test_native_placement(library, placement, tmp_path): + leaf = estimator(library) + variants = { + "nonfinal": Pipeline([("booster", leaf), ("scale", StandardScaler())]), + "column": Pipeline( + [ + ("columns", ColumnTransformer([("leaf", leaf, [0])])), + ("final", estimator(library)), + ] + ), + "union": FeatureUnion([("leaf", leaf)]), + "stacking": StackingClassifier([("leaf", leaf)]), + "voting": VotingClassifier([("leaf", leaf)]), + "calibration": CalibratedClassifierCV(leaf), + "search": GridSearchCV(leaf, {}), + "target": TransformedTargetRegressor(regressor=leaf), + "nested": Pipeline([("outer", Pipeline([("leaf", leaf)]))]), + } + with pytest.raises( + mlbundle.UnsupportedModelError, match="last step of the outermost Pipeline" + ) as error: + mlbundle.export(variants[placement], tmp_path / "never.mlbundle") + assert any(name.startswith(library + ".") for name in error.value.untrusted_types) + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "name,value,message", + [ + ("memory", "cache", "memory must be None"), + ("verbose", 1, "verbose must be a boolean"), + ("transform_input", [], "transform_input and metadata routing"), + ("_metadata_request", {}, "transform_input and metadata routing"), + ("unknown", 1, "Unknown outer Pipeline state"), + ], +) +def test_outer_state(name, value, message, tmp_path): + model, _ = pipeline() + setattr(model, name, value) + with pytest.raises(mlbundle.MLBundleError, match=message): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +@pytest.mark.parametrize( + "names", + [ + ["same", "same"], + ["memory", "est"], + ["x__y", "est"], + ["", "est"], + ["x" * 257, "est"], + [0, "est"], + ], +) +def test_step_names(names, tmp_path): + model, _ = pipeline() + model.steps = list(zip(names, [step for _, step in model.steps], strict=True)) + with pytest.raises( + mlbundle.UnsupportedModelError, match="unique bounded sklearn names" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_exact_outer_leaf_and_step_count(tmp_path): + class CustomPipeline(Pipeline): + pass + + model, _ = pipeline() + for invalid in [ + CustomPipeline(model.steps), + Pipeline([("p" + str(i), None) for i in range(128)] + [model.steps[-1]]), + ]: + with pytest.raises( + mlbundle.UnsupportedModelError, match="exact outer Pipeline" + ): + mlbundle.export(invalid, tmp_path / "never.mlbundle") + + class CustomEstimator(type(model.steps[-1][1])): + pass + + with pytest.raises(mlbundle.UnsupportedModelError, match="Only XGBClassifier"): + mlbundle.export( + Pipeline([("leaf", CustomEstimator())]), tmp_path / "never.mlbundle" + ) + + +def test_metadata_output_and_unfitted_prefix(tmp_path): + model, _ = pipeline() + with ( + config_context(transform_output="pandas"), + pytest.raises( + mlbundle.UnsupportedModelError, match="default global transform_output" + ), + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0][1]._sklearn_output_config = {"transform": "polars"} + with pytest.raises(mlbundle.UnsupportedModelError, match="default or pandas"): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0] = ("scale", StandardScaler()) + with pytest.raises( + mlbundle.UnsupportedModelError, match="transformers must be fitted" + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model.steps[0][1]._metadata_request = {} + with pytest.raises(mlbundle.UnsupportedModelError, match="metadata routing"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_transformer_route_rejections_and_success(): + frame, y = data() + prefix = Pipeline([("scale", StandardScaler())]).fit(frame) + restored = transformers.Adapter().deserialize( + transformers.Adapter().serialize(prefix) + ) + assert np.array_equal(restored.transform(frame), prefix.transform(frame)) + for invalid, message in [ + (StandardScaler(), "nonempty exact Pipeline"), + (Pipeline([]), "nonempty exact Pipeline"), + (Pipeline([("leaf", LogisticRegression().fit(frame, y))]), "non-transformer"), + ]: + with pytest.raises(mlbundle.UnsupportedModelError, match=message): + transformers.Adapter().serialize(invalid) + + +def test_unverifiable_width_and_custom_function(tmp_path): + from sklearn.kernel_approximation import RBFSampler + + frame, y = data() + model = Pipeline([("r", RBFSampler(n_components=3)), ("est", estimator())]).fit( + frame, y + ) + with pytest.raises( + mlbundle.UnsupportedModelError, + match="output width cannot be verified without data", + ): + mlbundle.export(model, tmp_path / "never.mlbundle") + model = Pipeline( + [("custom", FunctionTransformer(lambda x: x)), ("est", estimator())] + ).fit(frame, y) + with pytest.raises(mlbundle.UnsupportedModelError) as error: + mlbundle.export(model, tmp_path / "never.mlbundle") + assert any("lambda" in name for name in error.value.untrusted_types) diff --git a/tests/test_pipeline_tampering.py b/tests/test_pipeline_tampering.py new file mode 100644 index 0000000..0d5f64c --- /dev/null +++ b/tests/test_pipeline_tampering.py @@ -0,0 +1,252 @@ +"""Rehashed attacker recipes, component state and native structure still fail.""" + +import copy + +import numpy as np +import pytest +import skops.io as sio + +import mlbundle +from conftest import build_zip +from mlbundle._sklearn import transformers +from mlbundle.canonical import canonical_dumps, canonical_loads +from mlbundle.writing import write_container +from pipeline_helpers import parts, pipeline + + +@pytest.fixture +def composite(tmp_path): + model, _ = pipeline() + path = tmp_path / "composite.mlbundle" + mlbundle.export(model, path) + return parts(path) + + +def changed_record(parts, path, value): + record = copy.deepcopy(parts) + current = record + for key in path[:-1]: + current = current[key] + current[path[-1]] = value + return record + + +@pytest.mark.parametrize( + "path,value", + [ + (("model", "pipeline", "unknown"), 1), + (("model", "pipeline", "step_names"), ["same", "same"]), + (("model", "pipeline", "step_names"), ["x__y", "est"]), + (("model", "pipeline", "step_names"), ["memory", "est"]), + (("model", "pipeline", "step_names"), ["x" * 257, "est"]), + (("model", "pipeline", "transformers"), None), + (("model", "pipeline", "transformers", "state_sha256"), None), + (("model", "pipeline", "transformers", "member"), "model.ubj"), + (("model", "pipeline", "estimator", "library"), "sklearn"), + (("model", "pipeline", "estimator", "serialization"), "cbm"), + (("model", "pipeline", "estimator", "member"), "model.cbm"), + (("model", "pipeline", "estimator", "claims", "estimator_class"), "eval"), + (("model", "pipeline", "transformed_features", "n_features"), True), + (("model", "pipeline", "transformed_features", "names"), ["a", "a", "b"]), + (("model", "pipeline", "transformed_features", "origin"), "declared"), + (("model", "hyperparameters", "memory"), "cache"), + (("model", "hyperparameters", "verbose"), 1), + (("model", "state_sha256"), "a" * 64), + (("model", "library"), "xgboost"), + (("model", "serialization"), "skops"), + (("model", "pipeline"), None), + ], +) +def test_strict_recipe(composite, path, value): + record, members = composite + with pytest.raises(mlbundle.InvalidManifestError): + mlbundle.inspect(write_container(changed_record(record, path, value), members)) + + +@pytest.mark.parametrize( + "path,value,claim", + [ + (("model", "pipeline", "step_names", 0), "renamed", "pipeline.transformers"), + ( + ("model", "pipeline", "transformers", "state_sha256"), + "a" * 64, + "pipeline.transformers", + ), + ( + ("model", "pipeline", "transformers", "hyperparameters", "scale__copy"), + False, + "pipeline.transformers", + ), + ( + ("model", "pipeline", "transformed_features", "names"), + ["x", "y", "z"], + "pipeline.transformed_features", + ), + ( + ( + "model", + "pipeline", + "estimator", + "claims", + "hyperparameters", + "prediction", + "importance_type", + ), + "weight", + "pipeline.estimator.hyperparameters", + ), + ( + ("model", "pipeline", "estimator", "claims", "feature_names"), + ["x", "y", "z"], + "pipeline.estimator.feature_names", + ), + ( + ("model", "pipeline", "estimator", "claims", "feature_dtypes"), + ["float", "float", "float"], + "pipeline.estimator.feature_dtypes", + ), + (("model", "hyperparameters", "verbose"), True, None), + ], +) +def test_recomputed_component_claims(composite, path, value, claim): + record, members = composite + data = write_container(changed_record(record, path, value), members) + if claim is None: + # Outer options are explicitly declarations; they do not restore model state. + assert mlbundle.load(data).native_model.verbose is True + else: + with pytest.raises(mlbundle.ClaimMismatchError) as error: + mlbundle.load(data) + assert error.value.claim == claim + + +@pytest.mark.parametrize( + "member_set", + [ + {"model.skops", "model.onnx"}, + {"model.ubj", "model.cbm"}, + {"model.skops", "model.ubj", "model.cbm"}, + set(), + ], +) +def test_invalid_member_candidates(composite, member_set): + record, _ = composite + with pytest.raises( + mlbundle.InvalidContainerError, match="Invalid model member combination" + ): + mlbundle.inspect( + build_zip( + [ + ("manifest.json", canonical_dumps(record)), + *[(name, b"native") for name in member_set], + ] + ) + ) + + +@pytest.mark.parametrize( + "change", + [ + "missing_skops", + "missing_native", + "extra_native", + "pair_without_recipe", + "recipe_without_pair", + ], +) +def test_member_recipe_disagreement(composite, change): + record, members = composite + record, members = copy.deepcopy(record), dict(members) + if change == "missing_skops": + members.pop(mlbundle.MemberName.SKOPS) + elif change == "missing_native": + members.pop(mlbundle.MemberName.UBJSON) + elif change == "extra_native": + members[mlbundle.MemberName.CBM] = b"native" + elif change == "pair_without_recipe": + record["model"]["pipeline"] = None + record["model"]["serialization"] = "skops" + else: + record["model"]["pipeline"]["transformers"] = None + record["model"]["pipeline"]["step_names"] = ["est"] + with pytest.raises((mlbundle.InvalidContainerError, mlbundle.InvalidManifestError)): + mlbundle.inspect(write_container(record, members)) + + +@pytest.mark.parametrize("alteration", ["state", "params", "names", "width"]) +def test_rehashed_transformer_payload(composite, alteration): + record, members = composite + restored = transformers.Adapter().deserialize(members[mlbundle.MemberName.SKOPS]) + step = restored.steps[0][1] + if alteration == "state": + step.mean_[0] += 1 + elif alteration == "params": + step.copy = False + elif alteration == "names": + restored.steps[0] = ("renamed", step) + else: + step.scale_ = np.ones(2) + members = {**members, mlbundle.MemberName.SKOPS: sio.dumps(restored)} + with pytest.raises((mlbundle.ClaimMismatchError, mlbundle.InvalidModelError)): + mlbundle.load(write_container(record, members)) + + +@pytest.mark.parametrize("alteration", ["tree", "boundary", "null", "extra"]) +def test_rehashed_structure(composite, alteration): + record, members = composite + structure = canonical_loads(members[mlbundle.MemberName.STRUCTURE]) + if alteration == "tree": + structure["trees"][0]["nodeid"] += 1 + elif alteration == "boundary": + structure["pipeline"]["features"]["names"][0] = "changed" + elif alteration == "extra": + structure["invented"] = True + else: + structure = None + members = {**members, mlbundle.MemberName.STRUCTURE: canonical_dumps(structure)} + with pytest.raises(mlbundle.ClaimMismatchError, match="structure"): + mlbundle.load(write_container(record, members)) + + +def test_labels_counts_missing_structure_and_unknown_version(composite): + record, members = composite + changed = copy.deepcopy(record) + changed["target"]["class_labels"] = changed["model"]["pipeline"]["estimator"][ + "claims" + ]["class_labels"] = [["no", "yes"]] + with pytest.raises(mlbundle.ClaimMismatchError, match="class_labels"): + mlbundle.load(write_container(changed, members)) + changed = copy.deepcopy(record) + changed["references"]["structure"] = None + with pytest.raises(mlbundle.InvalidManifestError): + write_container( + changed, + { + name: value + for name, value in members.items() + if name != mlbundle.MemberName.STRUCTURE + }, + ) + changed["format_version"] = "unknown" + with pytest.raises(mlbundle.UnsupportedFormatVersionError): + write_container(changed, members) + + +@pytest.mark.parametrize("library", ["xgboost", "catboost"]) +def test_native_preflight_before_native_loader(library, tmp_path, monkeypatch): + model, _ = pipeline(library) + path = tmp_path / "native.mlbundle" + mlbundle.export(model, path) + record, members = parts(path) + final_type = type(model.steps[-1][1]) + + def forbidden(*_args, **_kwargs): + raise AssertionError("native parser was called") + + monkeypatch.setattr(final_type, "load_model", forbidden) + native_member = ( + mlbundle.MemberName.UBJSON if library == "xgboost" else mlbundle.MemberName.CBM + ) + members[native_member] = b"not a native payload" + with pytest.raises(mlbundle.InvalidModelError): + mlbundle.load(write_container(record, members)) diff --git a/tests/test_pipeline_width.py b/tests/test_pipeline_width.py new file mode 100644 index 0000000..3aa00ac --- /dev/null +++ b/tests/test_pipeline_width.py @@ -0,0 +1,263 @@ +"""Closed fitted width transitions, successful prediction and malformed state.""" + +import copy +import warnings + +import numpy as np +import pytest +from sklearn.compose import ColumnTransformer +from sklearn.decomposition import PCA +from sklearn.feature_selection import ( + GenericUnivariateSelect, + SelectFdr, + SelectFpr, + SelectFwe, + SelectKBest, + SelectPercentile, + VarianceThreshold, +) +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import ( + FunctionTransformer, + KBinsDiscretizer, + MaxAbsScaler, + MinMaxScaler, + Normalizer, + OneHotEncoder, + OrdinalEncoder, + PolynomialFeatures, + RobustScaler, + StandardScaler, +) + +import mlbundle +from mlbundle._sklearn.transform_width import output_width +from mlbundle._sklearn.trusted import NUMPY_FUNCTIONS +from pipeline_helpers import data, estimator + + +def transform_cases(): + return [ + StandardScaler(), + StandardScaler(with_mean=False, with_std=False), + MinMaxScaler(), + MaxAbsScaler(), + RobustScaler(), + Normalizer(), + PCA(2), + VarianceThreshold(), + SelectKBest(k=2), + SelectPercentile(percentile=75), + SelectFpr(alpha=1), + SelectFdr(alpha=1), + SelectFwe(alpha=1), + GenericUnivariateSelect(mode="k_best", param=2), + SimpleImputer(), + SimpleImputer(add_indicator=True), + SimpleImputer(strategy="constant", keep_empty_features=True), + PolynomialFeatures(2), + PolynomialFeatures((2, 3)), + PolynomialFeatures(2, interaction_only=True), + *[ + KBinsDiscretizer(n_bins=2, encode=encoding) + for encoding in ("ordinal", "onehot", "onehot-dense") + ], + OneHotEncoder(), + OneHotEncoder(min_frequency=2, drop="if_binary"), + OrdinalEncoder(), + OrdinalEncoder(min_frequency=2), + *[FunctionTransformer(func) for func in (None, *NUMPY_FUNCTIONS)], + Pipeline([("sc", StandardScaler()), ("pca", PCA(2))]), + ] + + +@pytest.mark.parametrize("transform", transform_cases(), ids=lambda x: type(x).__name__) +def test_width_roundtrip(transform, tmp_path): + frame, y = data() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model = Pipeline([("transform", transform), ("est", estimator())]).fit(frame, y) + transformed = transform.transform(frame) + assert output_width(transform, 3) == transformed.shape[1] + path = tmp_path / "width.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert ( + bundle.manifest.model.pipeline.transformed_features.n_features + == transformed.shape[1] + ) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize("add_indicator", [False, True]) +@pytest.mark.parametrize("keep_empty", [False, True]) +def test_imputer_empty_columns(add_indicator, keep_empty, tmp_path): + frame, y = data() + frame["b"] = np.nan + model = Pipeline( + [ + ( + "imp", + SimpleImputer( + add_indicator=add_indicator, keep_empty_features=keep_empty + ), + ), + ("est", estimator()), + ] + ) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + model.fit(frame, y) + path = tmp_path / "empty.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert ( + bundle.manifest.model.pipeline.transformed_features.n_features + == 2 + int(keep_empty) + int(add_indicator) + ) + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize("remainder", ["drop", "passthrough", StandardScaler()]) +@pytest.mark.parametrize("columns", [["a", "b"], [0, 1], [True, True, False]]) +def test_column_routing_and_remainder(remainder, columns, tmp_path): + frame, y = data() + transform = ColumnTransformer( + [("scale", StandardScaler(), columns), ("empty", StandardScaler(), [])], + remainder=remainder, + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + path = tmp_path / "column.mlbundle" + mlbundle.export(model, path) + assert np.array_equal( + mlbundle.load(path).predict_proba(frame), model.predict_proba(frame) + ) + + +def test_column_sparse_strings_and_names(tmp_path): + frame, y = data() + frame["b"] = np.where(y, "high", "low") + transform = ColumnTransformer( + [ + ("enc", OneHotEncoder(handle_unknown="ignore"), ["b"]), + ( + "num", + Pipeline([("imp", SimpleImputer()), ("sc", StandardScaler())]), + ["a", "c"], + ), + ], + sparse_threshold=1, + ) + model = Pipeline([("ct", transform), ("est", estimator())]).fit(frame, y) + path = tmp_path / "sparse.mlbundle" + mlbundle.export(model, path) + bundle = mlbundle.load(path) + assert transform.sparse_output_ + assert bundle.manifest.model.pipeline.estimator.claims.feature_names is None + assert bundle.manifest.model.pipeline.transformed_features.names == [ + "enc__b_high", + "enc__b_low", + "num__a", + "num__c", + ] + assert np.array_equal(bundle.predict_proba(frame), model.predict_proba(frame)) + + +@pytest.mark.parametrize( + "factory,attribute,value", + [ + (StandardScaler, "scale_", np.ones(2)), + (StandardScaler, "n_features_in_", True), + (StandardScaler, "n_samples_seen_", np.ones(2)), + (MinMaxScaler, "data_min_", np.ones(2)), + (lambda: PCA(2), "components_", np.ones((2, 2))), + (lambda: PCA(2), "n_components_", 1.5), + (VarianceThreshold, "variances_", np.ones(2)), + (lambda: SelectKBest(k=2), "scores_", np.ones(2)), + (SimpleImputer, "statistics_", np.ones(2)), + (lambda: SimpleImputer(add_indicator=True), "indicator_", None), + (OneHotEncoder, "categories_", [np.array([1])]), + (OneHotEncoder, "_n_features_outs", [1, 1, 1]), + (OrdinalEncoder, "_missing_indices", {0: 99}), + (lambda: KBinsDiscretizer(n_bins=2), "n_bins_", np.array([2, 2])), + (lambda: KBinsDiscretizer(n_bins=2), "bin_edges_", [np.array([0, 0, 1])] * 3), + (PolynomialFeatures, "n_output_features_", 1), + (PolynomialFeatures, "_max_degree", 5), + ], +) +def test_malformed_fitted_dimensions(factory, attribute, value, tmp_path): + frame, y = data() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + transform = factory().fit(frame, y) + model = Pipeline( + [("tr", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + setattr(transform, attribute, value) + with pytest.raises(mlbundle.MLBundleError): + mlbundle.export(model, tmp_path / "never.mlbundle") + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize( + "attribute,value", + [ + ("_columns", [[2]]), + ("_transformer_to_input_indices", {"sc": [True], "remainder": [1, 2]}), + ("_transformer_to_input_indices", {"sc": [0], "remainder": [2]}), + ("output_indices_", {"sc": slice(0, 2), "remainder": slice(2, 4)}), + ("sparse_output_", 1), + ("remainder", "drop"), + ], +) +def test_column_cache_disagreement(attribute, value, tmp_path): + frame, y = data() + transform = ColumnTransformer( + [("sc", StandardScaler(), [0])], remainder="passthrough" + ).fit(frame) + model = Pipeline( + [("ct", transform), ("est", estimator().fit(transform.transform(frame), y))] + ) + setattr(transform, attribute, value) + with pytest.raises(mlbundle.InvalidModelError, match="ColumnTransformer"): + mlbundle.export(model, tmp_path / "never.mlbundle") + + +def test_grouping_drop_indicator_and_width_mismatches(tmp_path): + frame, y = data() + for transform in [ + OneHotEncoder(min_frequency=3, drop="first"), + SimpleImputer(add_indicator=True), + ]: + training = frame.copy() + if type(transform) is OneHotEncoder: + training[:] = np.repeat([0, 1, 2], [12, 10, 2])[:, None] + transform.fit(training) + leaf = estimator().fit(transform.transform(training), y) + if type(transform) is OneHotEncoder: + transform._default_to_infrequent_mappings[0][0] = 99 + else: + transform.indicator_.features_ = np.array([99]) + with pytest.raises(mlbundle.InvalidModelError, match="dimensions disagree"): + mlbundle.export( + Pipeline([("tr", transform), ("est", leaf)]), + tmp_path / "never.mlbundle", + ) + scaler = StandardScaler().fit(frame) + leaf = estimator().fit(frame[["a", "b"]], y) + with pytest.raises( + mlbundle.InvalidModelError, match="output width disagrees with final estimator" + ): + mlbundle.export( + Pipeline([("sc", scaler), ("est", leaf)]), tmp_path / "never.mlbundle" + ) + nested = None + for _ in range(18): + nested = Pipeline([("p", nested)]) + with pytest.raises(mlbundle.UnsupportedModelError, match="nesting exceeds"): + output_width(nested, 3) + changed = copy.deepcopy(scaler) + changed.n_features_in_ = 2 + with pytest.raises(mlbundle.InvalidModelError, match="input widths disagree"): + output_width(changed, 3) diff --git a/tests/test_readme.py b/tests/test_readme.py index 8dd08ff..1883aba 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -18,7 +18,9 @@ @pytest.mark.parametrize( - "code", PYTHON, ids=["sklearn", "xgboost", "catboost", "onnx", "schema-samples"] + "code", + PYTHON, + ids=["sklearn", "xgboost", "catboost", "composite", "onnx", "schema-samples"], ) def test_python_example(code, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -29,7 +31,7 @@ def test_python_example(code, tmp_path, monkeypatch): def test_console_examples(tmp_path, monkeypatch): - assert len(PYTHON) == 5 + assert len(PYTHON) == 6 assert all(language in {"python", "shell"} for language, _ in BLOCKS) monkeypatch.chdir(tmp_path) exec(compile(PYTHON[0], str(README), "exec"), {}) diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py index 4c4b39b..3d4aa94 100644 --- a/tests/test_sklearn.py +++ b/tests/test_sklearn.py @@ -181,7 +181,17 @@ def test_multilabel_chain_is_rejected(data, tmp_path): ), (LinearSVC(), "predict_proba"), (HistGradientBoostingClassifier(), "TreePredictor"), - (Pipeline([("xgb", XGBClassifier(n_estimators=2, n_jobs=1))]), "xgboost"), + ( + Pipeline( + [ + ( + "inner", + Pipeline([("xgb", XGBClassifier(n_estimators=2, n_jobs=1))]), + ) + ] + ), + "xgboost", + ), ], ) def test_export_rejections(model, match, data, tmp_path): From c5785c5d803e3fe36cd7a1c1b4b858dffe8ec474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Ulloa=20Sant=C3=ADn?= Date: Tue, 13 Oct 2026 11:32:07 +0200 Subject: [PATCH 15/15] ci: prepare manual pre-alpha PyPI publishing (#17) --- .github/workflows/ci.yml | 60 +++++++++++++++++++++++++++++++++-- .github/workflows/publish.yml | 36 +++++++++++++++++++++ AGENTS.md | 16 +++++----- README.md | 46 ++++++++++++++------------- SECURITY.md | 5 +-- docs/publishing.md | 59 ++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++-- scripts/package_smoke.py | 28 +++++++++++++--- scripts/publish_check.py | 34 ++++++++++++++++++++ tests/test_environment.py | 4 +-- tests/test_manifest.py | 7 ++++ tests/test_publish.py | 54 +++++++++++++++++++++++++++++++ tests/test_writing.py | 14 ++++---- uv.lock | 2 +- 14 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 docs/publishing.md create mode 100644 scripts/publish_check.py create mode 100644 tests/test_publish.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1299357..db55b91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,47 @@ on: branches: [main, develop] pull_request: branches: [main, develop] + workflow_call: + inputs: + prepare-publish: + description: Require main and an unpublished version before building + type: boolean + default: false permissions: contents: read jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + - name: Check publish ref and PyPI version + if: inputs.prepare-publish + run: python scripts/publish_check.py + - name: Install UV CLI + run: python -m pip install uv==0.12.19 + - name: Build wheel and source archive + run: uv build + - name: Validate metadata and README rendering + run: uvx twine check --strict dist/* + - name: Upload distributions for testing and publishing + uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + if-no-files-found: error + base-install: name: Base wheel smoke (${{ matrix.os }}, Python 3.14) runs-on: ${{ matrix.os }} + needs: build strategy: fail-fast: false matrix: @@ -26,8 +59,11 @@ jobs: python-version: '3.14' - name: Install UV CLI run: python -m pip install uv==0.12.19 - - name: Build wheel and source archive - run: uv build + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist - name: Install fixture producer from lockfile run: uv sync --locked --group dev --all-extras - name: Produce bundle and check artifact contents @@ -86,6 +122,7 @@ jobs: tests: name: Tests (${{ matrix.os }}, Python 3.14) runs-on: ${{ matrix.os }} + needs: build strategy: fail-fast: false matrix: @@ -101,8 +138,25 @@ jobs: run: python -m pip install uv==0.12.19 - name: Install development dependencies from lockfile run: uv sync --locked --group dev --all-extras + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist + - name: Install the wheel that will be published + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + test_python="$GITHUB_WORKSPACE/.venv/Scripts/python.exe" + else + test_python="$GITHUB_WORKSPACE/.venv/bin/python" + fi + uv pip install --python "$test_python" --no-deps --reinstall dist/*.whl + "$test_python" -I -c 'import mlbundle; from pathlib import Path; assert "site-packages" in Path(mlbundle.__file__).parts; print(mlbundle.__version__, mlbundle.__file__)' - name: Run tests with coverage - run: uv run pytest + shell: bash + working-directory: ${{ runner.temp }} + run: uv run --project "$GITHUB_WORKSPACE" --no-sync pytest "$GITHUB_WORKSPACE/tests" -c "$GITHUB_WORKSPACE/pyproject.toml" --cov-config="$GITHUB_WORKSPACE/pyproject.toml" --cov-report="xml:$GITHUB_WORKSPACE/coverage.xml" - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..054c4a0 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,36 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mlbundle-pypi-publish + cancel-in-progress: false + +jobs: + build: + uses: ./.github/workflows/ci.yml + with: + prepare-publish: true + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi_env + url: https://pypi.org/p/mlbundle + permissions: + id-token: write + steps: + - name: Download CI-validated distributions + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist + - name: Publish to PyPI with Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/AGENTS.md b/AGENTS.md index f90c248..1f030bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,8 +136,8 @@ still preserve validation, security and reproducibility. or PR text. - `C:\software\mlsuite` and `C:\software\mlschema` are read-only references. Never modify, commit or run write commands inside either checkout. -- Do not publish to PyPI, create releases or version tags, or add a publish job. - "Tags" in this project means GitHub repository topics. +- Publish only via manual `publish.yml` from `main`, through `pypi_env` with owner approval, on the owner's explicit request for that specific version. + Agents never run or approve it on their own initiative or add publish paths/tokens/secrets. No GitHub releases/version tags; "tags" means GitHub repository topics. - sklearn-to-ONNX conversion, per-model environments, bundle signing and additional libraries (including LightGBM and PyTorch) are outside the project scope. - Never claim a verification that was not run; report its real output or blocker. @@ -154,8 +154,8 @@ uv run pre-commit install ``` After changing dependency metadata, use `uv sync` to regenerate `uv.lock` and -commit the lockfile. CI uses `uv sync --locked --group dev --all-extras` on Python 3.14. -The default dev group installs `mlbundle[all]`; base users can omit the dev group. +commit the lockfile. CI and manual `publish.yml` use locked dev/all extras on 3.14. +Default dev installs `mlbundle[all]`; base users can omit it. Publishing reuses CI. Ruff handles lint and formatting; Pyright uses the family's basic mode. ## Verifying @@ -174,8 +174,8 @@ The Ruff commands also work without the final `.` because the default is the current directory: `uv run ruff check` and `uv run ruff format --check`. `uv run pytest` runs `tests/`, measures branch coverage and enforces the family's 80% minimum, writing terminal and XML reports. Tests use the installed package; -its version comes from package metadata. CI checks lint and types and runs tests -and a clean base-wheel smoke check on Ubuntu and Windows. The local build must produce a wheel and source archive. +its version comes from package metadata. CI checks lint/types and the built wheel +on Ubuntu/Windows; manual `publish.yml` requires that CI. Build a wheel and sdist. Start with the smallest proof of a behavior change, then broaden when needed. If a check cannot run, report the exact command and blocker. Never claim a test, @@ -285,9 +285,9 @@ its kind/count inference remains ONNX-owned. Keep native behavior in adapters; f - `src/mlbundle/inspection.py`, `results.py` — metadata-only inspection and result records without adapter resolution. - `docs/format.md` — version 1 wire specification; linked container, adapter/structure, ONNX and schema/sample sections are normative. - `tests/` — one file per feature, with success and error cases together. -- `scripts/package_smoke.py` — fixture, isolated install and artifact-content checks. +- `scripts/package_smoke.py`, `publish_check.py` — artifact checks and publish preflight. - `pyproject.toml` and `uv.lock` — package metadata, tool configuration and lock. -- `.github/workflows/ci.yml` — lint, types and cross-platform tests. +- `.github/workflows/ci.yml`, `publish.yml` — shared CI and owner-approved publishing. - `.pre-commit-config.yaml` — Ruff, Pyright and basic file hygiene. - `README.md`, `SECURITY.md` and this file — project and contributor context. diff --git a/README.md b/README.md index f2dbd77..e9f98aa 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,15 @@ mlbundle replaces three gaps in separate model/DataFrame uploads: - **Training data leaving the machine:** schema inference runs locally; training rows are included only when explicitly requested as samples. -**Pre-release v0.1.0; format version 1 is not yet frozen.** Nothing is published -on PyPI. Requires **Python >=3.14**. The tested baseline is CPython 3.14 on -Windows and Linux; the schema extra currently works only on Python 3.14. +**Pre-alpha v0.1.0a1, published to reserve the PyPI name.** Format version 1 is +not frozen; bundles may need re-export. The security design has not had an +independent review: do not accept bundles from untrusted parties. +Requires **Python >=3.14**; tested on Windows/Linux. Schema requires Python 3.14. ## Install and try scikit-learn -Install from Git with -`python -m pip install "mlbundle[sklearn] @ git+https://github.com/UlloaSP/mlbundle.git@develop"`. +Install the pre-release with +`python -m pip install --pre "mlbundle[sklearn]"`. Replace `[sklearn]` with `[xgboost]`, `[catboost]`, `[onnx]`, `[schema]` or `[all]` for the corresponding dependencies. Combine extras as `[sklearn,schema]` for local DataFrame inference. Omit the brackets for a base install that can inspect @@ -99,7 +100,7 @@ assert bundle.predict_proba(rows) == model.predict_proba(rows).tolist() Depthwise/Lossguide, custom Python objectives/metrics and other losses are rejected. Classifier losses: Logloss, CrossEntropy, MultiClass. Regressors: RMSE, MAE, Quantile, Huber, Poisson, MAPE. Complete trees/borders/NaN handling/importances -are verified on load. [The CatBoost contract](docs/format-catboost.md) details +are verified on load. [The CatBoost contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-catboost.md) details wrapper recovery, native-parser limits and the private in-memory serializer. ## Composite pipelines @@ -132,7 +133,7 @@ Each needs a fitted, data-free width proof. Unknown widths, FeatureUnion/KNNImpu slice/callable/scalar selectors, caching, metadata routing and other native placements remain rejected. Step names/outer options are declarations; trees address transformed features, without original-feature attribution. Schema/dtypes/samples describe original -inputs; six members permit schema plus explicit samples. See [the pipeline contract](docs/format-pipeline.md). +inputs; six members permit schema plus explicit samples. See [the pipeline contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-pipeline.md). ## Existing ONNX files @@ -157,7 +158,7 @@ Paths and bytes are preserved unchanged; a ModelProto is serialized once. There is no conversion. `native_model` contains the original `data` and CPU `session`. Native graph labels are verified; supplied labels must agree with them. Without native/supplied labels, encoded indices are generated. ONNX has -no visualization structure. See [the exact ONNX contract](docs/format-onnx.md). +no visualization structure. See [the exact ONNX contract](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). ## Local schema and explicit samples @@ -199,7 +200,7 @@ steps, categories (including unused), one-hot suffixes, pair-axis metadata and class labels. No inferred ranges, means, counts or arbitrary text/example rows. Names/categories and learned models/structure can reveal training information. Explicit `sample_rows=N` includes the first N feature rows, without target/index; -N must be positive and within the frame. See [schema and samples](docs/format-schema.md). +N must be positive and within the frame. See [schema and samples](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-schema.md). ## Inspect, load and the CLI @@ -229,9 +230,9 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. dense brute-force neighbors, NearestCentroid, MLP and MultiOutput/RegressorChain. SimpleImputer/KNNImputer, scalers, KBinsDiscretizer, categorical encoders, univariate selectors and closed NumPy FunctionTransformer functions round-trip, - including nested ColumnTransformer pipelines; see [the exact lists](docs/format-state.md). + including nested ColumnTransformer pipelines; see [the exact lists](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). All-sklearn Pipeline structure stays null; composite trees expose the inner boundary. See - [tested configurations](tests/test_sklearn_families.py) and [core round trips/rejections](tests/test_sklearn.py). + [tested configurations](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn_families.py) and [core round trips/rejections](https://github.com/UlloaSP/mlbundle/blob/main/tests/test_sklearn.py). - **Sklearn rejections:** custom estimators/subclasses, functions/types outside the closed lists (including lambdas and user functions), compound fitted dtypes, classifiers without probabilities, multilabel/ClassifierChain layouts, unsupported native placements, @@ -240,22 +241,22 @@ dependency rejection (`MLBundleError`), **2** usage/I/O failure. - **Sklearn versions:** the closed class/attribute catalog is audited/tested against **1.9.1 with skops 0.16.0**, not every version allowed by `>=1.6`. Unknown state fails; export/load sklearn versions must match. Configurations - must satisfy the catalog and round trip; see [state ownership](docs/format-state.md). + must satisfy the catalog and round trip; see [state ownership](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md). - **XGBoost:** exact XGBClassifier/XGBRegressor, one target, native gbtree (including XGBoost 3.4.1 dart normalized to gbtree). Closed probability/regression objectives; raw boosters, RF wrappers, subclasses, ranking/survival/custom objectives and unsupported storage are rejected. At most 100,000 trees/depth 14, - with complete state/dumps; see [the mapping](docs/format-adapters.md#implemented-xgboost-mapping). + with complete state/dumps; see [the mapping](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-adapters.md#implemented-xgboost-mapping). - **ONNX:** fixed-width rank-two numeric inputs; one regression/probability target, optional native labels and dynamic/one-row batches. Unsupported layouts, external data/custom domains/functions and runtime/training configuration fail - before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](docs/format-onnx.md). + before CPU sessions. Tested with ONNX 1.23.0/Runtime 1.30.0; see [ONNX bounds](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md). Archive ceilings: **6 members**, **512 MiB** archive/compressed/member bytes, **1 GiB** aggregate uncompressed bytes, ratio **200**, JSON depth **32**. Manifest: **8 MiB**; structure: **256 MiB**; schema/samples: **16 MiB each**. Features/classes: at most **100,000**; outputs: **1,024** (XGBoost/CatBoost/ONNX: one). -Callers may only lower `ArchiveLimits`. Native [state](docs/format-state.md)/[ONNX](docs/format-onnx.md) +Callers may only lower `ArchiveLimits`. Native [state](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-state.md)/[ONNX](https://github.com/UlloaSP/mlbundle/blob/main/docs/format-onnx.md) bounds do not cap process memory, prediction rows or CPU. ## Bundle contents and security @@ -267,7 +268,7 @@ Every bundle contains `manifest.json` and one native member, or the declared The manifest records identity/time/writer, model parameters/counts/state, ordered features/dtypes/origin, target labels/name, environment, member SHA-256/sizes, references and free-form user metadata. The normative -[format specification](docs/format.md) owns these records and verification rules. +[format specification](https://github.com/UlloaSP/mlbundle/blob/main/docs/format.md) owns these records and verification rules. Treat bundles as untrusted. The reader validates ZIP layout, allowed members, limits, strict JSON/manifest and hashes; adapters use closed trust/loading paths @@ -275,7 +276,7 @@ and recompute claims. **Hashes detect corruption, not deliberate tampering**; there is no signing or proof of authorship, training history, quality or fairness. Schema, samples and training-environment declarations are not authenticated. Native parsers/inference require process isolation, memory/CPU/row limits and -maintained dependencies. See [SECURITY.md](SECURITY.md) for guarantees and reporting. +maintained dependencies. See [SECURITY.md](https://github.com/UlloaSP/mlbundle/blob/main/SECURITY.md) for guarantees and reporting. ## Development @@ -288,8 +289,9 @@ versions; dependency edits require `uv sync` and committing the updated lockfile Run `uv run ruff check .`, `uv run ruff format --check .`, `uv run pyright`, `uv run pytest` and `uv build`. Tests execute every fenced README example and write terminal/XML coverage with an 80% branch-inclusive minimum. -CI preserves lint/types and Ubuntu/Windows tests and checks a clean base wheel -installation on both OSes. `uv build` creates a local wheel and source archive. -Read [AGENTS.md](AGENTS.md) before contributing. Publishing, releases, version -tags, conversions, per-model environments, signing and additional model libraries -remain outside scope. The project is MIT licensed. +CI checks lint/types and tests the same built wheel on Ubuntu/Windows, including +clean base installs. The owner, or an agent on the owner's explicit request for +that version, runs manual [publishing](https://github.com/UlloaSP/mlbundle/blob/main/docs/publishing.md) from `main` with `pypi_env` approval. Read [AGENTS.md](https://github.com/UlloaSP/mlbundle/blob/main/AGENTS.md). +Agents never run or approve it on their own initiative or add publish paths, tokens or secrets. +Releases, version tags, conversions, per-model environments, signing and additional +model libraries remain outside scope. The project is MIT licensed. diff --git a/SECURITY.md b/SECURITY.md index 93c422c..1073721 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,8 @@ # Security policy -mlbundle v0.1 is pre-release; format version 1 is not yet frozen. It implements -strict manifest validation, secure container reading/writing, metadata-only +The published v0.1.0a1 pre-alpha has not had an independent security review; +do not accept bundles from untrusted parties. Format version 1 is not frozen. +It implements strict manifest validation, secure container reading/writing, metadata-only `inspect`, export/load, sklearn, XGBoost, CatBoost and ONNX adapters, local schema inference, explicit samples, prediction-state verification and the inspect/verify CLI. The container validates ZIP/ZIP64 layout, bounds sources and actual decompression, diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 0000000..69b8691 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,59 @@ +# Manual pre-alpha publishing + +Version `0.1.0a1` reserves the PyPI name `mlbundle`. Format version 1 is not +frozen, so bundles may need re-export. The security design has not had an +independent review; do not accept bundles from untrusted parties. + +The owner, or an agent on the owner's explicit request for that specific version, +runs this workflow. Agents never run or approve it on their own initiative. +There is no API token, repository secret, tag, GitHub release or other publish path. + +## Publishing steps + +The owner, or an agent on the owner's explicit request, follows these steps: + +1. Review and merge the change into `develop`, then bring it into `main` through + the normal reviewed integration process. The workflow refuses any other ref. +2. Confirm the registered PyPI pending publisher at + [account publishing](https://pypi.org/manage/account/publishing/) has these + exact values: project `mlbundle`, owner `UlloaSP`, repository `mlbundle`, + workflow `publish.yml`, environment `pypi_env`. Confirm the GitHub `pypi_env` + environment still requires `UlloaSP` approval and permits only `main`. +3. Open [Publish to PyPI](https://github.com/UlloaSP/mlbundle/actions/workflows/publish.yml), + choose **Run workflow**, select **main**, and run it once. +4. Wait for the build/CI jobs to succeed. Review the run's commit and downloaded + `distributions` artifact, containing the `0.1.0a1` wheel and source archive. + Approve the `pypi_env` deployment when satisfied. This approval permits upload. +5. Check [mlbundle on PyPI](https://pypi.org/project/mlbundle/0.1.0a1/) and install + with `python -m pip install --pre "mlbundle[sklearn]"` on Python 3.14. + +A pending publisher does not reserve a name until a successful upload. The +[PyPI pending-publisher documentation](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) +explains this first-upload step. If publishing fails or only one artifact uploads, +inspect the PyPI state before taking further action; the preflight rejects an +existing version and the workflow does not skip existing files. + +## What the workflow checks + +`publish.yml` has two jobs. Its `build` job calls `ci.yml` from the same commit, +with publishing preflight enabled. That preflight checks `refs/heads/main`, reads +the version from `pyproject.toml`, and requires HTTP 404 from the exact PyPI +version endpoint. An existing version, other HTTP errors or a network failure +stop the run. One concurrency group prevents overlapping publish runs. + +Reusable CI builds one wheel and source archive with Python 3.14, uv 0.12.19 and +`uv_build`. Strict Twine checks validate both artifacts' metadata and README +rendering. Both OSes check artifact contents, matching wheel/sdist package bytes, +and an isolated base-wheel install. Ubuntu and Windows install that exact wheel +and run the full test suite; lint and type checks must also succeed. Tests run +from the runner's temporary directory so CatBoost writes stay outside the checkout. + +The artifact is immutable within the run. `publish` waits for all reusable CI +jobs and `pypi_env` approval, downloads `distributions` and uploads those files without +rebuilding or checking out source. Only this job has `id-token: write`; the +workflow otherwise permits only `contents: read`. Trusted Publishing exchanges +OIDC identity for short-lived PyPI credentials through the documented +[PyPA publish action](https://github.com/pypa/gh-action-pypi-publish). + +Publication and OIDC authorization cannot be tested locally. The first authorized +upload is the verification of the registered publisher and approval gate. diff --git a/pyproject.toml b/pyproject.toml index 9c15a97..2e053f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mlbundle" -version = "0.1.0" +version = "0.1.0a1" description = "Pickle-free, verifiable model bundles with a manifest, schema and structure." readme = "README.md" license = "MIT" @@ -58,7 +58,7 @@ all = [ Homepage = "https://github.com/UlloaSP/mlbundle" Repository = "https://github.com/UlloaSP/mlbundle" Issues = "https://github.com/UlloaSP/mlbundle/issues" -Documentation = "https://github.com/UlloaSP/mlbundle/blob/develop/README.md" +Documentation = "https://github.com/UlloaSP/mlbundle/blob/main/README.md" [dependency-groups] dev = [ @@ -99,7 +99,8 @@ filterwarnings = [ source = ["mlbundle"] branch = true parallel = true -omit = ["*/tests/*", "*/__pycache__/*", "*/site-packages/*"] +# CI measures the installed wheel inside site-packages. +omit = ["*/tests/*", "*/__pycache__/*"] [tool.coverage.report] fail_under = 80 diff --git a/scripts/package_smoke.py b/scripts/package_smoke.py index d9c0201..e3b4de4 100644 --- a/scripts/package_smoke.py +++ b/scripts/package_smoke.py @@ -52,7 +52,7 @@ def _base(path: Path) -> None: import mlbundle - assert mlbundle.__version__ == "0.1.0" + assert mlbundle.__version__ == "0.1.0a1" view = mlbundle.inspect(path) assert view.manifest.model.library == "sklearn" assert "cannot attest" in _console("--help") @@ -119,14 +119,34 @@ def _contents(directory: Path) -> None: assert any(name.endswith(".dist-info/licenses/LICENSE") for name in wheel_names) assert any(name.endswith(".dist-info/entry_points.txt") for name in wheel_names) assert all(name.startswith(("mlbundle/", "mlbundle-")) for name in wheel_names) + wheel_files = { + name: archive.read(name) + for name in wheel_names + if name.startswith("mlbundle/") and not name.endswith("/") + } with open_tar(sdist) as archive: sdist_names = archive.getnames() assert any(name.endswith("/src/mlbundle/py.typed") for name in sdist_names) assert any(name.endswith("/LICENSE") for name in sdist_names) + prefix = sdist.name.removesuffix(".tar.gz") + "/src/" + sdist_files = {} + for member in archive.getmembers(): + if member.isfile() and member.name.startswith(prefix): + stream = archive.extractfile(member) + assert stream is not None + sdist_files[member.name.removeprefix(prefix)] = stream.read() + assert wheel_files == sdist_files, "Wheel and sdist package bytes must match" for name in [*wheel_names, *sdist_names]: - assert not set(Path(name).parts) & {"tests", ".venv", ".git", "__pycache__"}, ( - name - ) + assert not set(Path(name).parts) & { + "tests", + ".venv", + ".git", + "__pycache__", + "catboost_info", + ".pytest_cache", + ".ruff_cache", + "htmlcov", + }, name assert not name.endswith((".pyc", ".mlbundle", "coverage.xml")), name sys.stdout.write( f"artifacts: {wheel.name}, {sdist.name}; py.typed/catalog/license present; no tests or stray files\n" diff --git a/scripts/publish_check.py b/scripts/publish_check.py new file mode 100644 index 0000000..e778ab2 --- /dev/null +++ b/scripts/publish_check.py @@ -0,0 +1,34 @@ +"""Read-only preflight for the owner's manual PyPI workflow.""" + +import os +import sys +import tomllib +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import urlopen + + +def check(ref: str, project: dict) -> None: + if ref != "refs/heads/main": + raise SystemExit("Publishing requires refs/heads/main; select main.") + version = project["version"] + url = f"https://pypi.org/pypi/mlbundle/{version}/json" + try: + with urlopen(url, timeout=30): + pass + except HTTPError as error: + error.close() + if error.code != 404: + raise SystemExit(f"PyPI version check failed: HTTP {error.code}.") from None + except (URLError, TimeoutError) as error: + raise SystemExit(f"PyPI version check failed: {error}.") from None + else: + raise SystemExit(f"mlbundle {version} already exists on PyPI; refusing upload.") + sys.stdout.write(f"mlbundle {version} is not on PyPI (HTTP 404); main ref OK.\n") + + +if __name__ == "__main__": + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + check(os.environ["GITHUB_REF"], project) diff --git a/tests/test_environment.py b/tests/test_environment.py index bded544..ad521d3 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -20,7 +20,7 @@ def test_installed_environment(): def test_missing_distributions_and_onnx(monkeypatch): def version(name): if name == "mlbundle": - return "0.1.0" + return "0.1.0a1" raise metadata.PackageNotFoundError(name) monkeypatch.setattr(module.metadata, "version", version) @@ -31,7 +31,7 @@ def version(name): producer_version="", ) environment = module.capture_environment(onnx=graph) - assert environment.packages == {"mlbundle": "0.1.0"} + assert environment.packages == {"mlbundle": "0.1.0a1"} assert environment.onnx == graph diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d60837c..8d6ec2c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -18,6 +18,13 @@ def read(data: dict) -> Manifest: return Manifest.from_bytes(canonical_dumps(data)) +@pytest.mark.parametrize("writer_version", ["0.1.0", "0.1.0a1"]) +def test_writer_version_is_independent_of_reader(manifest_data, writer_version): + manifest_data["writer_version"] = writer_version + manifest_data["environment"]["packages"]["mlbundle"] = writer_version + assert read(manifest_data).writer_version == writer_version + + def test_round_trip_and_claim_projection(manifest_data): manifest = read(manifest_data) assert Manifest.from_bytes(manifest.to_bytes()) == manifest diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 0000000..f36194a --- /dev/null +++ b/tests/test_publish.py @@ -0,0 +1,54 @@ +"""Manual publish preflight rejects other refs, existing versions and outages.""" + +import runpy +from contextlib import nullcontext +from pathlib import Path +from urllib.error import HTTPError, URLError + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "publish_check.py" + + +@pytest.fixture +def preflight(): + return runpy.run_path(str(SCRIPT))["check"] + + +@pytest.mark.parametrize("ref", ["refs/heads/develop", "refs/tags/v0.1.0a1", ""]) +def test_other_refs_fail_before_network(preflight, monkeypatch, ref): + def unexpected(*args, **kwargs): + raise AssertionError("Network must not be used for other refs") + + monkeypatch.setitem(preflight.__globals__, "urlopen", unexpected) + with pytest.raises(SystemExit, match="requires refs/heads/main"): + preflight(ref, {"version": "0.1.0a1"}) + + +@pytest.mark.parametrize("code", [200, 404, 403, 429, 500]) +def test_exact_version_response(preflight, monkeypatch, capsys, code): + def response(url, timeout): + assert url == "https://pypi.org/pypi/mlbundle/0.1.0a1/json" + assert timeout == 30 + if code != 200: + raise HTTPError(url, code, "response", None, None) + return nullcontext() + + monkeypatch.setitem(preflight.__globals__, "urlopen", response) + if code == 404: + preflight("refs/heads/main", {"version": "0.1.0a1"}) + assert "HTTP 404" in capsys.readouterr().out + else: + message = "already exists" if code == 200 else f"HTTP {code}" + with pytest.raises(SystemExit, match=message): + preflight("refs/heads/main", {"version": "0.1.0a1"}) + + +@pytest.mark.parametrize("error", [URLError("offline"), TimeoutError("timeout")]) +def test_network_failure_is_closed(preflight, monkeypatch, error): + def response(*args, **kwargs): + raise error + + monkeypatch.setitem(preflight.__globals__, "urlopen", response) + with pytest.raises(SystemExit, match="PyPI version check failed"): + preflight("refs/heads/main", {"version": "0.1.0a1"}) diff --git a/tests/test_writing.py b/tests/test_writing.py index 7031d13..8e42e92 100644 --- a/tests/test_writing.py +++ b/tests/test_writing.py @@ -163,30 +163,30 @@ def test_chunked_model_and_empty_model(manifest_data): [ ( b"native model bytes", - "7349202ed42da9cbf06588566b130261bc2e09ba11a7193ae3fa3d561079e773", + "8e4bea2f61364aa5247df42588f6ad89065bcf87dc65a96d0cc185b602b27fe8", ), ( random.Random(0).randbytes(200_000), - "96bad29c8a6736e010e6fd0a2b80413bb6b1c716e9970770d7e1b2b514ae368c", + "f32df46fe39f515d09403af12a2e64bbee1fe4c579ad4229c14f187d397eb473", ), ( b"hello world " * 10_000, - "11b89928584fa5eac0950d48cffcdbd77b2d42ead5a36b7b381c2f4e512ffa36", + "2cfab1749bdca7b97cfd6eba990e16054f4b7404ac8a8a2c3a818c95d04150c8", ), ( bytes(range(256)) * 500, - "e9d931bd6cb9e0e77b9fafc66f28c2065d720167e0032f5f0f3ceff82a949014", + "a2d903caee80044799e2640cd5672e26c2fe13d0a0eddf1a5a9d061f12b9c942", ), ( b"0" * 1000, - "cf5bbd36f140cd2af5feffca7c4c67b288120bb44d33e558d927520a21f23eb7", + "5e92b884593597b822b7a72326b06084fe4c85ee028c164869115895f52dbc41", ), ], ids=["native", "noise", "text", "uniform", "runs"], ) def test_fixed_archive_bytes_on_both_platforms(manifest_data, data, expected): - manifest_data["writer_version"] = "0.1.0" - manifest_data["environment"]["packages"]["mlbundle"] = "0.1.0" + manifest_data["writer_version"] = "0.1.0a1" + manifest_data["environment"]["packages"]["mlbundle"] = "0.1.0a1" bundle = write_container(manifest_data, {MemberName.SKOPS: data}) assert hashlib.sha256(bundle).hexdigest() == expected diff --git a/uv.lock b/uv.lock index a237765..251f4eb 100644 --- a/uv.lock +++ b/uv.lock @@ -459,7 +459,7 @@ wheels = [ [[package]] name = "mlbundle" -version = "0.1.0" +version = "0.1.0a1" source = { editable = "." } dependencies = [ { name = "pydantic" },