diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 3186d7f..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 548f424..bb1897f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and thi adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — with the caveat that the public API is not frozen until `1.0.0`. Pin an exact version. +## [0.1.1] + +A patch over `0.1.0`. Every package is released together at the new version, including +the ones `0.1.0` did not break, so that one version number still describes one tested +set. + +### Fixed + +- **`lowest_landed_cost` could choose a container its own rate card cannot price.** When + one container billed lighter than another but its rate table ran out before the + shipment's billed weight, the search preferred it — returning the one packing you + cannot actually buy over a priced alternative. Every engine now compares candidates by + the money the rate table charges rather than by billed weight, which also fixes the + case this objective exists for: a bracket step or a minimum charge can make the + cheaper shipment the heavier one. If no container on offer can price the load, the + request is refused with a message naming the container, its billed weight and the last + bracket, in all four languages — previously two of them returned a result carrying a + sentinel cost, and two aborted requests that had a shippable answer. `RateTable` gains + a non-throwing `charge_minor_or_none` / `chargeMinorOrNull`; the throwing form is + unchanged. No request or result field changed. + +- **`@packvium/engine@0.1.0` could not be imported.** The published tarball was missing + a runtime module that the fallback engine imports, so the first `import` of the package + threw `ERR_MODULE_NOT_FOUND`. npm versions are immutable, which is why the fix has to + arrive as a new version rather than a re-upload. Package assembly now dry-packs the + tarball and resolves every relative import in the real published inventory, so a + missing runtime file fails the release build instead of the consumer's first import. + Only the Node package was affected; the Python, PHP and Rust `0.1.0` releases install + and run correctly. + +### Added + +- **Commercial and control-plane API.** Three deterministic functions over one canonical + JSON document: `quote` returns a landed cost together with the tariff version that + produced it, `evaluate_policy` returns an eligibility decision together with the rule id + and version that decided it, and `catalog_version_info` returns the metadata of one + pinned catalog version. Exported as `packvium.commerce` (Python), `Packvium\Commerce\` + (PHP), `packvium_core::commerce` (Rust, plus three C ABI entry points) and `commerce` + on `@packvium/engine` and `@packvium/browser`. Prices are exact integers in minor + currency units and every inexact division rounds up, so a quote is reproducible rather + than approximately equal. No packing-request or packing-result field changed. Each + package ships a runnable `commerce` example; the contract is in `docs/COMMERCE-API.md`. + ## [0.1.0] First release. diff --git a/README.md b/README.md index c3f0879..8184c18 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,19 @@ print(result["status"]) # "feasible" The adapter does not replace the `packvium` import. It is useful when an application wants one stable call site with a native fast path and a pure-Python fallback. +## Examples + +Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom +and execute without a project around it. + +| File | What it shows | +| --- | --- | +| [`basic.py`](examples/basic.py) | Pack through the adapter and report which backend answered. | + +```bash +python3 examples/basic.py +``` + ## Platforms Native wheels support Linux x86_64/aarch64, macOS Apple Silicon/Intel and Windows. The diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index c163f61..e0a8728 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -48,7 +48,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `0.1.0` is an early release. The public API is not yet frozen: field names, +Version `0.1.1` is an early release. The public API is not yet frozen: field names, status codes and the objective vector may change before `1.0.0`. Pin an exact version. The algorithm complexities documented in `ALGORITHMS-AND-COMPLEXITY.md` are design diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 2d38c76..be693ef 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -357,6 +357,38 @@ details. A structural bound takes precedence over a deadline: an oversized item `proven` even if the overall run timed out. Conversely, `time_limit`, `search_exhausted` and other unfinished-search outcomes can never carry `proven`. +## Commercial and control-plane API + +Three deterministic functions over one canonical JSON document -- a carrier quote, an +eligibility decision, and catalog version metadata. They are a separate surface from the +packing API and add no packing-request or packing-result field; the full contract, with +the document format, every result shape, the closed set of rejection codes, complexity +and limitations, is COMMERCE-API.md. + +| Language | Entry point | +| --- | --- | +| Python | `packvium.commerce.quote(document, request)`, `.evaluate_policy(...)`, `.catalog_version_info(...)`, `.canonical_json(result)` | +| PHP | `Packvium\Commerce\quote(array $document, array $request)`, `evaluatePolicy(...)`, `catalogVersionInfo(...)`, `canonicalJson(...)` | +| Rust | `packvium_core::commerce::quote_json(&str)`, `evaluate_policy_json(&str)`, `catalog_version_info_json(&str)` | +| JavaScript | `commerce.quote(document, request)`, `.evaluatePolicy(...)`, `.catalogVersionInfo(...)` on `@packvium/engine`; the same three, async, on `@packvium/browser` | +| C ABI | `packvium_commerce_quote(call)`, `packvium_commerce_evaluate_policy(call)`, `packvium_commerce_catalog_version_info(call)` | + +The Rust, C ABI and WASM entry points take one JSON string, `{"document": ..., "request": +...}`, and return the result document as a string. Each C ABI function follows the same +pointer contract as `packvium_solve_json`: a valid, immutable, NUL-terminated UTF-8 +input, and an owned result string the caller releases exactly once with +`packvium_free_string`. `@packvium/engine` selects the native addon when it is installed +and the deterministic JavaScript implementation otherwise, the same way `pack` does; +`commerce.backend()` reports which answered. + +Two kinds of failure, and they are not interchangeable. A malformed document or request +is a caller bug and is raised the way each language raises one (`CommerceInputError`, +`Packvium\Commerce\CommerceInputException`, `Err(CommerceInputError)`, a thrown +`CommerceInputError`). A well-formed request the commercial model cannot answer -- no +tariff effective at that instant, no rate for that zone -- is a successful call returning +`"status": "rejected"` with a code from a closed set, exactly as an infeasible packing +request returns a result with a status rather than raising. + ## JSON API Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, diff --git a/examples/basic.py b/examples/basic.py new file mode 100644 index 0000000..57bae9a --- /dev/null +++ b/examples/basic.py @@ -0,0 +1,79 @@ +"""Pack through the adapter, whichever backend happens to be installed. + +Run it: + + python3 examples/basic.py + +This package is a thin selector, not an engine. It tries the compiled Rust wheel +(`packvium-native-rust`) first and uses the pure-Python `packvium` package when that +wheel is not installed for your platform. Both answer the same shared JSON contract, so +your code does not branch on which one is present -- that is the whole point of the +adapter. + +`backend()` tells you which one answered, which is worth logging once at startup: it is +the difference between "we are running the compiled engine" and "we quietly fell back", +and you want to find that out from a log line rather than from a latency graph. +""" + +from packvium_native import __version__, backend, pack + +print(f"adapter {__version__} using the {backend()} backend\n") + +request = { + "items": [ + # Lengths and weights are strings on purpose. They are parsed into exact + # integers, so "0.1" means a tenth of a millimetre and never + # 0.09999999999999999. Plain integers and fractions like "3/16" work too. + { + "id": "mug", + "quantity": 6, + "dimensions": {"length": "120", "width": "120", "height": "100"}, + "weight": "400 g", + }, + { + "id": "plate", + "quantity": 8, + "dimensions": {"length": "260", "width": "260", "height": "20"}, + "weight": "600 g", + }, + # Too long for the box in every orientation, so it cannot be placed. + { + "id": "ladder", + "quantity": 1, + "dimensions": {"length": "1800", "width": "300", "height": "100"}, + "weight": "6 kg", + }, + ], + "containers": [ + { + "id": "box", + "inner_dimensions": {"length": "400", "width": "400", "height": "400"}, + "max_payload": "15 kg", + "cost_minor": 180, + } + ], +} + +result = pack(request) + +print(f"status: {result['status']}") +print(f"containers opened: {len(result['containers'])}") + +for index, container in enumerate(result["containers"], start=1): + placements = container["placements"] + print(f"\nbox #{index}: {len(placements)} placement(s)") + for placement in placements: + # Every measurement arrives as {"ticks", "value", "unit"}: `ticks` is the exact + # integer the engine reasoned about, `value` is that number written for a human. + position = placement["position"] + print( + f" {placement['item_type']:8s} at " + f"({position['x']['value']}, {position['y']['value']}, {position['z']['value']}) " + f"{position['x']['unit']} orientation {placement['orientation']}" + ) + +# A refusal is an answer, not an error. +if result["unpacked_items"]: + print("\nnot packed:") + for unpacked in result["unpacked_items"]: + print(f" {unpacked['item_id']:10s} {unpacked['reason']}") diff --git a/pyproject.toml b/pyproject.toml index 1ad12ef..216236f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium-native" -version = "0.1.0" +version = "0.1.1" requires-python = ">=3.9" description = "Backend selector for Packvium's native and pure Python engines" license = "MIT" @@ -14,7 +14,10 @@ dependencies = [] # `packvium` is deliberately not a hard dependency of the package itself (see # README's "does not replace or shadow" note) -- it is only needed to exercise the # fallback path in tests. -test = ["pytest", "packvium"] +# The pure engine itself supports Python 3.10+, while this selector remains useful on +# Python 3.9 with the native wheel. Keep the public 3.9 CI lane installable instead of +# asking pip to satisfy an impossible test-only dependency there. +test = ["pytest", "packvium; python_version >= '3.10'"] # Convenience extra: `pip install packvium-native[native]` pulls in the compiled Rust # wheel too, rather than requiring a second, separate install command. native = ["packvium-native-rust"] diff --git a/src/packvium_native/__init__.py b/src/packvium_native/__init__.py index a8a1800..bf5ad55 100644 --- a/src/packvium_native/__init__.py +++ b/src/packvium_native/__init__.py @@ -3,7 +3,7 @@ import json from typing import Any -__version__ = "0.1.0" +__version__ = "0.1.1" def backend() -> str: