diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml new file mode 100644 index 0000000..5520470 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -0,0 +1,28 @@ +title: "[Idea] " +labels: ["idea"] +body: + - type: markdown + attributes: + value: | + Ideas for new packing rules, outputs or integrations. An idea the maintainers take on + is moved to an issue and linked back here, so you can follow it. + - type: textarea + id: problem + attributes: + label: The problem behind the idea + description: What real shipment or load does today's library not handle? + validations: + required: true + - type: textarea + id: idea + attributes: + label: The idea + validations: + required: true + - type: input + id: package + attributes: + label: Package(s) you use + placeholder: "packvium" + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml new file mode 100644 index 0000000..53394de --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -0,0 +1,30 @@ +title: "[Q&A] " +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Ask anything about using the library. If you have found a wrong result or an error, + a bug report is the better place. + - type: input + id: package-version + attributes: + label: Package and version + placeholder: "packvium 1.8.0 (Python)" + validations: + required: true + - type: textarea + id: question + attributes: + label: Your question + description: What you are trying to pack, and what you tried. + validations: + required: true + - type: textarea + id: request + attributes: + label: Request (optional) + description: The JSON request or code, if it helps. + render: json + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml new file mode 100644 index 0000000..fb799e6 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml @@ -0,0 +1,33 @@ +title: "[Show and tell] " +labels: ["show-and-tell"] +body: + - type: markdown + attributes: + value: | + Using the library in a shop, a warehouse tool or a load planner? Tell us how. Leave out + anything confidential about your business. + - type: textarea + id: what + attributes: + label: What you built + validations: + required: true + - type: input + id: package-version + attributes: + label: Package and version + placeholder: "packvium 1.8.0 (Python)" + validations: + required: false + - type: textarea + id: lessons + attributes: + label: What worked, and what got in the way + validations: + required: false + - type: input + id: link + attributes: + label: Link (optional) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d375b6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,105 @@ +name: Bug report +description: A result that is wrong, an error you did not expect, or a crash. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting. A report we can run is a report we can fix, so the most useful + thing below is the smallest request that shows the problem. + + **Security problems do not belong here.** Use **Report a vulnerability** under the + Security tab instead; it opens a private advisory. + - type: dropdown + id: package + attributes: + label: Package + description: Which package did you call? + options: + - "packvium (PyPI) — Packvium for Python" + - "packvium-native (PyPI) — Packvium native backend selector for Python" + - "packvium/packvium (Packagist) — Packvium for PHP" + - "packvium/native-bridge (Packagist) — Packvium native bridge for PHP" + - "@packvium/engine (npm) — Packvium for Node.js" + - "@packvium/browser (npm) — Packvium for browsers" + - "packvium (crates.io) — Packvium for Rust" + - "packvium-native-rust (PyPI) — Packvium native engines: Python wheels, Node addons and the C library" + - Not sure + default: 0 + validations: + required: true + - type: input + id: version + attributes: + label: Package version + description: The exact version installed, for example 1.8.0. + placeholder: "1.8.0" + validations: + required: true + - type: input + id: runtime + attributes: + label: Runtime and operating system + description: Language runtime version and OS, for example "Python 3.12, Ubuntu 24.04" or "PHP 8.3, macOS 15". + validations: + required: true + - type: dropdown + id: profile + attributes: + label: Search profile + options: + - fast + - balanced + - quality + - exact_small + - Not set / default + - Not sure + validations: + required: false + - type: textarea + id: request + attributes: + label: Request that reproduces it + description: | + The JSON request, or the code that builds it, cut down as far as it still shows the + problem. Remove anything confidential; renamed item ids are fine. + render: json + validations: + required: true + - type: textarea + id: actual + attributes: + label: What happened + description: The result (or its relevant part), or the full error message and stack trace. + render: text + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + description: What the result should have been, and why. If another of the packages gives a different answer for the same request, say which. + validations: + required: true + - type: dropdown + id: stopped-by + attributes: + label: How was the search limited? + description: A search stopped by a time limit can differ between runs by design; one stopped by an effort budget cannot. + options: + - effort_budget + - time_limit_ms + - Both + - Neither / not sure + validations: + required: false + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched the existing issues and did not find this one. + required: true + - label: I read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/HEAD/docs/GUARANTEES.md) and this is not something it says the library does not guarantee. + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4c80f76 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Question or help with a request + url: https://github.com/toxakara/packvium-python/discussions/categories/q-a + about: Ask in Discussions → Q&A. Answers there help the next person too. + - name: Report a security vulnerability + url: https://github.com/toxakara/packvium-python/security/advisories/new + about: Report privately. Do not open a public issue for a security problem. + - name: Documentation + url: https://packvium.com/docs + about: Guides, the request contract and the constraint reference. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..542f8de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,62 @@ +name: Feature request +description: A packing rule, constraint, output or integration the library does not support yet. +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Open-ended ideas are welcome in **Discussions → Ideas** first. Use this form when you can + describe a concrete problem the library does not solve today. + - type: dropdown + id: package + attributes: + label: Package you use + options: + - "packvium (PyPI) — Packvium for Python" + - "packvium-native (PyPI) — Packvium native backend selector for Python" + - "packvium/packvium (Packagist) — Packvium for PHP" + - "packvium/native-bridge (Packagist) — Packvium native bridge for PHP" + - "@packvium/engine (npm) — Packvium for Node.js" + - "@packvium/browser (npm) — Packvium for browsers" + - "packvium (crates.io) — Packvium for Rust" + - "packvium-native-rust (PyPI) — Packvium native engines: Python wheels, Node addons and the C library" + - More than one + default: 0 + validations: + required: true + - type: input + id: version + attributes: + label: Package version + placeholder: "1.8.0" + validations: + required: true + - type: textarea + id: problem + attributes: + label: The problem + description: What are you packing, into what, and what rule or output is missing? A real (anonymised) example helps more than a general description. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What you would like + description: The behaviour or field you have in mind, if you have one. + validations: + required: false + - type: textarea + id: workaround + attributes: + label: What you do today + description: Any workaround you use, and what it costs you. + validations: + required: false + - type: markdown + attributes: + value: | + Every package implements one shared contract, so a new rule or result field lands in all + of them together, and a change to the request or result format waits for a release that + is allowed to change it. A request that is accepted may therefore take more than one + release to ship. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index debacaa..72aaaf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # ubuntu-24.04, not ubuntu-latest: ubuntu-latest became 26.04, which setup-python has no 3.9 for. + os: [ubuntu-24.04, macos-latest, windows-latest] python: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 diff --git a/.github/workflows/rc.yml b/.github/workflows/rc.yml new file mode 100644 index 0000000..6989b56 --- /dev/null +++ b/.github/workflows/rc.yml @@ -0,0 +1,81 @@ +name: rc + +# A pre-release tag (`v1.9.0-rc.1`) builds and tests exactly as `release.yml` does, then goes +# to TestPyPI -- a separate index whose database "may be periodically pruned" -- and never to +# PyPI, where a version is permanent. There is no GitHub Release: an RC is not a release. +# +# Before the first RC, once (the owner): +# 1. Create an account on https://test.pypi.org (separate from the PyPI one) and enable 2FA. +# 2. Add a pending Trusted Publisher for project `packvium`: owner `toxakara`, repository +# `packvium-python`, workflow `rc.yml`, environment `testpypi`. +# 3. In this repository, create a GitHub environment named `testpypi` with the same +# reviewers as `pypi`. +# 4. Set the repository variable RC_PUBLISHING to `enabled`. +# A consumer then installs it with +# pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ packvium== + +on: + push: + tags: ["v*-*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip build twine + - run: python -m build + - run: python -m twine check --strict dist/* + + # The tag and the packaged version must agree. Without this, `git tag v0.2.0` on a + # tree that still says 0.1.0 publishes 0.1.0 under a name that claims otherwise, + # and the mistake is only visible after it is public and immutable. + - name: The tag must match the version in the distribution + run: | + tagged="${GITHUB_REF_NAME#v}" + built=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$tagged" != "$built" ]; then + echo "tag $GITHUB_REF_NAME does not match pyproject version $built" >&2 + exit 1 + fi + + - name: Install the built wheel into a clean environment and re-run the tests + run: | + python -m venv /tmp/clean + /tmp/clean/bin/python -m pip install --quiet pytest + /tmp/clean/bin/python -m pip install --quiet --no-index --find-links dist packvium + /tmp/clean/bin/python -m pytest tests -q + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: distributions + path: dist/ + + # Inert until the owner sets the repository variable RC_PUBLISHING to `enabled` (Settings -> + # Secrets and variables -> Actions -> Variables). Without it the job is skipped and the run + # stays green, so an RC tag pushed before the accounts exist publishes nothing. + publish-testpypi: + needs: build + if: vars.RC_PUBLISHING == 'enabled' + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write # OIDC, for Trusted Publishing and for the attestation + attestations: write # build provenance, so a consumer can verify where a file came from + steps: + - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: distributions + path: dist/ + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/* + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e74140e..666fd51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,9 +13,11 @@ name: release # wants. The environment is what turns "anyone who can push a tag" into "a release a # named person approved". +# A pre-release tag (`v1.9.0-rc.1`) never reaches the production registries: the negated +# pattern must follow the positive one, and `rc.yml` takes those tags instead. on: push: - tags: ["v*"] + tags: ["v*", "!v*-*"] permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index bbb0608..c86ae06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ What changed in `packvium` on PyPI, release by release. The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] + +Results now say how far they can be from the best possible answer, `optimal` means a proof, +and a mistyped flag is refused instead of guessed. + +### Added + +- **`optimality.lower_bound` and the gap** under the `default` and `lowest_cost` objectives. + `packvium.validate` checks them; `packvium.result_validation.objective_lower_bound(request)` + computes the bound. + +### Changed + +- **Attaining the bound, not search exhaustion, sets `optimal`** (`proven_optimal`). +- **An effort budget names a stop:** a run that ends on exactly its budget is `complete`, and + `time_limit_ms` is no longer split across starts. Check `termination.any_start_truncated`. +- **`keep_upright`, `stackable`, `must_be_on_floor` and `require_placement_coordinates` must + be booleans.** `"no"` or `1` is `invalid_request` / `wrong_type`; `Item` and `PackingConfig` + refuse it when built. + `validate` refuses such a request as `MalformedResult` naming the field, and reads `null` as + the default. +- **Faster, identical answers:** loading-order check about 30%, `validate` about 20%. +- **Each measure is parsed once per request:** the request check hands its parsed values to + deserialisation, whether a measure is written as a string, a number or a `{value, unit}` object. + The measured macOS unpackable-request paired wall-time median improved about 8%. +- **Placement under axle limits is about 4x faster:** each candidate's axle check is O(1). + +### Fixed + +- **`solvers=["homogeneous_blocks"]` packs under `fast` and `balanced`**, and a node-limit + stop is no longer reported as `time_limit`. +- **`units.length: null` and `output.*_unit: null` mean the default** instead of raising + `AttributeError`; `Length.of`/`Weight.of` refuse a unit that is not a string as `invalid_unit`. +- **`validate` reads `units: null` and `units.length: null` as millimetres**, as the engines do, + and refuses a `units` that is not an object, or an unknown `units.length`, as a `contract` + `MalformedResult` naming the field. `units.weight` is not a field and is ignored: put the unit + on each weight. + ## [1.7.0] The reference validator ships in the package, `quality` looks ahead in its block solver, and a diff --git a/CITATION.cff b/CITATION.cff index 807e5fd..78401b2 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.7.0" +version: "1.8.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-python" @@ -23,3 +23,5 @@ keywords: - packing-algorithm - logistics - deterministic + - box-packing + - load-planning diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8a7627..7bafcf9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,3 +65,24 @@ exact input. For anything with security implications, follow [SECURITY.md](SECURITY.md) rather than opening a public issue. + +## How your feedback is handled + +Every issue and discussion is read by a maintainer. This is what happens next: + +1. **New issues are labelled `needs-triage`.** A maintainer answers with one of: a question + back to you, a confirmation, or a reason it will not be taken on. +2. **A confirmed bug** gets the `bug` label and is reproduced from your request. A wrong result + in one package is checked against the others, because all of them implement the same + contract and must agree. +3. **An accepted feature request or idea** is added to the project's backlog, and the issue is + labelled `accepted` with a comment saying so. An idea from Discussions is first turned into + an issue and linked back to the discussion. +4. **When the change ships**, the issue is closed with the release it shipped in. A change to + the request or result format can only ship in a release that is allowed to change it, so + it may take more than one release. +5. **Declined** requests are closed with the reason, so you are never left waiting on a + silent issue. + +Questions go to **Discussions → Q&A**; security problems go to **Report a vulnerability** +under the Security tab, never to a public issue. diff --git a/README.md b/README.md index 2116045..7ebdef8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ coordinates and rotations for each item, with a reason for anything that did not Full documentation, the constraint reference and benchmarks live at [packvium.com](https://packvium.com). -> **Version 1.7.0 — the public API is frozen.** Field names, status codes and the +> **Version 1.8.0 — the public API is frozen.** Field names, status codes and the > objective vector do not change without a major version, so any `1.x` is a safe upgrade > from any earlier `1.x`. > Read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/main/docs/GUARANTEES.md) before relying on a result. @@ -71,7 +71,8 @@ Item.create("mug", Dimensions.mm("100", "100", "120"), "12 oz") ``` A JSON request refuses a float measure rather than rounding it: send `"160.5"`, not -`160.5`. See +`160.5`. Its `units` sets only the length unit; a weight takes its unit from the measure or is +grams, and `units.weight` is ignored today. See [docs/UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-python/blob/main/docs/UNITS-AND-NUMERICS.md). ## Getting the same answer every time @@ -101,6 +102,11 @@ replay_level` (a submodule `import packvium` does not load), gives the same verd `exact` or `not_guaranteed`. `reproducibility.py` shows four simulated machines giving four different answers under a clock, and one answer under a budget. +`effort_limit` means the budget stopped work the search still had: a search that finishes on +exactly its budget reads `complete`, and under a budget `time_limit_ms` is one fuse for the +whole solve, not a slice per start. `termination.any_start_truncated` says whether the budget +cut a start that might have won. + ## Solver profiles `solver_profile` (JSON) or the matching `PackingConfig` constructor picks how hard the search @@ -244,6 +250,10 @@ instead: `PYTHONPATH=src python3 examples/trucking.py`. maximal-space and bounded exact search, selected by problem shape and profile. - **Answers you can check.** Every solution is re-validated by logic independent of the search. Unplaced items come back with a reason code, not silently missing. +- **A proof, or the distance to one.** Under the `default` and `lowest_cost` objectives every + result states `optimality.lower_bound`. A complete result that attains it reports `optimal`, + which proves no packing obeying the request's rules scores better; any other carries the gap + to the bound (`gap_key`, `absolute_gap`, `relative_gap`). - **Deterministic when you ask for it.** The same request with an `effort_budget` gives the same result on every run and machine. A search stopped by `time_limit_ms` keeps what it reached, which can differ between runs; `termination` says which happened. @@ -300,7 +310,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.7"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.8"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) | | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) | | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) | @@ -322,7 +332,7 @@ CodeMeta form. @software{packvium_python, author = {{Packvium contributors}}, title = {Packvium for Python}, - version = {1.7.0}, + version = {1.8.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/codemeta.json b/codemeta.json index 1a30786..986d168 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Python", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.7.0", + "version": "1.8.0", "license": "https://spdx.org/licenses/MIT", "author": [ { @@ -26,6 +26,8 @@ "truck-loading", "packing-algorithm", "logistics", - "deterministic" + "deterministic", + "box-packing", + "load-planning" ] } diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index e0949d9..8cdcba6 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -14,8 +14,11 @@ After a result reports `feasible` or `optimal` and passes independent validation - **exact arithmetic** — all geometry is integer, in units of 1/16000 mm for length and 1/8 µg for weight. No coordinate is ever a binary floating-point number, so no placement decision depends on rounding; -- **determinism** — identical input and identical seed produce an identical result, on - any platform, when the search completes or stops on `configuration.effort_budget`. A +- **determinism** — for a fixed backend and version, identical input and seed produce + identical packing and deterministic diagnostics on supported platforms when the search + completes or stops on `configuration.effort_budget`. Wall-clock timing diagnostics, + such as `duration_ms` and `elapsed_ms`, are excluded. This does not promise identical + solutions between backends; their documented native quality differences remain. A search stopped by `time_limit_ms` stops where the host's speed let it, so it may differ between runs; the result says which limit stopped it; - **complete accounting** — every requested item instance appears exactly once, either @@ -24,9 +27,15 @@ After a result reports `feasible` or `optimal` and passes independent validation ## It does not guarantee - **A globally optimal packing.** The `fast`, `balanced` and `quality` profiles are - heuristic. Only `exact_small`, within its item limit, searches exhaustively — and even - then optimality is not certified in the result. A returned solution is a good solution, - not a proven best one. + heuristic, and `exact_small` searches exhaustively only within its item limit. A returned + solution is a good solution, not a proven best one, unless the result proves it: under the + `default` and `lowest_cost` objectives a result states `optimality.lower_bound`, and a + complete result whose score attains that bound reports `optimal` / `proven_optimal`. That + proves no packing obeying the request's rules scores better on that objective; it says + nothing about a rule the request cannot express. Any other stated result carries the gap + to the bound, an upper limit on what a longer search could still gain. The statement is + withheld if its gap exceeds the portable integer ceiling of `2^53 - 1`, even when the + request's bound fits. Where geometry makes a request hard, expect a gap. - **Physical safety in transit.** Nothing here models vibration, shock, acceleration, tipping, strapping or load shifting. A geometrically valid packing can still be an unsafe shipment. @@ -52,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.7.0` freezes the public API. Field names, status codes, the objective +Version `1.8.0` freezes the public API. Field names, status codes, the objective vector, the numeric policy and the validation rules do not change without a major version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde constraint on `1.0` is enough; an exact pin is no longer required. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index a784f42..d737e88 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -155,10 +155,16 @@ request field. - `termination.code`: `complete`, `time_limit`, `effort_limit`, or `error`; - `optimality.code`: `proven_optimal`, `proven_infeasible`, `best_found`, or `not_proven`. The 1.1.0 freeze reserved and typed `gap_key`, `absolute_gap` and `relative_gap` - alongside it. The pre-1.1.0 extension point remains open: closing the object would - make a previously valid producer fail a 1.1.0 validator, which is a breaking change - and therefore not legal in this minor release. No engine emits the reserved fields - yet, so `code` is still the only key any Packvium result carries; + alongside it, and the 1.8.0 freeze added `lower_bound`: the lower bound on + `score`, in the result's own score order, through the first position the result misses + -- or all of it when none is missed, which proves the result optimal for its objective + under the rules this library models. `gap_key` requires `lower_bound`, and absence of + `lower_bound` makes no statement at all; it is never "attained". The pre-1.1.0 + extension point remains open: closing the object would make a previously valid + producer fail a validator, which is a breaking change. Every engine emits these fields + since, on the primary result and on each alternative, for the `default` and + `lowest_cost` objectives; a complete result that attains its bound reports `status: + optimal` and `code: proven_optimal`, and nothing else does; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a `centre_of_mass_offset_ppm` — the weighted centre of mass's exact-integer Chebyshev @@ -238,7 +244,10 @@ objective vector. `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` identify distinct, mutually exclusive causes. The former is only a wall-clock deadline. The latter means -one of the request's counted-work boundaries was reached. An unpacked `time_limit` or +the selected start was stopped by one of the request's counted-work boundaries: the search +had more work to do and the budget refused it. A start that finished having used exactly its +budget was not stopped, and reports neither flag. No counter in `algorithm.metrics` ends +above its limit: each counted step is checked before it is taken. An unpacked `time_limit` or `effort_limit` reason must match the corresponding flag. ## Loading and unloading sequences @@ -401,8 +410,9 @@ deadline: Each fact is an open object with a required string `code`. Consumers must preserve an unknown code and any additional fields, allowing future effort/node/cancellation limits without turning a minor release into a parsing break. `status` remains for one -compatibility cycle and is checked against the three facts. The alpha release emits no -`proven_optimal` claim without a future proof/certificate. +compatibility cycle and is checked against the three facts. `proven_optimal` is emitted only +on a complete result whose score attains its stated `optimality.lower_bound`: a proof that no +packing obeying the request's rules scores better on the result's own objective. `status: infeasible`, with `feasibility: infeasible` and `optimality: proven_infeasible`, is emitted only when the portfolio ranks no result at all. A request whose every item is proven @@ -452,6 +462,23 @@ The fields answer separate questions: | `winning_start_truncated` | The selected packing itself came from an interrupted start. | | `global_deadline_reached` | The portfolio-wide deadline expired, independently of individual start slices. | +**Under an `effort_budget` the clock is the request's fuse, and fires only as a whole.** +Without one, Python and PHP give each start an even share of the time left, so one slow start +cannot starve the rest. With one, every start may use all the time left: a request that +bounds its work by counting must get the same answer on any machine, and a start cut by its +own share of the clock would lose quietly, with no flag saying the answer depended on the +machine's speed. When the clock does fire, the portfolio-wide deadline is reached and +`time_limit_reached` is true. Rust and the JavaScript fallback always run their starts against +the one portfolio deadline. Each start keeps its own counters against the whole budget in +Python, PHP and Rust; the JavaScript fallback hands each later start what the earlier ones +left of one budget, so the same number buys less search there. + +**Read `any_start_truncated` when the answer must not depend on the budget.** The selected +start can finish while another start, one that might have won, was stopped by the budget; the +top-level flags then read `complete`, because they describe the selected start. +`any_start_truncated` is the only field that says a larger budget could change the answer. A +checkout that caches a quote refuses one on it (ECOMMERCE-CHECKOUT-INTEGRATIONS.md D3). + The aggregate fields are derived only from `starts`. Exactly one record is selected. `completed` and `truncated` are mutually exclusive and both require `started`. `termination.code` is `time_limit` when the selected start or portfolio hit the wall @@ -526,9 +553,10 @@ valid. A result without the documented shape is refused as `MalformedResult`, wh same as one JSON document for a binding. - PyO3 (`packvium_native_rust`): `validate_json(request, result)` over JSON text, a list of `{"code", "detail"}` dicts, `MalformedResult` (a `ValueError`) with `kind`. -- `@packvium/engine`: `validate(request, result)`, `MalformedResult`, and - `ValidatorUnavailableError` (`code: 'validator_unavailable'`) when the compiled addon is - absent, until the JavaScript fallback's port. +- `@packvium/engine`: `validate(request, result)` and `MalformedResult`; the compiled addon + answers when it is loaded and the package's JavaScript port otherwise (1.8.0). + `ValidatorUnavailableError` (`code: 'validator_unavailable'`) is still exported but + deprecated: nothing throws it since 1.8.0. - `@packvium/browser`: `validate(request, result)`, a promise, `MalformedResult`. - PHP: `Packvium\Validation\validate(array $request, array $result)`, a `list` @@ -727,3 +755,11 @@ depend on the exact shape. Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `units`, `configuration`, `items`, `containers`, `catalog_versions_used`, `policy`, `output` and `fixed_placements`. + +`units` holds one key, `length`: the unit of every length written without one, millimetres when +`units`, or `units.length`, is absent or `null`. There is no request-wide weight unit. A weight +takes its unit from the measure itself (`"2.5 kg"`, `{"value": "2.5", "unit": "kg"}`), and a +bare number is grams. `units.weight` is not a field: today every engine ignores it without an +error, so `{"length": "cm", "weight": "kg"}` still reads a bare weight `2` as 2 g. Write the unit +on each weight instead. A future release that changes the request schema will refuse unknown +keys in `units`, as it already refuses them in `configuration`. diff --git a/pyproject.toml b/pyproject.toml index 3bfb456..e84fb86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "1.7.0" +version = "1.8.0" description = "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results." readme = "README.md" requires-python = ">=3.9" license = "MIT" authors = [{name = "Packvium contributors"}] -keywords = ["3d-bin-packing", "bin-packing", "3d-packing", "cartonization", "container-loading", "pallet-packing", "truck-loading", "packing-algorithm", "logistics", "deterministic"] +keywords = ["3d-bin-packing", "bin-packing", "3d-packing", "cartonization", "container-loading", "pallet-packing", "truck-loading", "packing-algorithm", "logistics", "deterministic", "box-packing", "load-planning"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries", @@ -33,6 +33,7 @@ classifiers = [ Homepage = "https://packvium.com" Source = "https://github.com/toxakara/packvium-python" Documentation = "https://packvium.com/docs" +"Quick start" = "https://github.com/toxakara/packvium-python#quick-start" Changelog = "https://github.com/toxakara/packvium-python/blob/main/CHANGELOG.md" Issues = "https://github.com/toxakara/packvium-python/issues" diff --git a/src/packvium/artifacts.py b/src/packvium/artifacts.py index 76821dd..ae5a3ad 100644 --- a/src/packvium/artifacts.py +++ b/src/packvium/artifacts.py @@ -33,7 +33,7 @@ #: The suite version of this builder, the same string in all four engines of one release. #: The engine's own name is deliberately not recorded: four correct builders naming themselves #: would emit four different documents. `make version-set` rewrites this line. -SUITE_VERSION = "1.7.0" +SUITE_VERSION = "1.8.0" #: The deterministic part of `result.algorithm`. `duration_ms` is wall-clock time and never #: enters an artifact. diff --git a/src/packvium/bounds.py b/src/packvium/bounds.py index 2d51b4f..f5712b3 100644 --- a/src/packvium/bounds.py +++ b/src/packvium/bounds.py @@ -12,9 +12,12 @@ until it becomes arithmetic. Where the achieved score meets the bound, the search is over whether or not it explored anything, and the engine knows it. -What it is not. These bounds relax geometry away entirely. Attaining one certifies -optimality *of the relaxation*, never of the packing, and the difference between those two -claims is the difference between a true statement and a marketing one. +What it proves. These bounds relax geometry away entirely, and that is why attaining one is +a proof about the packing: every packing the request admits is a solution of the relaxation, +so none scores below its optimum, and a validated packing that attains the bound is optimal +among them. The proof covers only the rules this library models and only the result's own +objective; where the bound is missed, the gap is an upper limit on what a longer search +could gain, not a claim that the gain exists. Instances that occupy less than their box. The document states this rule for `nesting_height`: such an instance occupies less than its nominal volume, so nominal volumes @@ -37,21 +40,26 @@ mathematics and belong to the design task, not to this implementation of it -- recorded in `docs/OPTIMALITY-CERTIFICATES.md` rather than invented here. -Objective key order. The bounds are keyed to the *default* objective, +Objective key order. `compute` returns the bounds in the *default* objective's order, `(unpacked_count, container_count, total_cost_minor, unused_volume_ppm, stack_height_ppm)`. -`lowest_cost` and `maximum_value` reorder those keys, so a bound vector compared against -their score vectors compares different quantities and means nothing. Callers pass the -objective they scored with. +`for_request` returns them in the order of the objective a result is scored with: +`lowest_cost` reads `(L0, L2, L1, L3, L4)`, which the document shows is still a bound, and +every other objective gets none, because a vector compared against a score that ranks other +quantities means nothing. + +Which instances. The bound is a property of the request, so it is taken over every instance +the request declares, fixed placements included. A bound over the free instances +alone counts a fixed item's volume as empty and over-states the unused-volume key. Complexity. `O(n log n + c log c)` for `n` instances and `c` container types -- one sort of -the instance volumes, one of the weights, one of the per-unit costs. No geometry is touched -and no candidate position is generated, which is why this can be computed at the root of a -search rather than inside it. +the instance volumes, one of the weights, one of the container types by cost. No geometry is +touched and no candidate position is generated, which is why the packer computes it once per +pack, before any solver runs. """ from __future__ import annotations -from typing import Iterable, Sequence, Tuple +from typing import Dict, Iterable, Sequence, Tuple from ._compat import dataclass from .constraints import usable_volume @@ -81,6 +89,14 @@ MAX_BOUND_VALUE = 2 ** 53 - 1 +#: The objectives a bound is stated for, and where each reads the five bounds: the position +#: in `compute`'s default-order vector of each of the objective's own score keys. +OBJECTIVE_ORDERS = { + "default": (0, 1, 2, 3, 4), + "lowest_cost": (0, 2, 1, 3, 4), +} + + class BoundOverflowError(ValueError): """A sum in the bound path exceeded the declared ceiling. @@ -239,12 +255,40 @@ def _occupies_less_than_its_box(item: Item) -> bool: return item.shape_type in (ShapeType.CONVEX_HULL, ShapeType.COMPRESSIBLE) +def for_request(instances: Sequence[ItemInstance], containers: Sequence[Container], + objective: str) -> "Tuple[int, ...] | None": + """The request's bound in `objective`'s own score order, or `None` where none is stated. + + `instances` is every instance the request declares, fixed placements included, and + `containers` every type it may open. `None` for an objective with no bound, for a + request with a `made_to_measure` container (D13: the unused-volume key would be + taken from the machine maximum while the score uses the fabricated box), and for a + request past a declared ceiling. The last withholds the bound and nothing else: the pack + is still answered, as it was before any bound existed (OPTIMALITY-GAP.md § 1.5). + + A function of the request alone, computed independently of the solver and deadline. + Emitting its prefix also checks the result's score and the portable gap ceiling. + """ + order = OBJECTIVE_ORDERS.get(objective) + if order is None: + return None + if any(container.made_to_measure is not None for container in containers): + return None + try: + vector = compute(instances, containers).as_tuple() + except BoundOverflowError: + return None + return tuple(vector[position] for position in order) + + def compute(instances: Sequence[ItemInstance], containers: Sequence[Container]) -> Bounds: - """Every bound for one request, at the root of a search. + """Every bound for one request, in the default objective's key order. - The instances are the quantity-expanded list the solver is about to place, and the - containers are the types it may open -- both exactly as the request declares them, - before any placement decision has been taken. + The instances are every quantity-expanded instance the request declares -- free and + fixed alike -- and the containers are the types it may open, both exactly as the request + declares them, before any placement decision has been taken. Raises + `BoundOverflowError` where the one refusal predicate of docs/OPTIMALITY-CERTIFICATES.md + says the request is past a ceiling. """ volumes = sorted(instance.item.dimensions.volume for instance in instances) weights = sorted(instance.weight.ticks for instance in instances) @@ -326,16 +370,22 @@ def _cost_bound(costs, quantities, opened) -> int: still holds. Inventory is respected rather than assumed unlimited. Charging the cheapest type `L1` - times would also be a bound, and a weaker one whenever that type is nearly exhausted; - the difference costs one sort. + times would also be a bound, and a weaker one whenever that type is nearly exhausted. + + The types are sorted by cost once and taken greedily, `min(quantity, still needed)` of + each, which is the sum of the `L1` cheapest entries of the per-unit multiset without + building it: `O(c log c)` rather than the `O(c · L1 log(c · L1))` of materialising up to + `L1` copies of every type and sorting those (finding F6). """ - if opened <= 0: - return 0 - available: list[int] = [] - for cost, quantity in zip(costs, quantities): - available.extend([cost] * (opened if quantity is None else min(quantity, opened))) - available.sort() - return _guard(sum(available[:opened]), "opening cost") + needed = opened + total = 0 + for cost, quantity in sorted(zip(costs, quantities), key=lambda pair: pair[0]): + if needed <= 0: + break + taken = needed if quantity is None else min(quantity, needed) + total += cost * taken + needed -= taken + return _guard(total, "opening cost") def _unused_volume_bound(volumes, nests, placed, inner, opened) -> int: @@ -393,3 +443,33 @@ def gap(score: Sequence[int], bound: Bounds) -> Gap: absolute = achieved - least return Gap(index, absolute, (absolute, least) if least > 0 else None) return Gap(None, 0, None) + + +def statement(score: Sequence[int], bound: Sequence[int]) -> "Dict[str, object] | None": + """The `optimality` fields one result states against its request's bound (OPTIMALITY-GAP.md + § 1.1), or `None` where § 1.5 withholds them. + + `bound` is in the result's own score order. The prefix runs through the first position the + score misses, or is the whole bound when nothing is missed -- the attained case, which + carries no gap at all rather than a zero one. A score below its bound means the bound is + wrong, and an engine must not publish a claim it has just refuted, so nothing is stated. + A gap past the portable result ceiling also withholds the whole statement. + At most five comparisons, `O(1)`. + """ + for index, (achieved, least) in enumerate(zip(score, bound)): + if achieved < least: + return None + if achieved > least: + absolute = achieved - least + if absolute > MAX_BOUND_VALUE: + return None + fields: Dict[str, object] = { + "lower_bound": list(bound[:index + 1]), + "gap_key": index, + "absolute_gap": absolute, + } + if least > 0: + # Unreduced, so the pair reads as (score - bound, bound) by eye. + fields["relative_gap"] = {"numerator": absolute, "denominator": least} + return fields + return {"lower_bound": list(bound)} diff --git a/src/packvium/config.py b/src/packvium/config.py index c6effe4..752f761 100644 --- a/src/packvium/config.py +++ b/src/packvium/config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ._request_error import refused +from ._request_error import refused, refused_type from ._compat import dataclass from enum import Enum @@ -81,6 +81,9 @@ def __post_init__(self) -> None: raise refused(reason, "minimum_support_ratio must be between 0 and 1") if self.dimensional_weight_divisor is not None and self.dimensional_weight_divisor <= 0: raise refused("below_minimum", "dimensional_weight_divisor must be positive") + if not isinstance(self.require_placement_coordinates, bool): + # A truthiness reading would take `"no"` as yes. + raise refused_type("require_placement_coordinates must be a boolean") for name, allowed in (("dimensional_weight_length_unit", LENGTH_UNIT_CODES), ("dimensional_weight_weight_unit", WEIGHT_UNIT_CODES)): if getattr(self, name) not in allowed: diff --git a/src/packvium/constraints.py b/src/packvium/constraints.py index d550f80..c72ae96 100644 --- a/src/packvium/constraints.py +++ b/src/packvium/constraints.py @@ -531,6 +531,7 @@ def stacked_counts(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = N #: route, so nothing can be blocking it -- but it does block everything beneath it. #: Ordering it after every real stop expresses both halves at once. RIDES_THE_WHOLE_ROUTE = float("inf") +_NO_STOP_ABOVE = float("-inf") def route_order_violated(units: Sequence[LoadUnit], stops: Sequence[float], @@ -546,12 +547,36 @@ def route_order_violated(units: Sequence[LoadUnit], stops: Sequence[float], post-validator answers; this check is the necessary condition, not the whole of it. """ if all(stop == RIDES_THE_WHOLE_ROUTE for stop in stops): return None - for index, above in enumerate(resting_above(units, graph)): + graph = graph if graph is not None else LoadSupportGraph(units) + max_stop_above = [_NO_STOP_ABOVE] * len(units) + for index in graph._descending_indices(): + best = _NO_STOP_ABOVE + for child in graph.children(index): + child_stop = stops[child] + if child_stop > best: + best = child_stop + child_above = max_stop_above[child] + if child_above > best: + best = child_above + max_stop_above[index] = best + for index, stop in enumerate(stops): + if max_stop_above[index] <= stop: + continue # Ascending, not set order: the detail names the first violator, and PHP and # Python must name the same one (S16). - for upper in sorted(above): - if stops[upper] > stops[index]: - return ("unloading_order_violation", f"{units[index].label} blocked by {units[upper].label}") + seen: set[int] = set() + pending = list(graph.children(index)) + upper_min: int | None = None + while pending: + upper = pending.pop() + if upper in seen: + continue + seen.add(upper) + if stops[upper] > stop and (upper_min is None or upper < upper_min): + upper_min = upper + pending.extend(graph.children(upper)) + assert upper_min is not None + return ("unloading_order_violation", f"{units[index].label} blocked by {units[upper_min].label}") return None @@ -1015,22 +1040,50 @@ class AxleLoadConstraint: configured axles sees no new rejection code and no behaviour change. """ + __slots__ = ("_placements", "_container", "_base_weight", "_base_moment") + + def __init__(self) -> None: + self._placements: tuple[Placement, ...] | None = None + self._container: Container | None = None + self._base_weight = 0 + self._base_moment = 0 + + def _base_for( + self, placements: tuple[Placement, ...], container: Container + ) -> tuple[int, int]: + if self._placements is placements and self._container is container: + return self._base_weight, self._base_moment + tare = container.tare_weight.ticks + total_weight = tare + sum(p.instance.weight.ticks for p in placements) + doubled_weighted_x = tare * container.inner_dimensions.length.ticks + sum( + p.instance.weight.ticks + * (2 * p.envelope_origin.x + p.envelope_dimensions.length.ticks) + for p in placements + ) + self._placements = placements + self._container = container + self._base_weight = total_weight + self._base_moment = doubled_weighted_x + return total_weight, doubled_weighted_x + def evaluate(self, context: ConstraintContext) -> ConstraintResult: if context.container.axles is None: return ConstraintResult.allow() + front, rear = context.container.axles + front_x = front.position.ticks + rear_x = rear.position.ticks + denominator = 2 * (rear_x - front_x) + base_weight, base_moment = self._base_for(context.placements, context.container) + cand_weight = context.item.weight.ticks candidate = context.envelope_box - nesting = context.item.item.nesting_height - units = load_units(context.placements, LoadUnit( - candidate, context.item.weight.ticks, None, None, context.item.id, - None if nesting is None else context.item.item.id, - None if nesting is None else nesting.ticks, - )) - failure = axle_load_exceeded( - context.container.axles, - units, - context.container.tare_weight.ticks, - context.container.inner_dimensions.length.ticks, + total_weight = base_weight + cand_weight + doubled_weighted_x = base_moment + cand_weight * ( + 2 * candidate.origin.x + candidate.dimensions.length.ticks ) - if failure is not None: - return ConstraintResult.reject(*failure) + front_numerator = 2 * total_weight * rear_x - doubled_weighted_x + rear_numerator = doubled_weighted_x - 2 * total_weight * front_x + if front.max_load is not None and front_numerator > front.max_load.ticks * denominator: + return ConstraintResult.reject("axle_overloaded", "front") + if rear.max_load is not None and rear_numerator > rear.max_load.ticks * denominator: + return ConstraintResult.reject("axle_overloaded", "rear") return ConstraintResult.allow() diff --git a/src/packvium/locks.py b/src/packvium/locks.py index 8b3e17c..56c0f7f 100644 --- a/src/packvium/locks.py +++ b/src/packvium/locks.py @@ -376,9 +376,11 @@ def resolve_with_locks(request: Mapping[str, Any], locks: Sequence[PlacementLock and `docs/EXECUTION-PLAN.md` makes the direction of that dependency a rule. """ from .serialization import _item, pack_from_dict + from .request_errors import _check_units locks = tuple(locks) - unit = (request.get("units") or {}).get("length", "mm") + # Admit the unit before reading measures, with the ordinary request's error and field. + unit = _check_units(request.get("units")) _refuse_a_contradictory_set(locks, { raw["id"]: _item(raw, unit) for raw in request.get("items") or () }) diff --git a/src/packvium/models.py b/src/packvium/models.py index a6c8628..adff74f 100644 --- a/src/packvium/models.py +++ b/src/packvium/models.py @@ -14,7 +14,7 @@ from .made_to_measure import MadeToMeasure, as_triple from .nesting import used_volume as nesting_used_volume from .units import Length, Weight -from ._request_error import refused +from ._request_error import refused, refused_type GROUND_CONTACT_RULES = frozenset({"free", "covered", "single", "multiple"}) @@ -68,6 +68,14 @@ class Axle: MAX_EXACT_STOP_INDEX = 2 ** 53 - 1 +def _require_booleans(model: Any, names: Iterable[str]) -> None: + """Refuse a non-boolean flag, as the JSON rules do: `"no"` is truthy and would + otherwise be read as yes. O(len(names)).""" + for name in names: + if not isinstance(getattr(model, name), bool): + raise refused_type(f"{name} must be a boolean") + + @dataclass(frozen=True, slots=True) class Item: id: str @@ -140,6 +148,7 @@ def __post_init__(self) -> None: if self.ground_contact_rule is not None and self.ground_contact_rule not in GROUND_CONTACT_RULES: raise refused("not_allowed", f"ground_contact_rule must be one of {sorted(GROUND_CONTACT_RULES)}") self._validate_shape() + _require_booleans(self, ("keep_upright", "stackable", "must_be_on_floor")) rotations = tuple(r for r in self.allowed_rotations if not self.keep_upright or r in Rotation.upright()) if not rotations: raise refused("invalid_value", "at least one rotation must be allowed") object.__setattr__(self, "allowed_rotations", rotations) diff --git a/src/packvium/packer.py b/src/packvium/packer.py index 2148789..d37191e 100644 --- a/src/packvium/packer.py +++ b/src/packvium/packer.py @@ -3,14 +3,15 @@ from collections.abc import Callable from dataclasses import replace +from . import bounds from .config import PackingConfig from .extensions import (ExtensionRegistry, SolutionScorer, UnknownObjectiveError, resolve_objective_scorer, unpriceable_container) from .fixed_placements import admit_fixed_placements from .models import Container, Item, PackingRequest, UnratedWeightError from .request_objects import check_objects -from .result import AlgorithmReport, PackingResult, PackingStatus -from .result import aggregate_termination, unsupported_placements_warning +from .result import AlgorithmReport, PackingResult, PackingStatus, ResultFact +from .result import aggregate_termination, derive_result_facts, unsupported_placements_warning from .solvers import Deadline, SolverOrchestrator from contextlib import nullcontext @@ -34,6 +35,16 @@ def __init__( self.clock = clock self.trace = trace + def _objective_bound(self, request: PackingRequest) -> "tuple[int, ...] | None": + """The request's lower bound in the objective's score order, or `None`. + + Over every instance, fixed placements included. A custom solution scorer + ranks by positions the bound knows nothing about, so it gets none. + """ + if self.solution_scorer is not None: + return None + return bounds.for_request(request.instances, request.containers, self.config.objective) + def pack(self, items, containers, fixed_placements=()) -> PackingResult: items, containers, fixed_placements = tuple(items), tuple(containers), tuple(fixed_placements) # The JSON path's rules, with the JSON path's reason and field: a request @@ -68,6 +79,10 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: request, self.config.minimum_support_ratio, self.config.clearance, self.config.max_containers, ) + # Once per pack, from the request alone and before the first solver runs, so that + # computing the bound never depends on which solver won, how many starts ran or + # how much of the deadline was left (OPTIMALITY-GAP.md F3). + objective_bound = self._objective_bound(request) deadline = ( Deadline(self.config.time_limit_ms, clock=self.clock) if self.clock is not None @@ -89,7 +104,6 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: score = scorer.score(raw) if raw.unpacked and raw.time_limit_reached: status = PackingStatus.TIME_LIMIT elif raw.unpacked: status = PackingStatus.BEST_FOUND - elif raw.exhaustive: status = PackingStatus.OPTIMAL else: status = PackingStatus.FEASIBLE warnings = () if self.config.validate_result: @@ -140,6 +154,7 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: termination.attributes["global_deadline_reached"] or raw.time_limit_reached ) + status, optimality = _stated_optimality(status, score, objective_bound, raw.unpacked) ranked.append(PackingResult( status, raw.containers, @@ -158,6 +173,7 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: score, warnings, termination=termination, + optimality=optimality, objective=self.config.objective, )) if not ranked: @@ -203,3 +219,27 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: best.optimality, best.objective, ) + + +def _stated_optimality( + status: PackingStatus, + score: "tuple[int, ...]", + objective_bound: "tuple[int, ...] | None", + unpacked: "tuple[object, ...]", +) -> "tuple[PackingStatus, ResultFact | None]": + """The status and `optimality` fact one ranked result states (OPTIMALITY-GAP.md § 1.3). + + Attainment is the only route to `optimal`: a complete result whose score meets the bound + on every position is proven, and nothing else is -- an exhausted candidate set covers only + the points it generated. `None` keeps the fact derived from the status alone, which is + every result the bound does not speak for (§ 1.5). + """ + if objective_bound is None or status is PackingStatus.INVALID_RESULT: + return status, None + fields = bounds.statement(score, objective_bound) + if fields is None: + return status, None + if not unpacked and "gap_key" not in fields: + status = PackingStatus.OPTIMAL + code = derive_result_facts(status, not unpacked, False)[2].code + return status, ResultFact(code, fields) diff --git a/src/packvium/packing_sequence.py b/src/packvium/packing_sequence.py index abe2b09..624aa9b 100644 --- a/src/packvium/packing_sequence.py +++ b/src/packvium/packing_sequence.py @@ -511,16 +511,21 @@ def verify_loading_prefix_business_rules( for step, index in enumerate(order): present.append(placements[index]) units = load_units(present) - problem = overloaded(units) or stack_limit_exceeded(units) or stack_density_exceeded(units, max_density_ticks) + graph = LoadSupportGraph(units) + problem = ( + overloaded(units, graph) + or stack_limit_exceeded(units, graph) + or stack_density_exceeded(units, max_density_ticks, graph) + ) if problem is None: - counts = stacked_counts(units) + counts = stacked_counts(units, graph) for unit_index, unit in enumerate(units): item = present[unit_index].instance.item if not item.stackable and counts[unit_index] > 0: problem = ("non_stackable_item_has_load", unit.label) break if problem is None and grouped: - refusal = non_stackable_failure(present, present[-1].instance, LoadSupportGraph(units), + refusal = non_stackable_failure(present, present[-1].instance, graph, len(present) - 1) if refusal is not None: problem = (refusal.code, refusal.detail) diff --git a/src/packvium/request_errors.py b/src/packvium/request_errors.py index 1c13623..d4d86d2 100644 --- a/src/packvium/request_errors.py +++ b/src/packvium/request_errors.py @@ -24,7 +24,7 @@ from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling from ._request_error import InvalidRequestError from .flat_bag import DEFORMABLE_BOUNDARY_KINDS -from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length, Weight +from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length, ParsedMeasures, Weight from .made_to_measure import box_cost_minor_for REASONS = ("missing_field", "wrong_type", "below_minimum", "above_maximum", "negative_measure", @@ -79,6 +79,12 @@ def _ratio(value: Any, field: str, maximum: Optional[int] = 1) -> None: raise InvalidRequestError("above_maximum", field, f"must be at most {maximum}") +def _boolean(value: Any, field: str) -> None: + # Only JSON `true`/`false`: a truthiness reading would pack `"no"` as yes. + if not isinstance(value, bool): + raise InvalidRequestError("wrong_type", field, "must be a boolean") + + def _one_of(value: Any, field: str, allowed: Sequence[str]) -> None: if value not in allowed: raise InvalidRequestError("not_allowed", field, f"must be one of {json_spelling(list(allowed))}") @@ -104,7 +110,8 @@ def _list(value: Any, field: str) -> Sequence[Any]: return value -def _measure(value: Any, field: str, kind: Type[Length] | Type[Weight], unit: str) -> None: +def _measure(value: Any, field: str, kind: Type[Length] | Type[Weight], unit: str, + measures: ParsedMeasures) -> None: """A measure is an integer, a string or `{value, unit}`, and never negative.""" if isinstance(value, bool) or not isinstance(value, (int, float, str, Mapping)) or ( isinstance(value, float) and json_integer(value) is None): @@ -116,7 +123,7 @@ def _measure(value: Any, field: str, kind: Type[Length] | Type[Weight], unit: st if not isinstance(stated, str) or stated.lower().strip() not in kind._MULTIPLIERS: raise InvalidRequestError("invalid_unit", field, f"has an unknown unit {json_spelling(stated)}") try: - kind.parse(value, unit) + measures.read(kind, value, unit) except (ValueError, TypeError, ArithmeticError) as error: if "cannot be negative" in str(error): raise InvalidRequestError("negative_measure", field, "cannot be negative") from error @@ -142,22 +149,27 @@ def _measure(value: Any, field: str, kind: Type[Length] | Type[Weight], unit: st "multi_start_orders", "objective", "require_placement_coordinates", "seed", "solver_profile", "solvers", "time_limit_ms") _SIDES = ("length", "width", "height") +#: The item fields the request schema types `boolean`, in schema order. +ITEM_BOOLEANS = ("keep_upright", "stackable", "must_be_on_floor") -def check_request(data: Any) -> None: - """Refuse the request with its first violation, or return. O(size of the request).""" +def check_request(data: Any) -> ParsedMeasures: + """Refuse the request with its first violation, or return the measures it parsed, for + deserialisation to read instead of parsing them again. O(size of the request).""" + measures = ParsedMeasures() request = _object(data, "") unit = _check_units(request.get("units")) _check_output(request.get("output")) - _check_configuration(request.get("configuration"), unit) + _check_configuration(request.get("configuration"), unit, measures) items = _required_list(request, "items", "") for index, raw in enumerate(items): - _check_item(raw, pointer("items", index), unit) + _check_item(raw, pointer("items", index), unit, measures) _require_unique_ids(items, "items") containers = _required_list(request, "containers", "") for index, raw in enumerate(containers): - _check_container(raw, pointer("containers", index), unit) + _check_container(raw, pointer("containers", index), unit, measures) _require_unique_ids(containers, "containers") + return measures def _check_units(raw: Any) -> str: @@ -181,7 +193,7 @@ def _check_output(raw: Any) -> None: _optional(output, "weight_unit", "/output", lambda v, f: _one_of(v, f, WEIGHT_UNITS)) -def _check_configuration(raw: Any, unit: str) -> None: +def _check_configuration(raw: Any, unit: str, measures: ParsedMeasures) -> None: if raw is None: return configuration = _object(raw, "/configuration") @@ -198,7 +210,8 @@ def _check_configuration(raw: Any, unit: str) -> None: for name, minimum in _CONFIGURATION_INTEGERS: _optional(configuration, name, "/configuration", lambda v, f, m=minimum: _integer(v, f, m)) _optional(configuration, "minimum_support_ratio", "/configuration", _ratio) - _optional(configuration, "clearance", "/configuration", lambda v, f: _measure(v, f, Length, unit)) + _optional(configuration, "clearance", "/configuration", lambda v, f: _measure(v, f, Length, unit, measures)) + _optional(configuration, "require_placement_coordinates", "/configuration", _boolean) effort = configuration.get("effort_budget") if effort is not None: budget = _object(effort, "/configuration/effort_budget") @@ -207,15 +220,15 @@ def _check_configuration(raw: Any, unit: str) -> None: _optional(budget, name, "/configuration/effort_budget", lambda v, f: _integer(v, f, 1)) -def _check_item(raw: Any, where: str, unit: str) -> None: +def _check_item(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: item = _object(raw, where) _required_string(item, "id", where) _optional(item, "quantity", where, lambda v, f: _integer(v, f, 1)) - _dimensions(_required(item, "dimensions", where), where + "/dimensions", unit) - _optional(item, "weight", where, lambda v, f: _measure(v, f, Weight, "g")) - _optional(item, "max_top_load", where, lambda v, f: _measure(v, f, Weight, "g")) - _optional(item, "nesting_height", where, lambda v, f: _measure(v, f, Length, unit)) - _optional(item, "nesting_height", where, lambda v, f: _nesting_below_height(v, f, item, unit)) + _dimensions(_required(item, "dimensions", where), where + "/dimensions", unit, measures) + _optional(item, "weight", where, lambda v, f: _measure(v, f, Weight, "g", measures)) + _optional(item, "max_top_load", where, lambda v, f: _measure(v, f, Weight, "g", measures)) + _optional(item, "nesting_height", where, lambda v, f: _measure(v, f, Length, unit, measures)) + _optional(item, "nesting_height", where, lambda v, f: _nesting_below_height(v, f, item, unit, measures)) _optional(item, "max_stacked_items", where, lambda v, f: _integer(v, f, 1)) _optional(item, "stop_index", where, lambda v, f: _integer(v, f, 0)) _optional(item, "value", where, lambda v, f: _integer(v, f, 0)) @@ -227,34 +240,38 @@ def _check_item(raw: Any, where: str, unit: str) -> None: _optional(item, "allowed_rotations", where, lambda v, f: _each_one_of(v, f, ROTATIONS)) _optional(item, "ground_contact_rule", where, lambda v, f: _one_of(v, f, GROUND_CONTACT_RULES)) _optional(item, "shape_type", where, lambda v, f: _one_of(v, f, SHAPE_TYPES)) + for name in ITEM_BOOLEANS: + _optional(item, name, where, _boolean) -def _nesting_below_height(value: Any, field: str, item: Mapping[str, Any], unit: str) -> None: +def _nesting_below_height(value: Any, field: str, item: Mapping[str, Any], unit: str, + measures: ParsedMeasures) -> None: """A nested copy sinks by less than its own height, or it would vanish into the one below . Both measures have passed their own rules already. O(1).""" - if Length.parse(value, unit).ticks >= Length.parse(item["dimensions"]["height"], unit).ticks: + height = measures.read(Length, item["dimensions"]["height"], unit) + if measures.read(Length, value, unit).ticks >= height.ticks: raise InvalidRequestError("invalid_value", field, "must be less than the item's height") -def _check_container(raw: Any, where: str, unit: str) -> None: +def _check_container(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: container = _object(raw, where) _required_string(container, "id", where) _optional(container, "quantity", where, lambda v, f: _integer(v, f, 1)) - _dimensions(_required(container, "inner_dimensions", where), where + "/inner_dimensions", unit) - _optional(container, "outer_dimensions", where, lambda v, f: _dimensions(v, f, unit)) + _dimensions(_required(container, "inner_dimensions", where), where + "/inner_dimensions", unit, measures) + _optional(container, "outer_dimensions", where, lambda v, f: _dimensions(v, f, unit, measures)) for name in ("tare_weight", "max_payload", "max_stack_density"): - _optional(container, name, where, lambda v, f: _measure(v, f, Weight, "g")) + _optional(container, name, where, lambda v, f: _measure(v, f, Weight, "g", measures)) _optional(container, "max_items", where, lambda v, f: _integer(v, f, 1)) _optional(container, "cost_minor", where, lambda v, f: _integer(v, f, 0)) _optional(container, "void_fill_reserve_ratio", where, _ratio) _optional(container, "access_directions", where, _access_directions) _optional(container, "tag_limits", where, _tag_limits) _optional(container, "rate_table", where, _rate_table) - _optional(container, "obstacles", where, lambda v, f: _obstacles(v, f, unit)) + _optional(container, "obstacles", where, lambda v, f: _obstacles(v, f, unit, measures)) _optional(container, "deformable_boundary", where, lambda v, f: _deformable_boundary(container, v, f, where)) _optional(container, "made_to_measure", where, - lambda v, f: _made_to_measure(container, v, f, where, unit)) + lambda v, f: _made_to_measure(container, v, f, where, unit, measures)) #: Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). @@ -277,7 +294,8 @@ def _deformable_boundary(container: Mapping[str, Any], raw: Any, field: str, whe "tare_per_square_metre") -def _made_to_measure(container: Mapping[str, Any], raw: Any, field: str, where: str, unit: str) -> None: +def _made_to_measure(container: Mapping[str, Any], raw: Any, field: str, where: str, unit: str, + measures: ParsedMeasures) -> None: """The made-to-measure rules: shape top to bottom, then each axis, then the price. Run after every other container field, so an existing error always wins. O(1).""" @@ -286,15 +304,15 @@ def _made_to_measure(container: Mapping[str, Any], raw: Any, field: str, where: for name in ("minimum", "increment"): _required(machine, name, field) for name in ("minimum", "increment", "outer_allowance"): - _optional(machine, name, field, lambda v, f: _dimensions(v, f, unit)) + _optional(machine, name, field, lambda v, f: _dimensions(v, f, unit, measures)) _optional(machine, "cost_per_square_metre_minor", field, lambda v, f: _integer(v, f, 0)) - _optional(machine, "tare_per_square_metre", field, lambda v, f: _measure(v, f, Weight, "g")) + _optional(machine, "tare_per_square_metre", field, lambda v, f: _measure(v, f, Weight, "g", measures)) if container.get("outer_dimensions") is not None: raise InvalidRequestError("not_allowed", pointer_join(where, "outer_dimensions"), "cannot be combined with made_to_measure") - inner = _ticks(container["inner_dimensions"], unit) - minimum = _ticks(machine["minimum"], unit) - increment = _ticks(machine["increment"], unit) + inner = _ticks(container["inner_dimensions"], unit, measures) + minimum = _ticks(machine["minimum"], unit, measures) + increment = _ticks(machine["increment"], unit, measures) for axis, side in enumerate(_SIDES): if minimum[axis] == 0: raise InvalidRequestError("invalid_value", pointer_join(field, "minimum", side), "must be positive") @@ -313,8 +331,8 @@ def _made_to_measure(container: Mapping[str, Any], raw: Any, field: str, where: f"prices the largest box above {MAX_EXACT_MAGNITUDE}") -def _ticks(sides: Mapping[str, Any], unit: str) -> Tuple[int, int, int]: - length, width, height = (Length.parse(sides[side], unit).ticks for side in _SIDES) +def _ticks(sides: Mapping[str, Any], unit: str, measures: ParsedMeasures) -> Tuple[int, int, int]: + length, width, height = (measures.read(Length, sides[side], unit).ticks for side in _SIDES) return (length, width, height) @@ -336,10 +354,10 @@ def _access_directions(raw: Any, where: str) -> None: _each_one_of(raw, where, ACCESS_DIRECTIONS) -def _dimensions(raw: Any, where: str, unit: str) -> None: +def _dimensions(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: sides = _object(raw, where) for side in _SIDES: - _measure(_required(sides, side, where), pointer_join(where, side), Length, unit) + _measure(_required(sides, side, where), pointer_join(where, side), Length, unit, measures) def _stacking_group_name(value: Any, field: str) -> None: @@ -373,7 +391,7 @@ def _rate_table(raw: Any, where: str) -> None: _optional(table, "fuel_surcharge_permille", where, lambda v, f: _integer(v, f, 0)) -def _obstacles(raw: Any, where: str, unit: str) -> None: +def _obstacles(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: for index, entry in enumerate(_list(raw, where)): at = pointer_join(where, index) obstacle = _object(entry, at) @@ -381,8 +399,8 @@ def _obstacles(raw: Any, where: str, unit: str) -> None: if origin is not None: point = _object(origin, at + "/origin") for axis in ("x", "y", "z"): - _optional(point, axis, at + "/origin", lambda v, f: _measure(v, f, Length, unit)) - _dimensions(_required(obstacle, "dimensions", at), at + "/dimensions", unit) + _optional(point, axis, at + "/origin", lambda v, f: _measure(v, f, Length, unit, measures)) + _dimensions(_required(obstacle, "dimensions", at), at + "/dimensions", unit, measures) # ------------------------------------------------------------------------------ plumbing diff --git a/src/packvium/result_validation.py b/src/packvium/result_validation.py index c6917e7..340f3a9 100644 --- a/src/packvium/result_validation.py +++ b/src/packvium/result_validation.py @@ -41,6 +41,12 @@ "ft": 4_876_800, "tick": 1, "ticks": 1, } +#: Every name the engines read in `units.length`, after stripping ASCII whitespace and lowering +#: ASCII case, to the `LENGTH_TICKS` key it means. +REQUEST_LENGTH_UNITS = {**{name: name for name in LENGTH_TICKS}, "millimeter": "mm", "millimeters": "mm"} + +_ASCII_SPACE = " \t\n\r\x0b\x0c" + #: Weight ticks per unit. One tick is 1/8 microgram. WEIGHT_TICKS = { "mg": 8_000, "g": 8_000_000, "kg": 8_000_000_000, @@ -247,20 +253,53 @@ class ContainerSpec: made_to_measure: "MachineSpec | None" = None +def _flag(raw: dict, key: str, default: bool, where: str) -> bool: + """A field the request schema types boolean: absent or null is its default, and any other + non-boolean is refused, as every engine refuses it, rather than read by truthiness.""" + value = raw.get(key) + if value is None: + return default + if not isinstance(value, bool): + raise MalformedResult(f"{where}.{key} must be a boolean") + return value + + +def _length_unit(request: dict) -> str: + """`units.length` as every engine reads it: `units` or `units.length` absent or + null is millimetres; `units` that is not an object, or a length unit the engines do not + know, is refused naming the field rather than failing inside the arithmetic.""" + units = request.get("units") + if units is None: + return "mm" + if not isinstance(units, dict): + raise MalformedResult("units must be an object") + length = units.get("length") + if length is None: + return "mm" + key = length.strip(_ASCII_SPACE) if isinstance(length, str) else "" + if not key.isascii() or key.lower() not in REQUEST_LENGTH_UNITS: + raise MalformedResult("units.length must be one of mm, cm, m, in, ft") + return REQUEST_LENGTH_UNITS[key.lower()] + + def _parse_items(request: dict, unit: str) -> dict[str, ItemSpec]: items = {} - for raw in request["items"]: + for index, raw in enumerate(request["items"]): + where = f"items[{index}]" + keep_upright = _flag(raw, "keep_upright", False, where) + stackable = _flag(raw, "stackable", True, where) + must_be_on_floor = _flag(raw, "must_be_on_floor", False, where) rotations = raw.get("allowed_rotations") if rotations is None: - rotations = UPRIGHT_ROTATIONS if raw.get("keep_upright") else tuple(ROTATIONS) + rotations = UPRIGHT_ROTATIONS if keep_upright else tuple(ROTATIONS) items[raw["id"]] = ItemSpec( id=raw["id"], dimensions=dimensions_to_ticks(raw["dimensions"], unit), weight=to_ticks(raw.get("weight", 0), "g", WEIGHT_TICKS), quantity=raw.get("quantity", 1), allowed_rotations=tuple(rotations), - stackable=raw.get("stackable", True), - must_be_on_floor=raw.get("must_be_on_floor", False), + stackable=stackable, + must_be_on_floor=must_be_on_floor, max_top_load=( None if raw.get("max_top_load") is None else to_ticks(raw["max_top_load"], "g", WEIGHT_TICKS) @@ -397,7 +436,7 @@ def _envelope(placement: dict, clearance: int) -> Box: def _materialize_lattice_summaries( - request: dict, + coordinates_required: bool, result: dict, items: dict[str, ItemSpec], containers: dict[str, ContainerSpec], @@ -415,16 +454,13 @@ def _materialize_lattice_summaries( return result expanded = deepcopy(result) - coordinates_optional = ( - request.get("configuration", {}).get("require_placement_coordinates") is False - ) for packed in expanded["containers"]: summary = packed.get("lattice_summary") if summary is None: continue container_id = packed.get("id", "?") - if not coordinates_optional: + if coordinates_required: issues.append(Issue( "unexpected_lattice_summary", f"{container_id}: request requires explicit placement coordinates", @@ -1213,10 +1249,10 @@ def _check_container( left, left_placement, left_spec = left_value right, right_placement, right_spec = right_value if (left.intersects(right) - and _solids_overlap(_placed_hull(left, left_placement, left_spec), - _placed_hull(right, right_placement, right_spec)) and not _valid_nesting( - left, left_placement, left_spec, right, right_placement, right_spec)): + left, left_placement, left_spec, right, right_placement, right_spec) + and _solids_overlap(_placed_hull(left, left_placement, left_spec), + _placed_hull(right, right_placement, right_spec))): issues.append(Issue("collision", f"{left_id} with {right_id} in {packed['id']}")) if ( left_spec is not None and right_spec is not None @@ -1231,8 +1267,9 @@ def _check_container( _check_support_and_load( packed, envelopes, supporters, direct_above, global_support, issues ) - _check_stack_density(packed, envelopes, supporters, spec.max_stack_density, issues) - _check_crush(packed, envelopes, supporters, issues) + loads = _propagate_load(envelopes, supporters) + _check_stack_density(packed, envelopes, loads, spec.max_stack_density, issues) + _check_crush(packed, envelopes, loads, issues) _check_route_removal_order(packed, envelopes, direct_above, spec.inner, spec.access_directions, issues) @@ -1259,18 +1296,19 @@ def _check_container( f"{packed['id']}: {tag} count {count} > limit {limit}", )) - loads = _propagate_load(envelopes, supporters) used = sum( _occupied_volume(envelopes[p["item_id"]][0], p, items.get(p["item_type"]), loads[p["item_id"]]) for p in packed["placements"] ) - for (_, left_value), (_, right_value) in combinations(envelopes.items(), 2): - left, left_placement, left_spec = left_value - right, right_placement, right_spec = right_value - if _valid_nesting(left, left_placement, left_spec, right, right_placement, right_spec): - assert left_spec is not None and left_spec.nesting_height is not None - used -= left_spec.nesting_height * left.length * left.width + if any(item_spec is not None and item_spec.nesting_height is not None + for _, _, item_spec in envelopes.values()): + for (_, left_value), (_, right_value) in combinations(envelopes.items(), 2): + left, left_placement, left_spec = left_value + right, right_placement, right_spec = right_value + if _valid_nesting(left, left_placement, left_spec, right, right_placement, right_spec): + assert left_spec is not None and left_spec.nesting_height is not None + used -= left_spec.nesting_height * left.length * left.width reported_volume = packed.get("used_volume_ticks3") if reported_volume is not None and int(reported_volume) != used: issues.append(Issue("used_volume_mismatch", @@ -1379,11 +1417,11 @@ def _valid_nesting( ) -> bool: if ( left_spec is None + or left_spec.nesting_height is None or right_spec is None + or right_spec.nesting_height != left_spec.nesting_height or left_spec.id != right_spec.id or left_placement["item_type"] != right_placement["item_type"] - or left_spec.nesting_height is None - or right_spec.nesting_height != left_spec.nesting_height ): return False if (left.x, left.y, left.x2, left.y2) != (right.x, right.y, right.x2, right.y2): @@ -1587,7 +1625,7 @@ def _propagate_load( def _check_crush( packed: dict, envelopes: dict[str, tuple[Box, dict, "ItemSpec | None"]], - supporters: dict[str, list[tuple[str, int]]], + loads: dict[str, int], issues: list[Issue], ) -> None: """No `compressible` item may carry more pressure than it declared. @@ -1604,7 +1642,6 @@ def _check_crush( if all(spec is None or spec.max_compression_pressure_kpa is None for _, _, spec in envelopes.values()): return - loads = _propagate_load(envelopes, supporters) for item_id, (envelope, _, spec) in envelopes.items(): if spec is None or spec.max_compression_pressure_kpa is None: continue @@ -1625,7 +1662,7 @@ def _check_crush( def _check_stack_density( packed: dict, envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], - supporters: dict[str, list[tuple[str, int]]], + loads: dict[str, int], max_density: int | None, issues: list[Issue], ) -> None: @@ -1636,7 +1673,6 @@ def _check_stack_density( """ if max_density is None: return - loads = _propagate_load(envelopes, supporters) for item_id, (envelope, _, spec) in envelopes.items(): if spec is None: continue @@ -1790,8 +1826,9 @@ def validate(request: dict, result: dict) -> list[Issue]: if key not in result: raise MalformedResult(f"result is missing {key!r}") - unit = request.get("units", {}).get("length", "mm") + unit = _length_unit(request) configuration = request.get("configuration", {}) + coordinates_required = _flag(configuration, "require_placement_coordinates", True, "configuration") clearance = to_ticks(configuration.get("clearance", 0), unit, LENGTH_TICKS) global_support = Fraction(str(configuration.get("minimum_support_ratio", 0))) @@ -1800,7 +1837,7 @@ def validate(request: dict, result: dict) -> list[Issue]: issues: list[Issue] = [] validated_result = _materialize_lattice_summaries( - request, result, items, containers, clearance, issues + coordinates_required, result, items, containers, clearance, issues ) _check_structure(validated_result, issues) _check_algorithm_metrics(validated_result, issues) @@ -1851,7 +1888,9 @@ def validate(request: dict, result: dict) -> list[Issue]: _check_fixed_placements(request, validated_result, unit, issues) _check_groups(request, validated_result, issues) _check_solver_selection(request, validated_result, issues) - _check_objective(request, validated_result, containers, items, clearance, issues, fabricated) + score = _check_objective(request, validated_result, containers, items, clearance, issues, + fabricated) + _check_optimality_bound(request, validated_result, items, containers, score, issues) return issues @@ -2310,7 +2349,8 @@ def _landed_charge_minor(spec: "ContainerSpec", billed_weight_ticks: int) -> int def _check_objective(request: dict, result: dict, containers: dict[str, ContainerSpec], items: dict[str, ItemSpec], clearance: int, issues: list[Issue], - fabricated: dict[str, ContainerSpec] | None = None) -> None: + fabricated: dict[str, ContainerSpec] | None = None) -> list[int]: + """Check the reported score against the recomputed one; return the reported score.""" configuration = request.get("configuration", {}) selected = configuration.get("objective", "default") reported_name = result.get("objective") @@ -2329,6 +2369,7 @@ def _check_objective(request: dict, result: dict, containers: dict[str, Containe ) if reported != recomputed: issues.append(Issue("score_mismatch", f"reported {reported}, recomputed {recomputed}")) + return reported def _check_solver_selection(request: dict, result: dict, issues: list[Issue]) -> None: @@ -2374,3 +2415,360 @@ def _check_groups(request: dict, result: dict, issues: list[Issue]) -> None: if packed.get(group, 0) != expected[group]: issues.append(Issue("group_partially_packed", f"{group}: {packed.get(group, 0)} of {expected[group]} placed")) + + +# ------------------------------------------------------------- optimality bound + +#: Where each covered objective reads `(L0, L1, L2, L3, L4)` in its own score order +#: (docs/OPTIMALITY-CERTIFICATES.md, "Per-objective vectors"). Any other objective has no bound. +BOUND_ORDERS: dict[str, tuple[int, ...]] = { + "default": (0, 1, 2, 3, 4), + "lowest_cost": (0, 2, 1, 3, 4), +} + +#: The one refusal predicate's two ceilings. +MAX_BOUND_SUM = 10 ** 30 +MAX_BOUND_VALUE = 2 ** 53 - 1 + +#: Values sorted ascending, each with how many instances carry it. Formed per item type rather +#: than per instance, so a quantity of a million costs one entry. +Runs = tuple[tuple[int, int], ...] + + +@dataclass(frozen=True) +class BoundContainer: + """One container type as the bound reads it, `None` wherever the request sets no limit.""" + + inner_volume: int + base_area: int + height: int + usable: int + cost_minor: int + payload: int | None + max_items: int | None + quantity: int | None + + +def _runs(pairs: Sequence[tuple[int, int]]) -> Runs: + return tuple(sorted(pairs, key=lambda pair: pair[0])) + + +def _run_count(runs: Runs) -> int: + return sum(count for _, count in runs) + + +def _fit(runs: Runs, capacity: int | None) -> int: + """How many of the smallest values sum to at most `capacity`; all of them when unlimited.""" + if capacity is None: + return _run_count(runs) + if capacity < 0: + return 0 + taken = used = 0 + for value, count in runs: + room = count if value == 0 else min(count, (capacity - used) // value) + taken += room + used += value * room + if room < count: + break + return taken + + +def _smallest(runs: Runs, count: int) -> int: + """The sum of the `count` smallest values.""" + total = 0 + for value, available in runs: + if count <= 0: + break + taken = min(available, count) + total += value * taken + count -= taken + return total + + +def _largest(runs: Runs, count: int) -> int: + return _smallest(tuple(reversed(runs)), count) + + +def _capacity_total(values: Sequence[int | None], quantities: Sequence[int | None]) -> int | None: + """`Σ value × quantity`, or `None` when it is unbounded: some type sets no limit, or an + unlimited quantity of a type whose limit is positive. A type that holds nothing adds 0.""" + total = 0 + for value, quantity in zip(values, quantities): + if value is None: + return None + if quantity is None: + if value > 0: + return None + continue + total += value * quantity + return total + + +def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def _container_bound(volumes: Runs, weights: Runs, shrinks: bool, placed: int, + containers: Sequence[BoundContainer]) -> int: + """`L1`, given `L0`: the largest capacity of every kind granted to every container.""" + if placed <= 0 or not containers: + return 0 + bound = 1 + largest_volume = max(container.usable for container in containers) + if not shrinks and largest_volume > 0: + bound = max(bound, _ceil_div(_smallest(volumes, placed), largest_volume)) + payloads = [container.payload for container in containers] + if all(payload is not None for payload in payloads): + largest_payload = max(payload for payload in payloads if payload is not None) + if largest_payload > 0: + bound = max(bound, _ceil_div(_smallest(weights, placed), largest_payload)) + slots = [container.max_items for container in containers] + if all(slot is not None for slot in slots): + largest_slots = max(slot for slot in slots if slot is not None) + if largest_slots > 0: + bound = max(bound, _ceil_div(placed, largest_slots)) + return bound + + +def _cost_bound(containers: Sequence[BoundContainer], opened: int) -> int: + """`L2`: the `opened` cheapest containers the inventory holds, greedily by cost.""" + total = 0 + for container in sorted(containers, key=lambda container: container.cost_minor): + if opened <= 0: + break + taken = opened if container.quantity is None else min(container.quantity, opened) + total += container.cost_minor * taken + opened -= taken + return total + + +def _unused_bound(volumes: Runs, shrinks: bool, placed: int, + containers: Sequence[BoundContainer], opened: int) -> int: + """`L3`: the summed per-container ppm, less the `opened - 1` the ceilings can lose.""" + inner = [container.inner_volume for container in containers if container.inner_volume > 0] + if opened <= 0 or shrinks or not inner: + return 0 + filled = _ceil_div(_largest(volumes, placed) * SCORE_SCALE, min(inner)) + return max(0, opened * SCORE_SCALE - filled - (opened - 1)) + + +def _height_bound(volumes: Runs, shrinks: bool, placed: int, + containers: Sequence[BoundContainer], opened: int) -> int: + """`L4`: the placed volume over the widest floor, in the tallest container, less the + `opened - 1` the per-container floors can lose.""" + floors = [container.base_area for container in containers if container.base_area > 0] + heights = [container.height for container in containers if container.height > 0] + if opened <= 0 or shrinks or not floors or not heights: + return 0 + required = _ceil_div(_smallest(volumes, placed), max(floors)) + return max(0, required * SCORE_SCALE // max(heights) - (opened - 1)) + + +def lower_bound(volumes: Runs, weights: Runs, shrinks: bool, + containers: Sequence[BoundContainer]) -> tuple[int, ...] | None: + """`(L0, L1, L2, L3, L4)` of docs/OPTIMALITY-CERTIFICATES.md, or `None` where its one + refusal predicate withholds the bound. + + The validator's own copy, written from that document and held to its oracle + (`scripts/optimality_bounds.py`) on the shared scene: the engines' copies are what it + checks. `shrinks` drops every volume term, because an instance that can occupy less than + its box stops nominal volumes summing. Over `t` value runs and `c` container types it costs + O(t + c log c) after the runs are sorted, and O(t + c) memory. + """ + quantities = [container.quantity for container in containers] + guarded = ( + sum(value * count for value, count in volumes), + sum(value * count for value, count in weights), + _capacity_total([container.usable for container in containers], quantities), + _capacity_total([container.payload for container in containers], quantities), + _capacity_total([container.max_items for container in containers], quantities), + ) + if any(total is not None and total > MAX_BOUND_SUM for total in guarded): + return None + count = _run_count(volumes) + placeable = count + if containers: + if not shrinks: + placeable = min(placeable, _fit(volumes, guarded[2])) + placeable = min(placeable, _fit(weights, guarded[3])) + if guarded[4] is not None: + placeable = min(placeable, guarded[4]) + else: + placeable = 0 + opened = _container_bound(volumes, weights, shrinks, placeable, containers) + bound = ( + count - placeable, + opened, + _cost_bound(containers, opened), + _unused_bound(volumes, shrinks, placeable, containers, opened), + _height_bound(volumes, shrinks, placeable, containers, opened), + ) + return None if any(value > MAX_BOUND_VALUE for value in bound) else bound + + +def _bound_inputs(items: dict[str, ItemSpec], containers: dict[str, ContainerSpec], + ) -> tuple[Runs, Runs, bool, tuple[BoundContainer, ...]]: + """The request as the bound reads it: every instance, fixed placements included (they are + part of their item's quantity), and every declared container type.""" + volumes = _runs([(spec.dimensions[0] * spec.dimensions[1] * spec.dimensions[2], spec.quantity) + for spec in items.values()]) + weights = _runs([(spec.weight, spec.quantity) for spec in items.values()]) + shrinks = any(spec.quantity > 0 and (spec.nesting_height is not None + or spec.shape_type in ("convex_hull", "compressible")) + for spec in items.values()) + bound_containers = [] + for spec in containers.values(): + volume = spec.inner[0] * spec.inner[1] * spec.inner[2] + scaled = _ratio_ppm(spec.void_fill_reserve_ratio) + reserved = (volume // SCORE_SCALE) * scaled + ((volume % SCORE_SCALE) * scaled) // SCORE_SCALE + bound_containers.append(BoundContainer( + inner_volume=volume, base_area=spec.inner[0] * spec.inner[1], height=spec.inner[2], + usable=volume - reserved, cost_minor=spec.cost_minor, payload=spec.max_payload, + max_items=spec.max_items, quantity=spec.quantity, + )) + return volumes, weights, shrinks, tuple(bound_containers) + + +def objective_lower_bound(request: dict) -> tuple[int, ...] | None: + """The lower bound a result for `request` may state, in its objective's score order, or + `None` where docs/OPTIMALITY-GAP.md § 1.5 says no bound is stated.""" + unit = _length_unit(request) + items = _parse_items(request, unit) + containers = _parse_containers(request, unit) + return _stated_bound(request, "feasible", items, containers)[0] + + +def _stated_bound(request: dict, status: Any, items: dict[str, ItemSpec], + containers: dict[str, ContainerSpec]) -> tuple[tuple[int, ...] | None, str]: + """The bound in the objective's score order, or `None` with the reason none is stated + (docs/OPTIMALITY-GAP.md § 1.5).""" + objective_name = request.get("configuration", {}).get("objective", "default") + order = BOUND_ORDERS.get(objective_name) + if order is None: + return None, f"objective {objective_name!r} has no bound" + if any(spec.made_to_measure is not None for spec in containers.values()): + return None, "a container is made to measure" + if status == "infeasible": + return None, "status is 'infeasible'" + if status == "invalid_result": + return None, "status is 'invalid_result'" + bound = lower_bound(*_bound_inputs(items, containers)) + if bound is None: + return None, "the request is past a declared ceiling" + return tuple(bound[position] for position in order), "" + + +def _departure(score: Sequence[int], bound: Sequence[int]) -> tuple[int, int] | None: + """The first position where `score` leaves `bound`, with the sign of the difference. + + Lexicographic, because each bound holds only over packings attaining the ones before it: + once a position is missed, the later positions say nothing. + """ + for index, (achieved, floor) in enumerate(zip(score, bound)): + if achieved != floor: + return index, 1 if achieved > floor else -1 + return None + + +def _is_count(value: Any) -> bool: + return type(value) is int and value >= 0 + + +def _expected_gap(score: Sequence[int], bound: Sequence[int], + departure: tuple[int, int] | None) -> tuple[int, dict[str, Any]]: + """The prefix length and the three gap fields a correct result carries (§ 1.1).""" + if departure is None: + return len(bound), {"gap_key": None, "absolute_gap": None, "relative_gap": None} + key = departure[0] + absolute = score[key] - bound[key] + relative = None if bound[key] == 0 else (absolute, bound[key]) + return key + 1, {"gap_key": key, "absolute_gap": absolute, "relative_gap": relative} + + +def _gap_field_matches(name: str, actual: Any, expected: Any) -> bool: + if expected is None: + return actual is None + if name != "relative_gap": + return type(actual) is int and actual == expected + return (isinstance(actual, dict) and len(actual) == 2 + and type(actual.get("numerator")) is int and actual["numerator"] == expected[0] + and type(actual.get("denominator")) is int and actual["denominator"] == expected[1]) + + +def _gap_text(name: str, expected: Any) -> str: + if expected is None: + return "absent" + return f"{expected[0]}/{expected[1]}" if name == "relative_gap" else str(expected) + + +def _check_reported_bound(emitted: list[int], score: Sequence[int], bound: Sequence[int], + departure: tuple[int, int] | None, fact: dict, + issues: list[Issue]) -> None: + """`lower_bound` is the recomputed prefix, and the gap agrees with it and the score.""" + if emitted != list(bound[:len(emitted)]): + issues.append(Issue("optimality_bound_mismatch", + f"reported {emitted}, recomputed {list(bound[:len(emitted)])}")) + length, gap = _expected_gap(score, bound, departure) + if len(emitted) != length: + issues.append(Issue("optimality_gap_mismatch", + f"lower_bound has {len(emitted)} entries, expected {length}")) + for name, expected in gap.items(): + if not _gap_field_matches(name, fact.get(name), expected): + issues.append(Issue("optimality_gap_mismatch", + f"{name} should be {_gap_text(name, expected)}")) + + +def _check_optimality_bound(request: dict, result: dict, items: dict[str, ItemSpec], + containers: dict[str, ContainerSpec], score: list[int], + issues: list[Issue]) -> None: + """The bound and the gap of docs/OPTIMALITY-GAP.md § 6.2, recomputed from the + request, and the one route to `optimal` they open (§ 1.3, Q1). + + Absence is never an issue: it makes no claim, so it cannot make a false one, and results + from producers that predate the field stay valid. O(t log t + c log c) for `t` item types + and `c` container types, once per result. + """ + fact = result["optimality"] + bound, withheld = _stated_bound(request, result["status"], items, containers) + departure = None if bound is None else _departure(score, bound) + below = bound is not None and departure is not None and departure[1] < 0 + if bound is not None and departure is not None and below: + key = departure[0] + issues.append(Issue("score_below_bound", + f"score[{key}] is {score[key]}, below its lower bound {bound[key]}")) + + emitted = fact.get("lower_bound") + if (emitted is not None and bound is not None and departure is not None + and score[departure[0]] - bound[departure[0]] > MAX_BOUND_VALUE): + issues.append(Issue("optimality_gap_mismatch", "the gap exceeds the portable result ceiling")) + well_formed = (isinstance(emitted, list) and 1 <= len(emitted) <= 5 + and all(_is_count(value) for value in emitted)) + if emitted is None: + if any(fact.get(name) is not None for name in ("gap_key", "absolute_gap", "relative_gap")): + issues.append(Issue("optimality_gap_mismatch", "a gap is reported without lower_bound")) + elif not well_formed: + issues.append(Issue("optimality_bound_mismatch", + "lower_bound must be a list of 1 to 5 non-negative integers")) + elif bound is None: + issues.append(Issue("optimality_bound_mismatch", + f"lower_bound is reported where no bound is stated: {withheld}")) + elif below: + issues.append(Issue("optimality_bound_mismatch", + "lower_bound is reported for a score below the recomputed bound")) + else: + _check_reported_bound(emitted, score, bound, departure, fact, issues) + + attained = bound is not None and departure is None + complete = bool(result["complete"]) + if result["status"] == "optimal" and not (complete and attained): + issues.append(Issue("result_fact_mismatch", + "status 'optimal' requires a complete result that attains the " + "recomputed lower bound")) + claims_attainment = well_formed and emitted == list(score) + if result["status"] == "feasible" and complete and claims_attainment: + issues.append(Issue("result_fact_mismatch", + "status is 'feasible', but the result attains its lower_bound; " + "expected 'optimal'")) + if fact.get("code") == "proven_optimal" and not claims_attainment: + issues.append(Issue("unproven_optimality_claim", + "optimality.code is 'proven_optimal' without an attained lower_bound")) diff --git a/src/packvium/serialization.py b/src/packvium/serialization.py index d073879..f1405aa 100644 --- a/src/packvium/serialization.py +++ b/src/packvium/serialization.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterator, Mapping -from typing import Any +from typing import Any, Optional from contextlib import contextmanager from dataclasses import replace @@ -18,7 +18,7 @@ from .request_errors import InvalidRequestError, check_request from .packer import Packer from .policy import PolicyRuleSet -from .units import Length, Weight, output_length_unit +from .units import Length, ParsedMeasures, Weight, output_length_unit def _axles(raw: list | None, unit: str) -> tuple[Axle, Axle] | None: @@ -59,19 +59,38 @@ def _hull_vertices(raw, unit: str) -> "tuple[tuple[int, int, int], ...] | None": ) -def _item(raw: dict, unit: str) -> Item: +def _flag(raw: Mapping[str, Any], name: str, default: bool) -> Any: + """A boolean field as written, null or absent read as its default. Never cast: the model + refuses a value that is not a boolean, where `bool("no")` would read it as yes.""" + value = raw.get(name) + return default if value is None else value + + +def _or_fresh(measures: Optional[ParsedMeasures]) -> ParsedMeasures: + """The request check's parsed measures (`pack_from_dict`), or a fresh set for a caller that + builds one record without them.""" + return ParsedMeasures() if measures is None else measures + + +def _dimensions(raw: Mapping[str, Any], unit: str, measures: ParsedMeasures) -> Dimensions: + return Dimensions._read(*(measures.read(Length, raw[side], unit) for side in ("length", "width", "height"))) + + +def _item(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) -> Item: + measures = _or_fresh(measures) rotations = tuple(Rotation(v) for v in raw.get("allowed_rotations", [r.value for r in Rotation.all()])) return Item( - id=str(raw["id"]), dimensions=Dimensions.from_dict(raw["dimensions"], unit), weight=Weight.parse(raw.get("weight", 0)), - quantity=int(raw.get("quantity", 1)), allowed_rotations=rotations, keep_upright=bool(raw.get("keep_upright", False)), - stackable=bool(raw.get("stackable", True)), must_be_on_floor=bool(raw.get("must_be_on_floor", False)), - max_top_load=None if raw.get("max_top_load") is None else Weight.parse(raw["max_top_load"]), + id=str(raw["id"]), dimensions=_dimensions(raw["dimensions"], unit, measures), + weight=measures.read(Weight, raw.get("weight", 0), "g"), + quantity=int(raw.get("quantity", 1)), allowed_rotations=rotations, keep_upright=_flag(raw, "keep_upright", False), + stackable=_flag(raw, "stackable", True), must_be_on_floor=_flag(raw, "must_be_on_floor", False), + max_top_load=None if raw.get("max_top_load") is None else measures.read(Weight, raw["max_top_load"], "g"), max_stacked_items=raw.get("max_stacked_items"), minimum_support_ratio=float(raw.get("minimum_support_ratio", 0)), group=raw.get("group"), tags=frozenset(raw.get("tags", [])), incompatible_tags=frozenset(raw.get("incompatible_tags", [])), priority=int(raw.get("priority", 0)), eligible_container_tags=frozenset(raw.get("eligible_container_tags", [])), ground_contact_rule=raw.get("ground_contact_rule"), metadata=raw.get("metadata", {}), - nesting_height=None if raw.get("nesting_height") is None else Length.parse(raw["nesting_height"], unit), + nesting_height=None if raw.get("nesting_height") is None else measures.read(Length, raw["nesting_height"], unit), stop_index=raw.get("stop_index"), value=raw.get("value"), shape_type=ShapeType(raw.get("shape_type", ShapeType.RIGID_CUBOID.value)), @@ -84,15 +103,16 @@ def _item(raw: dict, unit: str) -> Item: ) -def _point_ticks(raw: dict, unit: str) -> tuple[int, int, int]: - return (Length.parse(raw.get("x", 0), unit).ticks, Length.parse(raw.get("y", 0), unit).ticks, - Length.parse(raw.get("z", 0), unit).ticks) +def _point_ticks(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) -> tuple[int, int, int]: + measures = _or_fresh(measures) + return (measures.read(Length, raw.get("x", 0), unit).ticks, measures.read(Length, raw.get("y", 0), unit).ticks, + measures.read(Length, raw.get("z", 0), unit).ticks) -def _box(raw: dict, unit: str) -> AxisAlignedBox: +def _box(raw: dict, unit: str, measures: ParsedMeasures) -> AxisAlignedBox: return AxisAlignedBox( - Point(*_point_ticks(raw.get("origin", {}), unit)), - Dimensions.from_dict(raw["dimensions"], unit), + Point(*_point_ticks(raw.get("origin", {}), unit, measures)), + _dimensions(raw["dimensions"], unit, measures), ) @@ -120,29 +140,32 @@ def _rate_table(raw: dict | None) -> RateTable | None: ) -def _container(raw: dict, unit: str) -> Container: +def _container(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) -> Container: + measures = _or_fresh(measures) obstacles = tuple( - Obstacle(str(o["id"]), _box(o, unit), additional_boxes=tuple(_box(b, unit) for b in o.get("additional_boxes", []))) + Obstacle(str(o["id"]), _box(o, unit, measures), + additional_boxes=tuple(_box(b, unit, measures) for b in o.get("additional_boxes", []))) for o in raw.get("obstacles", []) ) return Container( - id=str(raw["id"]), inner_dimensions=Dimensions.from_dict(raw["inner_dimensions"], unit), - outer_dimensions=None if raw.get("outer_dimensions") is None else Dimensions.from_dict(raw["outer_dimensions"], unit), - tare_weight=Weight.parse(raw.get("tare_weight", 0)), max_payload=None if raw.get("max_payload") is None else Weight.parse(raw["max_payload"]), + id=str(raw["id"]), inner_dimensions=_dimensions(raw["inner_dimensions"], unit, measures), + outer_dimensions=None if raw.get("outer_dimensions") is None else _dimensions(raw["outer_dimensions"], unit, measures), + tare_weight=measures.read(Weight, raw.get("tare_weight", 0), "g"), + max_payload=None if raw.get("max_payload") is None else measures.read(Weight, raw["max_payload"], "g"), cost_minor=int(raw.get("cost_minor", 0)), rate_table=_rate_table(raw.get("rate_table")), quantity=None if raw.get("quantity") is None else int(raw["quantity"]), obstacles=obstacles, tags=frozenset(raw.get("tags", [])), max_items=None if raw.get("max_items") is None else int(raw["max_items"]), void_fill_reserve_ratio=float(raw.get("void_fill_reserve_ratio", 0)), tag_limits={str(k): int(v) for k, v in raw.get("tag_limits", {}).items()}, metadata=raw.get("metadata", {}), - max_stack_density=None if raw.get("max_stack_density") is None else Weight.parse(raw["max_stack_density"]), + max_stack_density=None if raw.get("max_stack_density") is None else measures.read(Weight, raw["max_stack_density"], "g"), axles=_axles(raw.get("axles"), unit), access_directions=tuple(str(d) for d in raw.get("access_directions", ())), deformable_boundary=None if raw.get("deformable_boundary") is None else raw["deformable_boundary"]["kind"], - made_to_measure=_made_to_measure(raw.get("made_to_measure"), unit), + made_to_measure=_made_to_measure(raw.get("made_to_measure"), unit, measures), ) -def _made_to_measure(raw: dict | None, unit: str) -> MadeToMeasure | None: +def _made_to_measure(raw: dict | None, unit: str, measures: ParsedMeasures) -> MadeToMeasure | None: if raw is None: return None @@ -150,14 +173,14 @@ def sides(name: str) -> tuple[int, int, int]: value = raw.get(name) if value is None: return (0, 0, 0) - return tuple(Length.parse(value[axis], unit).ticks for axis in ("length", "width", "height")) + return tuple(measures.read(Length, value[axis], unit).ticks for axis in ("length", "width", "height")) tare = raw.get("tare_per_square_metre") return MadeToMeasure( minimum=sides("minimum"), increment=sides("increment"), cost_per_square_metre_minor=int(raw.get("cost_per_square_metre_minor") or 0), - tare_per_square_metre=0 if tare is None else Weight.parse(tare).ticks, + tare_per_square_metre=0 if tare is None else measures.read(Weight, tare, "g").ticks, outer_allowance=sides("outer_allowance"), ) @@ -288,15 +311,15 @@ def pack_from_dict(data: dict, *, if not isinstance(data, Mapping): raise InvalidRequestError("wrong_type", "", "must be an object") reject_unsupported(data) - check_request(data) + measures = check_request(data) data = _without_null_optionals(data) with _as_request_error(): unit = (data.get("units") or {}).get("length", "mm") - config = _config(data.get("configuration") or {}, unit) + config = _config(data.get("configuration") or {}, unit, measures) references = _catalog_versions_used(data.get("catalog_versions_used", [])) extensions = _extensions(data, extensions) - items = [_item(i, unit) for i in data["items"]] - containers = [_container(c, unit) for c in data["containers"]] + items = [_item(i, unit, measures) for i in data["items"]] + containers = [_container(c, unit, measures) for c in data["containers"]] fixed = _fixed_placements(data.get("fixed_placements"), unit) result = Packer(config, extensions).pack(items, containers, fixed) result = replace(result, catalog_versions_used=references) @@ -310,11 +333,14 @@ def _without_null_optionals(data: Mapping[str, Any]) -> dict: reached them would be read as a value. Only the request's own records are cleaned -- `metadata` and the like are echoed back and keep their nulls.""" request = _without_nulls(data) - if isinstance(request.get("configuration"), Mapping): - configuration = _without_nulls(request["configuration"]) - if isinstance(configuration.get("effort_budget"), Mapping): - configuration["effort_budget"] = _without_nulls(configuration["effort_budget"]) - request["configuration"] = configuration + # `units.length: null` is millimetres and `output.length_unit: null` the request's unit, + # as in every other engine; read as a value, either was an AttributeError. + for block in ("units", "output", "configuration"): + if isinstance(request.get(block), Mapping): + request[block] = _without_nulls(request[block]) + configuration = request.get("configuration") + if isinstance(configuration, Mapping) and isinstance(configuration.get("effort_budget"), Mapping): + configuration["effort_budget"] = _without_nulls(configuration["effort_budget"]) # The rule table has already required both lists and every entry in them to be objects. for key in ("items", "containers"): request[key] = [_record_without_nulls(entry) for entry in request[key]] @@ -352,13 +378,14 @@ def _as_request_error() -> Iterator[None]: raise InvalidRequestError("invalid_value", "", str(error)) from error -def _config(cfg: dict, unit: str) -> PackingConfig: +def _config(cfg: dict, unit: str, measures: Optional[ParsedMeasures] = None) -> PackingConfig: + measures = _or_fresh(measures) profile = SolverProfile(cfg.get("solver_profile", "balanced")) quality = profile is SolverProfile.QUALITY return PackingConfig( profile=profile, time_limit_ms=int(cfg.get("time_limit_ms", 1000)), top_k=int(cfg.get("alternatives", 3)), seed=int(cfg.get("seed", 42)), max_containers=cfg.get("max_containers"), - clearance=Length.parse(cfg.get("clearance", 0), unit), minimum_support_ratio=float(cfg.get("minimum_support_ratio", 0)), + clearance=measures.read(Length, cfg.get("clearance", 0), unit), minimum_support_ratio=float(cfg.get("minimum_support_ratio", 0)), exact_item_limit=int(cfg.get("exact_item_limit", 7)), multi_start_orders=int(cfg.get("multi_start_orders", 8)), max_candidates_per_item=int(cfg.get("max_candidates_per_item", 16 if quality else 1)), max_candidate_points=int(cfg.get("max_candidate_points", 4096)), @@ -368,7 +395,7 @@ def _config(cfg: dict, unit: str) -> PackingConfig: dimensional_weight_divisor=cfg.get("dimensional_weight_divisor"), dimensional_weight_length_unit=str(cfg.get("dimensional_weight_length_unit", "in")), dimensional_weight_weight_unit=str(cfg.get("dimensional_weight_weight_unit", "lb")), - require_placement_coordinates=bool(cfg.get("require_placement_coordinates", True)), + require_placement_coordinates=_flag(cfg, "require_placement_coordinates", True), container_plan_beam_width=int(cfg.get("container_plan_beam_width", 16 if quality else 1)), container_plan_node_limit=int(cfg.get("container_plan_node_limit", 100_000 if quality else 1)), ) diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index 3c2d252..31c1610 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -22,7 +22,7 @@ StopAccessibilityConstraint, SupportConstraint, TagCountConstraint, TopLoadConstraint, active_constraints, direct_support_view, is_position_independent, load_units, top_loads, usable_volume) -from . import bounds, hull, made_to_measure +from . import hull, made_to_measure from .geometry import (AxisAlignedBox, Dimensions, Point, Rotation, ShapeType, dimensional_weight) from .flat_bag import as_dimensions, fits_alone @@ -59,15 +59,6 @@ class SearchStats: # at once. This one stays internal, where tests can prove the refinement actually fired # rather than infer it from a placement that might have succeeded anyway. hull_refinements: int = 0 - # The request-level lower bound on the objective vector, computed once at the root of an - # `exact_small` or global-beam solve. Internal for the same reason as - # `hull_refinements` above, and for one more: reporting a gap to a caller is a new public - # result field, and this project reserves and rejects such a field before a contract - # freeze rather than adding it mid-line, following the precedent set for the reserved - # container field and restated by the objective-bound wave. - # `None` when no bound was computed -- a non-default objective keys its score vector - # differently, so a bound compared against it would compare different quantities. - objective_lower_bound: "tuple[int, ...] | None" = None def to_metrics(self) -> SolverMetrics: return SolverMetrics( @@ -89,9 +80,15 @@ class Deadline: existing call site already polls `expired`/`check()`, so attaching an effort budget (via `with_effort`) needs no changes anywhere else; leaving it unattached (the default) reproduces the exact prior time-only behaviour. + + A spent budget answers `True` to a poll, and that poll is a search refusing further work: + the deadline records it (`effort_stopped`), so the effort flag says the budget stopped the + search, not that a counter equals its limit afterwards. A search that used exactly + its budget and finished was not stopped. Code asking afterwards whether a search was cut + reads `stopped`, which records nothing. """ - __slots__ = ("started", "limit_ns", "_clock", "_effort_budget", "_stats") + __slots__ = ("started", "limit_ns", "_clock", "_effort_budget", "_stats", "effort_stopped") def __init__( self, @@ -107,6 +104,9 @@ def __init__( self.limit_ns = limit_ms * 1_000_000 if limit_ns is None else limit_ns self._effort_budget = effort_budget self._stats = stats + # Per-solve state: set once a poll found the effort budget spent and the search + # stopped there. + self.effort_stopped = False @property def elapsed_ms(self) -> int: return int((self._clock() - self.started) / 1_000_000) @@ -114,9 +114,30 @@ def elapsed_ms(self) -> int: return int((self._clock() - self.started) / 1_000_0 def remaining_ns(self) -> int: return self.limit_ns - (self._clock() - self.started) @property def effort_exceeded(self) -> bool: + """Whether the budget is spent; a query that records nothing.""" return self._effort_budget is not None and self._stats is not None and self._effort_budget.exceeded(self._stats) + + def effort_stops(self) -> bool: + """Whether the effort budget stops the search at this poll, recording that it did. + + Only a caller about to do more work asks: asking is refusing it.""" + if not self.effort_exceeded: + return False + self.effort_stopped = True + return True + @property - def expired(self) -> bool: return self.effort_exceeded or self.remaining_ns <= 0 + def stopped(self) -> bool: + """Whether the search was cut, by the effort budget at a poll or by the clock; records nothing.""" + return self.effort_stopped or self.remaining_ns <= 0 + @property + def expired(self) -> bool: + # `effort_stops()` inlined: this is polled per lattice group and per item. + budget = self._effort_budget + if budget is not None and self._stats is not None and budget.exceeded(self._stats): + self.effort_stopped = True + return True + return self.limit_ns - (self._clock() - self.started) <= 0 def slice(self, parts: int) -> "Deadline": remaining = self.remaining_ns @@ -139,6 +160,29 @@ def _search_node_ceiling(self, stats: "SearchStats") -> int | None: return None return budget.max_search_nodes + def placement_ceilings(self, stats: "SearchStats") -> tuple[int | None, int | None]: + """The `placements_attempted` and `candidates_evaluated` values of `stats` at which the + budget stops a search, `None` for a counter no limit reads. A scan compares a counter + with its ceiling before each step, so the counter never passes its limit. O(1).""" + budget = self._effort_budget + if budget is None or self._stats is not stats: + return None, None + return budget.max_placement_attempts, budget.max_candidates_evaluated + + def effort_room(self, stats: "SearchStats", candidates: int, placements: int, nodes: int) -> bool: + """Whether the budget leaves room for `candidates`, `placements` and `nodes` more counted + steps on `stats`, so a solver that counts a run of steps at once never passes a limit; + a query that records nothing. O(1).""" + budget = self._effort_budget + if budget is None or self._stats is not stats: + return True + return ((budget.max_candidates_evaluated is None + or stats.candidates_evaluated + candidates <= budget.max_candidates_evaluated) + and (budget.max_placement_attempts is None + or stats.placements_attempted + placements <= budget.max_placement_attempts) + and (budget.max_search_nodes is None + or stats.search_nodes_expanded + nodes <= budget.max_search_nodes)) + def with_effort(self, effort_budget: "EffortBudget | None", stats: "SearchStats") -> "Deadline": """Bind an effort budget to this start's own stats, keeping the same time bound.""" return Deadline(0, limit_ns=self.remaining_ns, clock=self._clock, effort_budget=effort_budget, stats=stats) @@ -148,6 +192,7 @@ def check(self) -> None: # chained property calls that made it measurable there. budget = self._effort_budget if budget is not None and self._stats is not None and budget.exceeded(self._stats): + self.effort_stopped = True raise TimeLimitReached("packing time limit reached") if self.limit_ns - (self._clock() - self.started) <= 0: raise TimeLimitReached("packing time limit reached") @@ -664,7 +709,7 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo if container.axles is not None: ordered = _append_unseen(ordered, _axle_balanced_points(state, item, forms, limit_x)) else: - ordered = sorted(points, key=lambda p: (p.z, p.y, p.x)) + ordered = list(points) if len(points) <= 1 else sorted(points, key=lambda p: (p.z, p.y, p.x)) candidates: list[Candidate] = [] best: Candidate | None = None retained: list[tuple[tuple[int, ...], int, Candidate]] = [] @@ -676,14 +721,29 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo form_count = len(forms) query = index.query collision_checks = 0 + # The point's check alone let a point's rotations run a counter past its limit, so a point + # tries only the rotations the budget has room for, and a feasible candidate is compared + # with its ceiling before it is counted; reaching either stops the scan through the same + # check, which records the stop. + attempt_ceiling, candidate_ceiling = deadline.placement_ceilings(stats) + # Only this scan moves `placements_attempted` while it runs, so the room is kept locally. + attempts_left = (attempt_ceiling - stats.placements_attempted + if attempt_ceiling is not None else form_count * len(ordered)) for point in ordered: stats.collision_checks += collision_checks collision_checks = 0 deadline.check() stats.candidate_points_considered += 1 - stats.placements_attempted += form_count + if attempts_left < form_count: + point_forms = forms[:attempts_left] + stats.placements_attempted += attempts_left + attempts_left = 0 + else: + point_forms = forms + stats.placements_attempted += form_count + attempts_left -= form_count x1, y1, z1 = point.x, point.y, point.z - for rotation, physical, envelope, dx, dy, dz, shape in forms: + for rotation, physical, envelope, dx, dy, dz, shape in point_forms: x2, y2, z2 = x1 + dx, y1 + dy, z1 + dz # Conditional expressions rather than `max()`: the builtin call made this test # four times dearer per bag candidate (75 ns against 309 ns, CPython 3.11). @@ -760,6 +820,10 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo ) if projected_volume > usable: continue + if candidate_ceiling is not None and stats.candidates_evaluated >= candidate_ceiling: + stats.collision_checks += collision_checks + collision_checks = 0 + deadline.check() stats.candidates_evaluated += 1 score: tuple[int, ...] | None = None if tracing: @@ -800,6 +864,10 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo else: heapq.heapreplace(retained, entry) else: candidates.append(candidate) + if point_forms is not forms: + stats.collision_checks += collision_checks + collision_checks = 0 + deadline.check() stats.collision_checks += collision_checks if max_candidates == 1: return [] if best is None else [best] if bounded: @@ -1069,6 +1137,12 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] expansions: list[tuple[ContainerState, tuple[ItemInstance, ...]]] = [] exhausted = False for state, unplaced in beam: + # A spent budget would stop this node before its first counted step, so it + # is not counted either: a counter never passes its limit. + if deadline.effort_stops(): + exhausted = True + expansions.append((state, (*unplaced, *batch))) + continue stats.search_nodes_expanded += 1 try: children = list( @@ -1131,6 +1205,10 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] if node_limit is not None and nodes >= node_limit: exhausted = True break + if deadline.effort_stops(): + exhausted = True + expansions.append((_BeamChild.built(state, _placement_signature(state)), (*unplaced, *batch))) + continue nodes += 1 stats.search_nodes_expanded += 1 signature = _placement_signature(state) @@ -1175,7 +1253,7 @@ def beam_pack(container: Container, sequence: int, items: Sequence[ItemInstance] # Consequently a larger counted node limit replays the same prefix and can # only retain or improve this incumbent. state, unplaced = incumbent - return SingleContainerSolution(state, unplaced, False, deadline.expired) + return SingleContainerSolution(state, unplaced, False, deadline.stopped) completed = min(beam, key=lambda node: _node_key(node, by_volume=by_volume)) if beam else incumbent state, unplaced = completed if _node_key(completed, by_volume=by_volume) <= incumbent_key else incumbent return SingleContainerSolution(state, unplaced) @@ -1378,6 +1456,10 @@ def pack_one(self, container, sequence, items, config, stats, deadline): if ( total > 0 and not config.require_placement_coordinates and nesting_ticks == 0 and len({i.item.id for i in items[:total]}) == 1 + # The fast path counts the whole lattice in one step, so it is taken only when + # the budget covers it; otherwise the per-item loop below places the same lattice + # one counted item at a time and stops on the limit, never past it. + and deadline.effort_room(stats, total, total, 1) ): # Quantity-compression fast path: the lattice parameters above # already fully determine every coordinate in O(r); skip the O(n) loop @@ -1513,17 +1595,17 @@ def subtract_all( def _is_contained(extents: Sequence[tuple[int, int, int, int, int, int]], index: int) -> bool: - """Whether another extent contains `extents[index]`, for extents ordered by origin z. + """Whether another extent contains `extents[index]`, for extents ordered by + `(z1, y1, x1, -volume)`. - A container starts at or below what it contains, so the scan stops at the first - extent that starts higher: `O(prefix)` instead of `O(s)`, with no index to build. + A containing extent starts at or before `extents[index]` on every axis and has strictly + larger volume when origins match, so it always appears at a position `< index` with + `other[2] <= z1` already guaranteed. """ - x1, y1, z1, x2, y2, z2 = extents[index] - for position, other in enumerate(extents): - if other[2] > z1: - return False - if (position != index and other[0] <= x1 and other[1] <= y1 - and x2 <= other[3] and y2 <= other[4] and z2 <= other[5]): + x1, y1, _, x2, y2, z2 = extents[index] + for position in range(index): + other = extents[position] + if other[3] >= x2 and other[4] >= y2 and other[5] >= z2 and other[0] <= x1 and other[1] <= y1: return True return False @@ -1562,6 +1644,10 @@ def pack_one(self, container, sequence, items, config, stats, deadline): placed = True try: for item in batch: + # An item past a full container reaches no check of its own, so a spent + # budget is checked before its node is counted. + if deadline.effort_stops(): + raise TimeLimitReached("effort budget spent") stats.search_nodes_expanded += 1 chosen = self._choose(state, spaces, item, config, constraints, stats, deadline) if chosen is None: @@ -1678,8 +1764,9 @@ def __init__(self, constraints: Sequence[PlacementConstraint] = (), *, modes: tuple[str, ...] = MODES, node_limit: int | None = None): self.constraints = tuple(constraints) self.modes = modes - # `fast` and `balanced` leave `container_plan_node_limit` at one, which would stop - # the lattice enumeration before its first block; their start carries its own bound. + # Every profile but `quality` leaves `container_plan_node_limit` at one, which would + # stop the lattice enumeration before its first block, so under them the solver + # carries its own bound (`_node_limit`); the start `fast` and `balanced` add passes it. self.node_limit = node_limit def pack_one(self, container, sequence, items, config, stats, deadline): @@ -1687,16 +1774,14 @@ def pack_one(self, container, sequence, items, config, stats, deadline): return ExtremePointSolver(self.constraints).pack_one( container, sequence, items, config, stats, deadline ) - if self.node_limit is not None: - config = replace(config, container_plan_node_limit=self.node_limit) + if (node_limit := self._node_limit(config)) != config.container_plan_node_limit: + config = replace(config, container_plan_node_limit=node_limit) best: SingleContainerSolution | None = None - reached = False # Group enumerations are pure arithmetic, so `balanced`'s second mode and every # later round reuse them (see `_lattice`). lattices: dict[tuple[int, int, int, int], tuple[int, int, int, int, int]] = {} for mode in self.modes: if deadline.expired: - reached = True break solution = self._pack_mode( container, sequence, items, config, stats, deadline, mode, lattices @@ -1705,15 +1790,25 @@ def pack_one(self, container, sequence, items, config, stats, deadline): solution = self._lookahead( container, sequence, items, config, stats, deadline, mode, lattices, solution ) - reached = reached or solution.time_limit_reached by_volume = _ranks_by_volume(config) if best is None or self._solution_key(solution, by_volume) < self._solution_key(best, by_volume): best = solution + # Only the deadline or the effort budget marks the answer as stopped by a limit: the + # node limit is a search bound, and the answer it leaves is the search's own. if best is None: return SingleContainerSolution( - ContainerState(container, sequence), tuple(items), False, reached + ContainerState(container, sequence), tuple(items), False, deadline.stopped ) - return SingleContainerSolution(best.state, best.unpacked, False, reached) + return SingleContainerSolution(best.state, best.unpacked, False, deadline.stopped) + + def _node_limit(self, config: PackingConfig) -> int: + """Lattice nodes one mode may enumerate: the start's own bound, `quality`'s + `container_plan_node_limit`, or `BLOCK_START_NODE_LIMIT` under any other profile.""" + if self.node_limit is not None: + return self.node_limit + if config.profile.value == "quality": + return config.container_plan_node_limit + return BLOCK_START_NODE_LIMIT def _supports(self, container, items, config) -> bool: if (self.constraints or container.obstacles or container.preloaded @@ -1814,7 +1909,7 @@ def _pack_mode(self, container, sequence, items, config, stats, deadline, mode, lattices, 0, ) return SingleContainerSolution( - solution.state, solution.unpacked, False, reached or deadline.expired + solution.state, solution.unpacked, False, reached or deadline.stopped ) def _complete(self, container, state, spaces, remaining, forms, config, stats, deadline, @@ -1831,7 +1926,9 @@ def _complete(self, container, state, spaces, remaining, forms, config, stats, d ) if not leading or reached: break - self._commit(state, leading[0], remaining, config, stats) + if not self._commit(state, leading[0], remaining, config, stats, deadline): + reached = True + break spaces = subtract_all(spaces, [leading[0].box], stats) unpacked = tuple( instance @@ -1892,10 +1989,11 @@ def _lookahead(self, container, sequence, items, config, stats, deadline, mode, chosen_key, known, block = chosen if chosen_key < best_key: best, best_key = known, chosen_key - self._commit(state, block, remaining, config, stats) + if not self._commit(state, block, remaining, config, stats, deadline): + break spaces = subtract_all(spaces, [block.box], stats) return SingleContainerSolution( - best.state, best.unpacked, False, best.time_limit_reached or deadline.expired + best.state, best.unpacked, False, best.time_limit_reached or deadline.stopped ) def _completion(self, container, state, spaces, remaining, forms, config, stats, deadline, @@ -1903,7 +2001,8 @@ def _completion(self, container, state, spaces, remaining, forms, config, stats, """The greedy completion after committing `block`, on copies of the round's state.""" trial_state = state.copy() trial_remaining = {item_id: list(available) for item_id, available in remaining.items()} - self._commit(trial_state, block, trial_remaining, config, stats) + if not self._commit(trial_state, block, trial_remaining, config, stats, deadline): + return None, nodes, True return self._complete( container, trial_state, subtract_all(spaces, [block.box], stats), trial_remaining, forms, config, stats, deadline, mode, lattices, nodes, @@ -1954,6 +2053,7 @@ def _leading_blocks(self, container, state, spaces, remaining, forms, config, st budget = min(budget, node_ceiling - stats.search_nodes_expanded) if group_nodes > budget: stats.search_nodes_expanded += budget + deadline.effort_stops() return leading, nodes + budget, True nodes += group_nodes stats.search_nodes_expanded += group_nodes @@ -1973,38 +2073,48 @@ def _leading_blocks(self, container, state, spaces, remaining, forms, config, st del leading[leaders:] return leading, nodes, False - def _commit(self, state, best, remaining, config, stats) -> None: - """Place every member of the chosen block, bottom layer first.""" - chosen = remaining[best.item_id][:best.count] - del remaining[best.item_id][:best.count] + def _commit(self, state, best, remaining, config, stats, deadline) -> bool: + """Place the chosen block's members, bottom layer first; `False` when the budget stopped it. + + Each member is one counted candidate and placement. When the effort budget has room + for fewer than the whole block, the members it has room for are placed, the budget + records the stop, and the search ends here: a counter never passes its limit. + Members go x fastest, then y, then z, so a layer is filled before the one above it and + a member above the bottom layer rests on the member beneath it.""" + members = best.count + attempt_ceiling, candidate_ceiling = deadline.placement_ceilings(stats) + if attempt_ceiling is not None: + members = min(members, attempt_ceiling - stats.placements_attempted) + if candidate_ceiling is not None: + members = min(members, candidate_ceiling - stats.candidates_evaluated) + members = max(0, members) + chosen = remaining[best.item_id][:members] + del remaining[best.item_id][:members] clearance = config.clearance.ticks - index = 0 - for z in range(best.nz): - for y in range(best.ny): - for x in range(best.nx): - point = Point( - best.space.origin.x + x * best.envelope.length.ticks, - best.space.origin.y + y * best.envelope.width.ticks, - best.space.origin.z + z * best.envelope.height.ticks, - ) - position = Point( - point.x + clearance, - point.y + clearance, - point.z + clearance, - ) - state.add_direct( - Placement( - chosen[index], position, best.rotation, best.physical, - point, best.envelope, - # Above its bottom layer a member rests on an identical - # footprint; the bottom layer rests on whatever is there. - self._member_support(state, chosen[index], point, best.envelope) - if z == 0 else 1.0, - ) - ) - index += 1 - stats.candidates_evaluated += 1 - stats.placements_attempted += 1 + per_layer = best.nx * best.ny + for index in range(members): + x, y, z = index % best.nx, (index // best.nx) % best.ny, index // per_layer + point = Point( + best.space.origin.x + x * best.envelope.length.ticks, + best.space.origin.y + y * best.envelope.width.ticks, + best.space.origin.z + z * best.envelope.height.ticks, + ) + position = Point(point.x + clearance, point.y + clearance, point.z + clearance) + state.add_direct( + Placement( + chosen[index], position, best.rotation, best.physical, point, best.envelope, + # Above its bottom layer a member rests on an identical footprint; the + # bottom layer rests on whatever is there. + self._member_support(state, chosen[index], point, best.envelope) + if z == 0 else 1.0, + ) + ) + stats.candidates_evaluated += 1 + stats.placements_attempted += 1 + if members < best.count: + deadline.effort_stops() + return False + return True def _placed_volume(state: ContainerState) -> int: @@ -2145,7 +2255,7 @@ def dfs(index: int, state: ContainerState, reachable: int, state_volume: int) -> best, tuple(i for i in items if i.id not in packed), False, - deadline.expired, + deadline.stopped, ) @@ -2377,7 +2487,40 @@ def _run_concurrent_start(solver, order, containers, config, container_selector, packed, unpacked, exhaustive, reached, dominant_lattice = orchestrator._across_containers( solver, order, containers, config, stats, budget, preloaded ) - return packed, unpacked, exhaustive, reached, dominant_lattice, stats + return packed, unpacked, exhaustive, reached, dominant_lattice, stats, budget.effort_stopped + + +def _start_budget(deadline: Deadline, effort_budget: EffortBudget | None, stats: SearchStats, + starts_left: int) -> Deadline: + """The deadline one start runs under. + + Without an effort budget each start gets an even share of what is left, so a slow start + cannot starve the rest. Under one the clock is only the request's fuse: every start gets + all that is left, and the clock stops the search only as a whole, so the portfolio-wide + deadline and `time_limit_reached` say it did. A slice would let a start the clock cut + lose quietly, making a budgeted answer depend on the machine's speed. + """ + if effort_budget is None: + return deadline.slice(starts_left) + return deadline.with_effort(effort_budget, stats) + + +def _limits_reached(reached: bool, effort_stopped: bool, remaining_ns: int) -> tuple[bool, bool]: + """Which limit cut a start, as `(wall clock, effort budget)`: at most one, and only for a + start that was cut. The effort budget is named only when a poll found it spent and the + search stopped there, not when a finished start used exactly its budget.""" + effort_reached = reached and effort_stopped + return reached and not effort_reached and remaining_ns <= 0, effort_reached + + +def _effort_limited(unpacked: tuple[UnpackedItem, ...], effort_reached: bool) -> tuple[UnpackedItem, ...]: + if not effort_reached: + return unpacked + return tuple( + UnpackedItem(item.instance, "effort_limit", item.details) + if item.reason == "time_limit" else item + for item in unpacked + ) class SolverOrchestrator: @@ -2602,19 +2745,11 @@ def _solve_sequential(self, items, containers, config, deadline, starts, effort_ else: order_name = f"{base_name}:beam" stats = SearchStats() - budget = deadline.slice(len(starts) - position) - if effort_budget is not None: - budget = budget.with_effort(effort_budget, stats) + budget = _start_budget(deadline, effort_budget, stats, len(starts) - position) start_config = _start_config(order_name, config) packed, unpacked, exhaustive, reached, dominant_lattice = self._across_containers(solver, order, containers, start_config, stats, budget, preloaded) - effort_reached = budget.effort_exceeded - wall_reached = reached and budget.remaining_ns <= 0 - if effort_reached: - unpacked = tuple( - UnpackedItem(item.instance, "effort_limit", item.details) - if item.reason == "time_limit" else item - for item in unpacked - ) + wall_reached, effort_reached = _limits_reached(reached, budget.effort_stopped, budget.remaining_ns) + unpacked = _effort_limited(unpacked, effort_reached) start_id = f"{solver.name}:{order_name}" results.append(RawSolution( start_id, packed, unpacked, stats, wall_reached, effort_reached, exhaustive @@ -2702,18 +2837,10 @@ def _solve_concurrent(self, items, containers, config, deadline, starts, effort_ solver0, order_name0, order0 = starts[0] stats0 = SearchStats() - budget0 = deadline.slice(len(starts)) - if effort_budget is not None: - budget0 = budget0.with_effort(effort_budget, stats0) + budget0 = _start_budget(deadline, effort_budget, stats0, len(starts)) packed0, unpacked0, exhaustive0, reached0, dominant_lattice0 = self._across_containers(solver0, order0, containers, config, stats0, budget0, preloaded) - effort_reached0 = budget0.effort_exceeded - wall_reached0 = reached0 and budget0.remaining_ns <= 0 - if effort_reached0: - unpacked0 = tuple( - UnpackedItem(item.instance, "effort_limit", item.details) - if item.reason == "time_limit" else item - for item in unpacked0 - ) + wall_reached0, effort_reached0 = _limits_reached(reached0, budget0.effort_stopped, budget0.remaining_ns) + unpacked0 = _effort_limited(unpacked0, effort_reached0) start_id0 = f"{solver0.name}:{order_name0}" results[0] = RawSolution(start_id0, packed0, unpacked0, stats0, wall_reached0, effort_reached0, exhaustive0) records[0] = StartRecord(start_id0, True, not reached0, reached0) @@ -2738,15 +2865,10 @@ def _solve_concurrent(self, items, containers, config, deadline, starts, effort_ } outcomes = {futures[future]: future.result() for future in futures} for position, (solver, order_name, order) in remaining: - packed, unpacked, exhaustive, reached, dominant_lattice, stats = outcomes[position] - effort_reached = effort_budget is not None and effort_budget.exceeded(stats) - wall_reached = reached and (absolute_deadline_ns - monotonic_ns()) <= 0 - if effort_reached: - unpacked = tuple( - UnpackedItem(item.instance, "effort_limit", item.details) - if item.reason == "time_limit" else item - for item in unpacked - ) + packed, unpacked, exhaustive, reached, dominant_lattice, stats, effort_stopped = outcomes[position] + wall_reached, effort_reached = _limits_reached( + reached, effort_stopped, absolute_deadline_ns - monotonic_ns()) + unpacked = _effort_limited(unpacked, effort_reached) start_id = f"{solver.name}:{order_name}" results[position] = RawSolution(start_id, packed, unpacked, stats, wall_reached, effort_reached, exhaustive) records[position] = StartRecord(start_id, True, not reached, reached) @@ -2957,7 +3079,6 @@ def _across_containers(self, solver, items, containers, config, stats, deadline, config.container_plan_beam_width > 1 and isinstance(self.container_selector, DefaultContainerSelector) ) - self._record_root_bound(solver, beam, items, containers, config, stats) opened = self._open_preloaded(solver, preloaded, items, containers, config, stats, deadline) if beam: return self._across_containers_beam( @@ -3077,44 +3198,6 @@ def _plan_bound_key(self, plan, containers, config) -> tuple: packing_signature = tuple(container.id for container in plan.packed) return (*partial, remaining_signature, packing_signature) - @staticmethod - def _record_root_bound(solver, beam, items, containers, config, stats) -> None: - """Compute the request-level lower bound once, at the root. - - The plan beam already computes the same relaxation *per node* to prune with -- the - request-level bound is that formula with the state empty, which is why this costs one - sort rather than a second search. `exact_small` gets it for the opposite reason: it - exhausts its candidate set and can say the incumbent is optimal over that set, and a - bound is what turns "I stopped looking" into a statement about the request. - - Only for the default objective. `lowest_cost` and `maximum_value` order their score - keys differently, so a bound vector compared against them would line up cost against - container count -- not a weaker claim, a meaningless one. - - Nothing downstream reads this yet, and that is deliberate: the number is visible to - the engines and to nothing else until a contract freeze decides whether a caller ever - sees a gap. - """ - if stats.objective_lower_bound is not None: - return - if not (beam or getattr(solver, "name", None) == "exact_small"): - return - if config.objective != "default": - return - # Its unused-volume and height keys are ratios against a declared size, and a - # made-to-measure box has none until it is cut: the bound would not be admissible. - if any(container.made_to_measure is not None for container in containers): - return - try: - stats.objective_lower_bound = bounds.compute(items, containers).as_tuple() - except bounds.BoundOverflowError: - # The bound refuses past its declared ceiling. Nothing reads it yet, so - # a refusal leaves it unset rather than failing a pack that is otherwise fine -- - # the refusal is the bound declining to answer, not the request being invalid. - # When a caller-facing gap field exists, that field carries the refusal instead. - stats.objective_lower_bound = None - - def _across_containers_beam(self, solver, items, containers, config, stats, deadline, opened): """Bounded deterministic beam over partial multi-container plans. @@ -3130,6 +3213,7 @@ def _across_containers_beam(self, solver, items, containers, config, stats, dead initial, reached = opened beam = [] if reached else [initial] incumbent = initial + incumbent_score = self._score_plan(incumbent.packed, incumbent.remaining, config) plan_nodes = 0 while beam and plan_nodes < config.container_plan_node_limit: @@ -3178,10 +3262,10 @@ def _across_containers_beam(self, solver, items, containers, config, stats, dead plan.dominant_lattice and one.dominant_lattice, ) expansions.append(child) - if self._score_plan(child.packed, child.remaining, config) < self._score_plan( - incumbent.packed, incumbent.remaining, config - ): + child_score = self._score_plan(child.packed, child.remaining, config) + if child_score < incumbent_score: incumbent = child + incumbent_score = child_score if reached: break if reached: @@ -3192,19 +3276,18 @@ def _across_containers_beam(self, solver, items, containers, config, stats, dead # Exact dominance: same remaining instances plus the same inventory means # the continuation set is identical. A worse closed prefix can never catch # up because every objective component is additive and non-negative. - dominant: dict[tuple, _ContainerPlan] = {} + dominant: dict[tuple, tuple[tuple[int, ...], _ContainerPlan]] = {} for plan in expansions: signature = ( tuple(item.id for item in plan.remaining), tuple((key, plan.inventory[key]) for key in sorted(plan.inventory)), ) + partial_score = self._score_plan(plan.packed, (), config) previous = dominant.get(signature) - if previous is None or self._score_plan(plan.packed, (), config) < self._score_plan( - previous.packed, (), config - ): - dominant[signature] = plan + if previous is None or partial_score < previous[0]: + dominant[signature] = (partial_score, plan) beam = sorted( - dominant.values(), + (pair[1] for pair in dominant.values()), key=lambda plan: self._plan_bound_key(plan, containers, config), )[:config.container_plan_beam_width] @@ -3384,7 +3467,7 @@ def _across_containers_greedy(self, solver, items, containers, config, stats, de if reason == "no_feasible_placement": reason = self._limit_reason(instance, containers, opened, config) or reason unpacked.append(UnpackedItem(instance, reason, details)) - # Optimality may only be claimed when an exhaustive search filled a single - # container -- the cheapest one, since containers are tried in cost order. + # `RawSolution.exhaustive` is a hint for extension scorers, not a proof: the result's + # `optimal` status comes only from attaining the lower bound (`packer._stated_optimality`). proven = exhaustive and not reached and not unpacked and len(packed) == 1 return tuple(packed), tuple(unpacked), proven, reached, dominant_lattice diff --git a/src/packvium/units.py b/src/packvium/units.py index 4051089..741f2f6 100644 --- a/src/packvium/units.py +++ b/src/packvium/units.py @@ -6,7 +6,7 @@ from enum import Enum from fractions import Fraction import re -from typing import Any +from typing import Any, Optional, Tuple, Type, TypeVar class Rounding(str, Enum): @@ -52,6 +52,12 @@ def _fraction(value: int | str | Decimal | Fraction) -> Fraction: raise refused("wrong_type", f"invalid decimal or fraction: {text}") from error +def _unit_key(unit: object) -> str | None: + """A unit code as the tables key it, or None for a value that is no code at all: `None` or + a number is an unknown unit, as the JSON path refuses it, not an AttributeError.""" + return unit.lower().strip() if isinstance(unit, str) else None + + def _non_negative(ticks: int, kind: str) -> int: """A measure read from the caller's input; `__post_init__` still guards the arithmetic, where a negative value is a defect rather than a caller's mistake.""" @@ -111,7 +117,7 @@ def __post_init__(self) -> None: @classmethod def of(cls, value: int | str | Decimal | Fraction, unit: str = "mm", rounding: Rounding = Rounding.NEAREST) -> "Length": - key = unit.lower().strip() + key = _unit_key(unit) if key not in cls._MULTIPLIERS: raise refused("invalid_unit", f"unsupported length unit: {unit}") return cls(_non_negative(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding), "length")) @@ -183,7 +189,7 @@ def __post_init__(self) -> None: @classmethod def of(cls, value: Any, unit: str = "g", rounding: Rounding = Rounding.NEAREST) -> "Weight": - key = unit.lower().strip() + key = _unit_key(unit) if key not in cls._MULTIPLIERS: raise refused("invalid_unit", f"unsupported weight unit: {unit}") return cls(_non_negative(_rounded(_fraction(value) * cls._MULTIPLIERS[key], rounding), "weight")) @@ -215,6 +221,60 @@ def to_dict(self, unit: str = "g") -> dict[str, int | str]: return {"ticks": self.ticks, "value": self.decimal(unit), "unit": unit} +_Measure = TypeVar("_Measure", Length, Weight) + + +class ParsedMeasures: + """The measures one request spells, each spelling parsed once. + + The request check parses every measure to refuse a bad one, and deserialisation reads the + same spellings again to build the model. A parse is a pure function of the spelling, its + kind and the default unit, so a repeated read returns the first one's value. The key carries + the spelling's type, so `True` never answers for `1`; a `{value, unit}` object is keyed by + the two fields `parse` reads, each with its type, so `{"value": 1}` never answers for + `{"value": "1"}`. A refused spelling is not kept, so it is refused again with the same + error, and an object with a field the key cannot hash is parsed on every read. Lives for one request: O(1) per + read, O(distinct spellings) memory.""" + + __slots__ = ("_parsed",) + + def __init__(self) -> None: + self._parsed: dict[tuple[Any, ...], Any] = {} + + def read(self, kind: Type[_Measure], value: Any, default_unit: str) -> _Measure: + if not isinstance(value, (str, int, float)): + return self._read_object(kind, value, default_unit) + key = (kind, type(value), value, default_unit) + parsed = self._parsed.get(key) + if parsed is None: + parsed = self._parsed[key] = kind.parse(value, default_unit) + return parsed + + def _read_object(self, kind: Type[_Measure], value: Any, default_unit: str) -> _Measure: + key = _object_key(kind, value, default_unit) + if key is None: + return kind.parse(value, default_unit) + try: + parsed = self._parsed.get(key) + except TypeError: + # An unhashable field (a list as `value`) is the parser's to refuse, as it was + # before objects were kept. + return kind.parse(value, default_unit) + if parsed is None: + parsed = self._parsed[key] = kind.parse(value, default_unit) + return parsed + + +def _object_key(kind: type, value: Any, default_unit: str) -> Optional[Tuple[Any, ...]]: + """The two fields `kind.parse` reads from a `{value, unit}` object, each with its type, or + None for anything else, which is parsed on every read. An object without `value` is left + to `parse` to refuse.""" + if not isinstance(value, dict) or "value" not in value: + return None + field, unit = value["value"], value.get("unit", default_unit) + return (kind, dict, type(field), field, type(unit), unit) + + def output_length_unit(request_unit: str) -> str: """The output unit a request without `output.length_unit` is written in. diff --git a/src/packvium/validation.py b/src/packvium/validation.py index a525433..4c51612 100644 --- a/src/packvium/validation.py +++ b/src/packvium/validation.py @@ -56,7 +56,8 @@ def validate( or p.instance.item.ground_contact_rule not in (None, "free") for p in placements)) stack_sensitive = any(is_stack_sensitive(p.instance.item) for p in placements) - stack_graph = LoadSupportGraph(load_units(placements)) if stack_sensitive else None + units = load_units(placements) + stack_graph = LoadSupportGraph(units) if stack_sensitive else None for left, right in self._collision_pairs(placements): issues.append(ValidationIssue( "collision", @@ -101,11 +102,14 @@ def validate( issues.append(ValidationIssue(result.code, f"{item_id}: {result.detail}")) # A second, whole-container bearing pass: the per-placement check above # reports the first offender it meets, this one is anchored on the container. - units = load_units(packed.placements) density_limit = None if packed.container.max_stack_density is None else packed.container.max_stack_density.ticks - failure = (overloaded(units) or crushed(units) or stack_limit_exceeded(units) - or stack_density_exceeded(units, density_limit)) - if failure is not None: issues.append(ValidationIssue(failure[0], f"{packed.id}: {failure[1]}")) + if stack_sensitive or density_limit is not None: + if stack_graph is None: + stack_graph = LoadSupportGraph(units) + failure = (overloaded(units, stack_graph) or crushed(units, stack_graph) + or stack_limit_exceeded(units, stack_graph) + or stack_density_exceeded(units, density_limit, stack_graph)) + if failure is not None: issues.append(ValidationIssue(failure[0], f"{packed.id}: {failure[1]}")) if packed.container.axles is not None: axle_failure = axle_load_exceeded( packed.container.axles, diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 815e478..7bf0b56 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -22,7 +22,7 @@ class of bug the fixed-point design exists to prevent. FloorConstraint, LoadSupportGraph, LoadUnit, SupportConstraint, TagCountConstraint, RIDES_THE_WHOLE_ROUTE, RouteOrderConstraint, TopLoadConstraint, direct_support_view, load_units, overloaded, required_area, - route_order_violated, scaled_ratio, + resting_above, route_order_violated, scaled_ratio, stack_density_exceeded, stack_limit_exceeded, stacked_counts, top_loads) from packvium.contact import ContactGraph from packvium.models import Placement, is_stack_sensitive @@ -1419,6 +1419,24 @@ def test_the_first_blocker_named_is_the_lowest_index_not_the_first_reached(): assert route_order_violated(scene, [0.0, 0.0, 1.0, 2.0]) == ("unloading_order_violation", "base blocked by right") +def test_a_blocker_reached_through_two_supporters_is_named_once_and_matches_resting_above(): + """`bridge` rests on both `left` and `right`, so the walk up from `base` reaches it twice. + The answer must be the one the full `resting_above` closure gives.""" + scene = ( + unit(0, 0, 0, 20, 10, 10, label="base"), + unit(0, 0, 10, 10, 10, 10, label="left"), + unit(10, 0, 10, 10, 10, 10, label="right"), + unit(0, 0, 20, 20, 10, 10, label="bridge"), + ) + stops = [0.0, 0.0, 0.0, 1.0] + above = resting_above(scene) + first = next(index for index, uppers in enumerate(above) if any(stops[u] > stops[index] for u in uppers)) + blocker = min(u for u in above[first] if stops[u] > stops[first]) + expected = ("unloading_order_violation", f"{scene[first].label} blocked by {scene[blocker].label}") + assert expected == ("unloading_order_violation", "base blocked by bridge") + assert route_order_violated(scene, stops) == expected + + # ------------------------------------------------- stop accessibility # # The worked examples in docs/STOP-ACCESSIBILITY.md were derived by hand and confirmed diff --git a/tests/test_effort_boundary.py b/tests/test_effort_boundary.py new file mode 100644 index 0000000..d5a02c4 --- /dev/null +++ b/tests/test_effort_boundary.py @@ -0,0 +1,218 @@ +"""An effort budget names a stop, never a counter that merely reached its limit. + +A start that finishes on exactly its budget was not stopped, so it is reported complete; a +counted step past a limit is refused, so no counter ends above its limit; and under an effort +budget the clock is the request's fuse, never sliced per start. +""" +from __future__ import annotations + +import json + +import pytest + +from packvium import Container, Dimensions, Item, PackingConfig +from packvium.effort import EffortBudget +from packvium.serialization import pack_from_dict +from packvium.solvers import (ContainerState, Deadline, MaximalSpaceSolver, SearchStats, + TimeLimitReached, _start_budget, beam_pack, default_constraints, + find_candidates) + + +def request(budget: dict | None, *, solvers: list[str] | None = None, profile: str = "fast", + items: list[dict] | None = None) -> dict: + configuration: dict = {"solver_profile": profile, "time_limit_ms": 60_000} + if budget is not None: + configuration["effort_budget"] = budget + if solvers is not None: + configuration["solvers"] = solvers + return { + "units": {"length": "mm"}, + "configuration": configuration, + "items": items or [ + {"id": "a", "quantity": 3, "dimensions": {"length": "40", "width": "30", "height": "20"}}, + {"id": "b", "quantity": 3, "dimensions": {"length": "25", "width": "25", "height": "35"}}, + ], + "containers": [{"id": "c", "inner_dimensions": {"length": "100", "width": "80", "height": "90"}}], + } + + +def pack(payload: dict) -> dict: + result = pack_from_dict(payload) + return result if isinstance(result, dict) else result.to_dict() + + +CUBES = [{"id": "cube", "quantity": 60, "dimensions": {"length": "10", "width": "10", "height": "10"}}] + + +def test_a_start_that_finishes_on_exactly_its_budget_is_not_stopped(): + unlimited = pack(request(None)) + used = unlimited["algorithm"]["placements_attempted"] + + exact = pack(request({"max_placement_attempts": used})) + + assert exact["algorithm"]["effort_limit_reached"] is False + assert exact["termination"]["code"] == "complete" + assert exact["termination"]["winning_start_truncated"] is False + assert exact["score"] == unlimited["score"] + assert exact["containers"] == unlimited["containers"] + + +def test_a_budget_one_short_stops_the_start_on_its_limit(): + unlimited = pack(request(None)) + used = unlimited["algorithm"]["placements_attempted"] + + short = pack(request({"max_placement_attempts": used - 1})) + + assert short["termination"]["any_start_truncated"] is True + assert short["algorithm"]["placements_attempted"] <= used - 1 + truncated = [start for start in short["termination"]["starts"] if start["truncated"]] + assert truncated and all(not start["completed"] for start in truncated) + + +def test_a_block_commit_places_only_what_the_budget_has_room_for(): + result = pack(request({"max_placement_attempts": 7}, solvers=["homogeneous_blocks"], + profile="quality", items=CUBES)) + + assert result["algorithm"]["placements_attempted"] == 7 + assert result["algorithm"]["candidates_evaluated"] == 7 + assert result["summary"]["packed_item_count"] == 7 + assert result["algorithm"]["effort_limit_reached"] is True + assert result["termination"]["code"] == "effort_limit" + + +def test_a_lattice_too_large_for_the_budget_is_placed_one_counted_item_at_a_time(): + payload = request({"max_candidates_evaluated": 7}, solvers=["grid"], items=CUBES) + payload["configuration"]["require_placement_coordinates"] = False + + result = pack(payload) + + assert result["algorithm"]["candidates_evaluated"] == 7 + assert result["summary"]["packed_item_count"] == 7 + assert result["algorithm"]["effort_limit_reached"] is True + + +def test_an_effort_budget_gives_every_start_the_whole_remaining_clock(): + deadline = Deadline(1_000, clock=lambda: 0) + + budgeted = _start_budget(deadline, EffortBudget(max_search_nodes=5), SearchStats(), 8) + unbudgeted = _start_budget(deadline, None, SearchStats(), 8) + + assert budgeted.remaining_ns == 1_000_000_000 + assert unbudgeted.remaining_ns == 1_000_000_000 // 8 + + +def test_a_poll_on_a_spent_budget_records_the_stop_and_a_later_read_does_not(): + stats = SearchStats() + deadline = Deadline(60_000).with_effort(EffortBudget(max_search_nodes=3), stats) + stats.search_nodes_expanded = 3 + + assert deadline.stopped is False + assert deadline.effort_stopped is False + assert deadline.expired is True + assert deadline.effort_stopped is True + assert deadline.stopped is True + + +# The m1 cart (conformance's regression-effort-budget-truncated-loser), with a budget that +# never binds. +M1_CART = { + "units": {"length": "cm"}, + "configuration": { + "objective": "lowest_landed_cost", + "effort_budget": {"max_candidates_evaluated": 1_000_000, "max_placement_attempts": 1_000_000, + "max_search_nodes": 1_000_000}, + "time_limit_ms": 300_000, "dimensional_weight_length_unit": "cm", + "dimensional_weight_weight_unit": "kg", "dimensional_weight_divisor": 5000, + }, + "items": [ + {"id": "product-21", "quantity": 10, "dimensions": {"length": "22", "width": "18", "height": "25"}, + "weight": "1.4 kg"}, + {"id": "product-22", "quantity": 20, "dimensions": {"length": "9", "width": "9", "height": "10"}, + "weight": "0.25 kg"}, + {"id": "product-11", "quantity": 20, "dimensions": {"length": "10", "width": "8", "height": "9"}, + "weight": "0.35 kg"}, + ], + "containers": [ + {"id": "small", "inner_dimensions": {"length": "25", "width": "20", "height": "15"}, "cost_minor": 40, + "rate_table": {"weight_brackets_g": [1000, 2000, 5000, 10000], "prices_minor": [495, 695, 995, 1495]}}, + {"id": "large", "inner_dimensions": {"length": "40", "width": "30", "height": "30"}, + "outer_dimensions": {"length": "41", "width": "31", "height": "31"}, "cost_minor": 90, + "rate_table": {"weight_brackets_g": [3000, 20000], "prices_minor": [1200, 1900], + "minimum_charge_minor": 1000, "fuel_surcharge_permille": 85}}, + ], +} + + +def test_a_budget_that_never_binds_gives_one_answer_or_says_the_clock_fired(): + """(b): with the clock not sliced per start, a run either finishes every start and + gives the one answer, or the fuse fired as a whole and `time_limit_reached` says so.""" + payload = json.loads(json.dumps(M1_CART)) + reference = pack(payload) + payload["configuration"]["time_limit_ms"] = 1_500 + + for _ in range(3): + result = pack(payload) + assert result["algorithm"]["time_limit_reached"] or ( + result["score"], result["containers"]) == (reference["score"], reference["containers"]) + + +def budgeted(stats: SearchStats, **limits: int) -> Deadline: + return Deadline(60_000).with_effort(EffortBudget(**limits), stats) + + +def cubes(quantity: int): + return Item.create("cube", Dimensions.mm(30, 20, 10), quantity=quantity).instances() + + +BOX = Container.create("c", Dimensions.mm(100, 100, 100)) + + +def test_a_feasible_candidate_past_its_limit_stops_the_scan_inside_a_point(): + config = PackingConfig.balanced() + stats = SearchStats() + deadline = budgeted(stats, max_candidates_evaluated=1) + + with pytest.raises(TimeLimitReached): + find_candidates(ContainerState(BOX, 1), cubes(1)[0], config, default_constraints(config), + stats, deadline, 1) + + assert stats.candidates_evaluated == 1 + assert stats.candidate_points_considered == 1 + assert deadline.effort_stopped + + +@pytest.mark.parametrize("config", [PackingConfig.balanced(), PackingConfig.quality()], + ids=["greedy", "beam"]) +def test_a_beam_node_reached_with_a_spent_budget_is_refused_and_not_counted(config): + stats = SearchStats(search_nodes_expanded=2) + deadline = budgeted(stats, max_search_nodes=2) + + solution = beam_pack(BOX, 1, cubes(3), config, default_constraints(config), stats, deadline) + + assert stats.search_nodes_expanded == 2 + assert solution.time_limit_reached + assert deadline.effort_stopped + + +def test_a_maximal_space_item_reached_with_a_spent_budget_is_refused_and_not_counted(): + config = PackingConfig.quality() + stats = SearchStats(search_nodes_expanded=2) + deadline = budgeted(stats, max_search_nodes=2) + + solution = MaximalSpaceSolver().pack_one(BOX, 1, cubes(3), config, stats, deadline) + + assert stats.search_nodes_expanded == 2 + assert len(solution.unpacked) == 3 and solution.time_limit_reached + assert deadline.effort_stopped + + +def test_a_wide_beam_stops_on_every_placement_limit_short_of_its_use_and_only_there(): + config = PackingConfig.quality() + full = SearchStats() + beam_pack(BOX, 1, cubes(3), config, default_constraints(config), full, Deadline(60_000)) + for limit in range(1, full.placements_attempted + 1): + stats = SearchStats() + deadline = budgeted(stats, max_placement_attempts=limit) + beam_pack(BOX, 1, cubes(3), config, default_constraints(config), stats, deadline) + assert stats.placements_attempted <= limit + assert deadline.effort_stopped == (limit < full.placements_attempted) diff --git a/tests/test_locks.py b/tests/test_locks.py index f4431cf..3018dcb 100644 --- a/tests/test_locks.py +++ b/tests/test_locks.py @@ -41,6 +41,7 @@ resolve_with_locks, ) from packvium.models import Container, Item, ItemInstance +from packvium.request_errors import InvalidRequestError from packvium.serialization import pack_from_dict FIXTURES = pathlib.Path(__file__).resolve().parents[2] / "conformance" / "fixtures" @@ -60,6 +61,25 @@ def eight_cubes() -> dict: return json.loads(fixture.read_text()) +@pytest.mark.parametrize("length", ["furlong", "", 25, True, {"unit": "mm"}, ["mm"]]) +def test_resolve_reports_the_request_unit_before_reading_items(length): + request = eight_cubes() + request["units"] = {"length": length} + for malformed_item in (False, True): + if malformed_item: + request["items"][0].pop("dimensions") + with pytest.raises(InvalidRequestError) as caught: + resolve_with_locks(request, []) + assert (caught.value.reason, caught.value.field) == ("invalid_unit", "/units/length") + + +@pytest.mark.parametrize("units", [None, {}, {"length": None}]) +def test_resolve_reads_null_units_as_millimetres(units): + request = eight_cubes() + request["units"] = units + assert count(resolve_with_locks(request, []).result) == 8 + + def placed(result: dict) -> set[tuple[int, str, str, tuple[int, int, int]]]: return { (index, placement["item_type"], placement["orientation"], @@ -245,7 +265,8 @@ def test_a_lock_that_cannot_apply_to_this_container_does_not_empty_it(): assert not resolve.preserved assert count(resolve.result) == 8 - assert resolve.result["status"] == "feasible" + # Eight cubes fill the box exactly, which the bound proves: the load is untouched. + assert resolve.result["status"] == "optimal" # -------------------------------------------------------- the constraint itself diff --git a/tests/test_made_to_measure.py b/tests/test_made_to_measure.py index 0252956..87e521c 100644 --- a/tests/test_made_to_measure.py +++ b/tests/test_made_to_measure.py @@ -15,6 +15,7 @@ from packvium.made_to_measure import (MAX_EXACT_INTEGER, SQUARE_METRE_TICKS2, box_cost_minor, fabricate, icbrt_ceil, instance_edges, per_square_metre, solving_envelopes) +from packvium import bounds from packvium.models import PackedContainer, PackingRequest, Placement from packvium.solvers import (ContainerState, Deadline, ExtremePointSolver, SearchStats, SingleContainerSolution, SolverOrchestrator, TimeLimitReached) @@ -276,11 +277,8 @@ def test_an_exact_search_is_never_reported_optimal_and_sets_no_root_bound(): data["configuration"]["solver_profile"] = "exact_small" data["items"][0]["quantity"] = 2 assert pack_from_dict(data)["optimality"]["code"] == "not_proven" - stats = SearchStats() declared = Container("machine", Dimensions.mm(1510, 1510, 1510), made_to_measure=machine()) - SolverOrchestrator._record_root_bound(ExtremePointSolver(), True, (), (declared,), - PackingConfig.quality(), stats) - assert stats.objective_lower_bound is None + assert bounds.for_request((), (declared,), "default") is None def test_a_load_larger_than_the_machine_opens_a_second_compact_box(): diff --git a/tests/test_optimality_bounds.py b/tests/test_optimality_bounds.py index 4fe1c5d..02de2ca 100644 --- a/tests/test_optimality_bounds.py +++ b/tests/test_optimality_bounds.py @@ -1,10 +1,11 @@ -"""The root lower bound is actually computed where the task says it is. +"""The request-level bound is computed where the design says, once per pack. The cross-implementation agreement and corpus soundness live in `conformance/tests/test_optimality_bounds_engine.py`. What is asserted here is the wiring: -that an `exact_small` or global-beam solve really does record a bound, that it stays out of -the serialised result, and that the number it records is sound against the packing that -solve produced. +that the packer computes the bound exactly once per pack, before the first solver, for every +solver and every covered objective, from every instance of the request; that the result +states it as OPTIMALITY-GAP.md § 1.1 says; and that the number is sound against +the packing that pack produced. That last part is why this is not a mock test. `hull_refinements` set the precedent for an internal counter and nothing ever asserted it fired, so "internal" quietly became @@ -23,10 +24,11 @@ import packvium # noqa: E402 from packvium import bounds, solvers # noqa: E402 +from packvium.extensions import DefaultSolutionScorer # noqa: E402 #: Four differently-shaped items. Identical items reach `GridSolver`'s quantity-compression -#: fast path, which answers before either target solver is consulted -- a request that never -#: exercises the code under test while looking like it does. +#: fast path, which answers before most solvers are consulted -- a request that would make +#: "for every solver" true of only one of them. ITEMS = [ { "id": f"i{index}", @@ -41,106 +43,242 @@ ] CONTAINERS = [{"id": "c", "inner_dimensions": {"length": "100", "width": "100", "height": "100"}}] +#: Every built-in profile, and the global container-set beam, which the old per-start bound +#: was confined to together with `exact_small`. +SOLVES = [ + {"solver_profile": "fast"}, + {"solver_profile": "balanced"}, + {"solver_profile": "quality"}, + {"solver_profile": "exact_small"}, + {"container_plan_beam_width": 4}, +] + -def _pack(monkeypatch, configuration): - """Pack, and return the result together with every bound the solve computed.""" - recorded: list[tuple] = [] - original = bounds.compute +#: Taken once, so that a second `_pack` in one test wraps the real functions rather than the +#: first call's spies. +_FOR_REQUEST = bounds.for_request +_SOLVE = solvers.SolverOrchestrator.solve - def spy(instances, containers): - computed = original(instances, containers) - recorded.append(computed.as_tuple()) + +def _spy(monkeypatch): + """Record every request-level bound, and its order against the solves.""" + recorded: list = [] + events: list[str] = [] + + def spy_bound(instances, containers, objective): + computed = _FOR_REQUEST(instances, containers, objective) + recorded.append(computed) + events.append("bound") return computed - monkeypatch.setattr(solvers.bounds, "compute", spy) - result = packvium.pack_from_dict({ - "containers": CONTAINERS, - "items": ITEMS, - "configuration": dict(configuration, time_limit_ms=20000), - }) - return result, recorded + def spy_solve(self, *arguments, **keywords): + events.append("solve") + return _SOLVE(self, *arguments, **keywords) + monkeypatch.setattr(bounds, "for_request", spy_bound) + monkeypatch.setattr(solvers.SolverOrchestrator, "solve", spy_solve) + return recorded, events -@pytest.mark.parametrize("configuration,expected_solver", [ - ({"solver_profile": "exact_small"}, "exact_small"), - ({"container_plan_beam_width": 4}, None), -]) -def test_a_bound_is_recorded_for_the_solvers_the_task_names(monkeypatch, configuration, - expected_solver): - """`exact_small` and the global container-set beam each reach the root bound.""" - result, recorded = _pack(monkeypatch, configuration) - assert recorded, f"no lower bound was computed for {configuration}" - if expected_solver is not None: - assert result["algorithm"]["solver"].startswith(expected_solver) +def _pack(monkeypatch, configuration, request=None): + """Pack, and return the result with every request-level bound and when it was taken. -@pytest.mark.parametrize("configuration", [ - {"solver_profile": "exact_small"}, - {"container_plan_beam_width": 4}, - {"solver_profile": "exact_small", "container_plan_beam_width": 4}, -]) -def test_the_recorded_bound_is_sound_against_the_packing_it_bounded(monkeypatch, - configuration): - """The bound and the arrangement come from the same solve, so they must agree. - - `gap` raises on a score below its bound rather than returning a negative number, so - calling it is the assertion. + `events` interleaves "bound" with "solve" so that "before the first solver" is read from + the order of calls rather than assumed. """ - result, recorded = _pack(monkeypatch, configuration) - score = [int(component) for component in result["score"]] - for bound in recorded: - bounds.gap(score, bounds.Bounds(*bound)) + recorded, events = _spy(monkeypatch) + document = request or {"containers": CONTAINERS, "items": ITEMS} + document = dict(document, configuration={"time_limit_ms": 20000, **configuration}) + return packvium.pack_from_dict(document), recorded, events + + +@pytest.mark.parametrize("configuration", SOLVES, ids=lambda c: str(next(iter(c.values())))) +def test_one_bound_per_pack_before_the_first_solver_for_every_solver(monkeypatch, configuration): + """F3: the old bound was taken per start, and only on the beam or `exact_small`.""" + result, recorded, events = _pack(monkeypatch, configuration) + assert events[0] == "bound" and events.count("bound") == 1, events + assert recorded[0] is not None + assert tuple(int(c) for c in result["score"]) >= recorded[0] + + +def test_the_bound_does_not_depend_on_the_deadline(monkeypatch): + """A start the deadline never reaches used to record nothing, so presence followed the + clock. Computed from the request alone, the bound is the same under any budget.""" + _, generous, _ = _pack(monkeypatch, {"solver_profile": "quality"}) + _, starved, _ = _pack(monkeypatch, {"solver_profile": "quality", "time_limit_ms": 1}) + assert generous == starved and generous[0] is not None + + +def test_lowest_cost_gets_the_same_bound_in_its_own_order(monkeypatch): + """`lowest_cost` scores `(count, cost, containers, unused, height)`; the bound is the same + five numbers moved to those positions, and still never above the packing.""" + _, default, _ = _pack(monkeypatch, {"solver_profile": "exact_small"}) + result, cheapest, _ = _pack( + monkeypatch, {"solver_profile": "exact_small", "objective": "lowest_cost"}) + unpacked, count, cost, unused, height = default[0] + assert cheapest[0] == (unpacked, cost, count, unused, height) + assert tuple(int(c) for c in result["score"]) >= cheapest[0] + + +@pytest.mark.parametrize("objective", ["maximum_value", "maximum_volume", + "open_dimension_height"]) +def test_an_objective_without_a_bound_gets_none(monkeypatch, objective): + """Their score positions rank other quantities, so a vector compared against them would + line up different things -- recording nothing is the honest answer.""" + _, recorded, _ = _pack(monkeypatch, {"solver_profile": "exact_small", "objective": objective}) + assert recorded == [None] + + +def test_a_custom_solution_scorer_gets_no_bound(): + """A caller-defined score's positions are not the bound's quantities.""" + request = packvium.PackingRequest( + tuple(packvium.Item.create(f"i{n}", packvium.Dimensions.mm(10, 10, 10)) for n in range(2)), + (packvium.Container.create("c", packvium.Dimensions.mm(100, 100, 100)),)) + assert packvium.Packer()._objective_bound(request) is not None + custom = packvium.Packer(solution_scorer=DefaultSolutionScorer()) + assert custom._objective_bound(request) is None + + +def test_a_request_past_the_ceiling_withholds_the_bound_and_is_still_answered(monkeypatch): + """F5's Python half: the refusal declines to state a bound; it never refuses the pack.""" + recorded, _ = _spy(monkeypatch) + heavy = packvium.Item.create("heavy", packvium.Dimensions.mm(10, 10, 10), weight=10 ** 31) + result = packvium.Packer(packvium.PackingConfig.quality(time_limit_ms=20000)).pack( + [heavy], [packvium.Container.create("c", packvium.Dimensions.mm(100, 100, 100))]) + assert recorded == [None] + assert result.complete + + +#: Finding F1, reproduced end to end. A 100 mm cube holds a fixed 100 x 100 x 50 mm slab and +#: one free slab of the same size, so the only packing fills it exactly. +FIXED_SLAB = { + "items": [{"id": "slab", "quantity": 2, + "dimensions": {"length": "100", "width": "100", "height": "50"}}], + "containers": [{"id": "cube", "quantity": 1, + "inner_dimensions": {"length": "100", "width": "100", "height": "100"}}], + "fixed_placements": [{"item_type": "slab", "container_type": "cube", "container_instance": 1, + "position": {"x": "0", "y": "0", "z": "0"}, "orientation": "LWH"}], +} + + +def test_a_fixed_placement_is_counted_by_the_bound(monkeypatch): + """F1: the bound used to see only the free slab and claim half the cube must stay empty. + + That was 500000 ppm on key 3 against a packing scoring 0 -- a lower bound above a packing + that exists. Over every instance the bound is the packing's own score: attained, and so a + proof. + """ + result, recorded, _ = _pack(monkeypatch, {"solver_profile": "quality"}, FIXED_SLAB) + score = tuple(int(c) for c in result["score"]) + assert score == (0, 1, 0, 0, 1_000_000) + assert recorded == [score] + assert bounds.gap(score, bounds.Bounds(*recorded[0])).attained + + slab = packvium.Item.create("slab", packvium.Dimensions.mm(100, 100, 50), quantity=2) + cube = packvium.Container.create("cube", packvium.Dimensions.mm(100, 100, 100), quantity=1) + free_only = bounds.compute(slab.instances()[1:], (cube,)) + with pytest.raises(bounds.UnsoundBoundError): + bounds.gap(score, free_only) -def test_the_bound_does_not_reach_the_serialised_result(monkeypatch): - """Reporting a gap to a caller is a new public result field, and this project reserves - and rejects such a field before a contract freeze rather than adding it mid-line. +def test_the_result_states_the_bound_it_was_given(monkeypatch): + """The one bound per pack is what the result states, prefix-truncated at the + first position its score misses, with the gap there (OPTIMALITY-GAP.md § 1.1). - Also the practical half: `algorithm.metrics` is serialised into every result, so a new - key there changes the bytes of every committed golden and would have to land in all four - engines at once. + Also the practical half: `algorithm.metrics` is serialised into every result, so the + bound belongs in `optimality` and nowhere else. """ - result, recorded = _pack(monkeypatch, {"solver_profile": "exact_small"}) - assert recorded, "the guard below would pass vacuously if nothing was computed" - metrics = result["algorithm"]["metrics"] - leaked = [key for key in metrics if "bound" in key or "gap" in key] - assert not leaked, f"the internal bound leaked into algorithm.metrics as {leaked}" + result, recorded, _ = _pack(monkeypatch, {"solver_profile": "exact_small"}) + assert recorded[0] is not None, "the assertions below would pass vacuously" + leaked = [key for key in result["algorithm"]["metrics"] if "bound" in key or "gap" in key] + assert not leaked, f"the bound leaked into algorithm.metrics as {leaked}" + score = tuple(int(c) for c in result["score"]) + stated = bounds.statement(score, recorded[0]) + assert {key: value for key, value in result["optimality"].items() if key != "code"} == stated + for alternative in result["alternatives"]: + assert {key: value for key, value in alternative["optimality"].items() + if key != "code"} == bounds.statement( + tuple(int(c) for c in alternative["score"]), recorded[0]) + + +def test_exhausting_a_search_is_no_longer_a_proof(): + """Q1: `exact_small` exhausting its candidates on one container used to set `optimal`. + Its candidates are only the points it generated, so that is not a proof; attainment is. + Four unlike items cannot fill the cube, so the bound is missed and the result says so.""" + result = packvium.pack_from_dict({"containers": CONTAINERS, "items": ITEMS, + "configuration": {"solver_profile": "exact_small", + "time_limit_ms": 20000}}) + assert result["complete"] and len(result["containers"]) == 1 + assert result["status"] == "feasible" + assert result["optimality"]["code"] == "not_proven" and "gap_key" in result["optimality"] + + +def test_a_complete_result_that_attains_its_bound_is_proven_optimal(): + """Q1: attainment sets `optimal` and `proven_optimal`, and the field carries the whole + bound with no gap: absence of a gap, next to a bound, is the proof.""" + result = packvium.pack_from_dict(FIXED_SLAB) + assert result["status"] == "optimal" + assert result["optimality"] == {"code": "proven_optimal", "lower_bound": [0, 1, 0, 0, 1_000_000]} + + +def test_an_incomplete_result_that_attains_its_bound_keeps_best_found(): + """§ 1.3: `code` is defined by completeness for an incomplete result; the proof is still + readable from `lower_bound` without `gap_key`.""" + result = packvium.pack_from_dict({ + "items": [{"id": "i", "quantity": 3, + "dimensions": {"length": "100", "width": "100", "height": "100"}}], + "containers": [{"id": "c", "quantity": 1, + "inner_dimensions": {"length": "100", "width": "100", "height": "100"}}], + }) + assert result["status"] == "best_found" and not result["complete"] + assert result["optimality"] == {"code": "best_found", "lower_bound": [2, 1, 0, 0, 1_000_000]} -def test_an_ordinary_solve_computes_no_bound(monkeypatch): - """The constructive heuristics have nothing to add to it and do not pay for it. +def test_a_statement_stops_at_the_first_missed_position(): + """The prefix, the unreduced relative gap, its absence on a zero bound, and nothing at all + for a score below its bound, which shows the bound wrong.""" + bound = (0, 2, 10, 500, 7) + assert bounds.statement(bound, bound) == {"lower_bound": list(bound)} + assert bounds.statement((0, 2, 14, 0, 0), bound) == { + "lower_bound": [0, 2, 10], "gap_key": 2, "absolute_gap": 4, + "relative_gap": {"numerator": 4, "denominator": 10}} + assert bounds.statement((1, 0, 0, 0, 0), bound) == { + "lower_bound": [0], "gap_key": 0, "absolute_gap": 1} + assert bounds.statement((0, 1, 99, 0, 0), bound) is None - A capacity relaxation says the same thing whoever asks, so computing it for `grid` or - `layer` would be arithmetic nobody reads -- and this stays true until a caller-facing - field exists to read it. - """ - _result, recorded = _pack(monkeypatch, {"solver_profile": "fast"}) - assert not recorded +def test_an_uncovered_objective_states_nothing(): + """§ 1.5: absence is "no statement", never "attained".""" + result = packvium.pack_from_dict({"containers": CONTAINERS, "items": ITEMS, + "configuration": {"objective": "maximum_value"}}) + assert set(result["optimality"]) == {"code"} -def test_a_non_default_objective_records_no_bound(monkeypatch): - """`lowest_cost` orders its score keys differently, so a bound vector compared against - it would line up cost against container count. - Recording nothing is the honest answer; recording a vector that cannot be compared is - how a wrong gap gets published later. - """ - _result, recorded = _pack( - monkeypatch, {"solver_profile": "exact_small", "objective": "lowest_cost"}) - assert not recorded +def test_the_statement_does_not_depend_on_the_deadline(): + """§ 6.3: under an effort budget the clock is only a fuse, so two different deadlines + give the same answer, and the field with it.""" + def under(time_limit_ms): + return packvium.pack_from_dict({ + "containers": CONTAINERS, "items": ITEMS, + "configuration": {"solver_profile": "quality", "time_limit_ms": time_limit_ms, + "effort_budget": {"max_restarts": 4, + "max_placement_attempts": 20000}}}) + first, second = under(60_000), under(120_000) + assert "lower_bound" in first["optimality"] + assert first["optimality"] == second["optimality"] and first["status"] == second["status"] -def test_the_bound_survives_into_the_stats_object(): - """The field exists and carries the vector, which is what "internally available" means. +def test_the_solvers_no_longer_compute_a_bound_per_start(): + """The bound lives in the packer; a per-start copy would bring F3 back.""" + assert not hasattr(solvers.SolverOrchestrator, "_record_root_bound") + assert not hasattr(solvers.SearchStats(), "objective_lower_bound") - Asserted directly rather than through a solve so that a rename of the field fails here - with a clear message instead of silently making every spy-based test above vacuous. - """ - stats = solvers.SearchStats() - assert stats.objective_lower_bound is None - stats.objective_lower_bound = (0, 1, 2, 3, 4) - assert stats.objective_lower_bound == (0, 1, 2, 3, 4) - assert "objective_lower_bound" not in stats.to_metrics().to_dict() + +def test_the_cost_bound_takes_the_cheapest_types_without_expanding_them(): + """F6: the same sum as the multiset of per-unit costs, from one sort of the types.""" + assert bounds._cost_bound([9, 5, 7], [3, 1, None], 4) == 5 + 7 * 3 + assert bounds._cost_bound([9, 5], [1, 1], 4) == 14 + assert bounds._cost_bound([3], [None], 10 ** 12) == 3 * 10 ** 12 # ----------------------------------- the degenerate inputs the guards exist for @@ -284,3 +422,44 @@ def test_a_container_with_no_usable_volume_still_bounds_the_count_by_its_other_l bound has to come from that instead of from a division nobody can perform. """ assert bounds._container_bound([1, 1], [5, 5], False, 2, [object()], [0], [4], [None]) == 3 + + +def test_a_score_below_its_bound_withholds_the_statement_rather_than_publishing_it(): + """§ 1.5 condition 5: the bound is then the suspect, and an engine must not publish a + claim it has just refuted. Unreachable through a sound bound, so stood in for here.""" + from packvium.packer import _stated_optimality + from packvium.result import PackingStatus + + assert _stated_optimality(PackingStatus.FEASIBLE, (0, 1, 0, 0, 0), (0, 2, 0, 0, 0), ()) == ( + PackingStatus.FEASIBLE, None) + + +@pytest.mark.parametrize('cheap_cost', [0, 1]) +def test_a_gap_past_the_portable_ceiling_withholds_only_the_statement(cheap_cost): + request = { + 'items': [{'id': 'i', 'quantity': 2, 'dimensions': dict.fromkeys(('length', 'width', 'height'), '10')}], + 'containers': [ + {'id': 'cheap', 'inner_dimensions': dict.fromkeys(('length', 'width', 'height'), '1'), + 'cost_minor': cheap_cost, 'max_items': 1}, + {'id': 'big', 'inner_dimensions': dict.fromkeys(('length', 'width', 'height'), '10'), + 'cost_minor': bounds.MAX_BOUND_VALUE, 'max_items': 1}, + ], + } + result = packvium.pack_from_dict(request) + assert result['score'][2] == 2 * bounds.MAX_BOUND_VALUE + assert result['optimality'] == {'code': 'not_proven'} + assert packvium.validate(request, result) == [] + result['optimality'].update(lower_bound=[0, 2, 2 * cheap_cost], gap_key=2, + absolute_gap=result['score'][2] - 2 * cheap_cost) + if cheap_cost: + result['optimality']['relative_gap'] = { + 'numerator': result['optimality']['absolute_gap'], 'denominator': 2 * cheap_cost} + assert 'optimality_gap_mismatch' in [issue.code for issue in packvium.validate(request, result)] + + +@pytest.mark.parametrize('least', [0, 1]) +def test_the_portable_gap_ceiling_is_inclusive(least): + bound = (0, 1, least, 0, 0) + statement = bounds.statement((0, 1, least + bounds.MAX_BOUND_VALUE, 0, 0), bound) + assert statement['absolute_gap'] == bounds.MAX_BOUND_VALUE + assert bounds.statement((0, 1, least + bounds.MAX_BOUND_VALUE + 1, 0, 0), bound) is None diff --git a/tests/test_packing.py b/tests/test_packing.py index b098b18..5787f73 100644 --- a/tests/test_packing.py +++ b/tests/test_packing.py @@ -541,34 +541,40 @@ def test_incompatible_items_are_separated(): # ------------------------------------------------------------------- reporting -def test_exact_small_does_not_claim_global_optimality_without_a_certificate(): - """The discrete search stops at its first full packing and has no lower-bound - certificate for the remaining objective keys.""" +def test_exact_small_claims_optimality_only_through_the_bound(): + """The discrete search proves nothing by stopping; the request's lower bound does when + the packing attains it on every key (OPTIMALITY-GAP.md § 1.3). A perfect fill of one + container attains it, and the result carries the bound it attained.""" items = [item("a", 100, 100, 100, quantity=2), item("b", 100, 100, 100, quantity=2)] containers = [container("box", 200, 200, 100)] result = pack(items, containers, PackingConfig.exact_small(time_limit_ms=5_000)) assert result.complete - assert result.status is PackingStatus.FEASIBLE + assert result.status is PackingStatus.OPTIMAL + assert result.to_dict()["optimality"] == {"code": "proven_optimal", + "lower_bound": list(result.score)} -def test_a_single_item_type_takes_the_lattice_path_and_claims_no_proof(): - """The lattice cannot prove optimality, so it must not be reported as optimal even - when it happens to fill the container perfectly.""" +def test_a_single_item_type_on_the_lattice_path_is_proven_by_the_bound_alone(): + """The lattice cannot prove optimality itself; a perfect fill is proven by the bound, + whichever solver produced it, and by nothing else.""" items = [item("cube", 100, 100, 100, quantity=4)] containers = [container("box", 200, 200, 100)] result = pack(items, containers, PackingConfig.exact_small(time_limit_ms=5_000)) assert result.complete - assert result.status is PackingStatus.FEASIBLE + assert result.status is PackingStatus.OPTIMAL -def test_a_complete_heuristic_answer_reports_feasible_not_optimal(): - """A heuristic that filled the order still has not proved it could not do better.""" - items = [item("cube", 50, 50, 50, quantity=16)] +def test_a_complete_heuristic_answer_that_misses_its_bound_reports_feasible_not_optimal(): + """A heuristic that filled the order has not proved it could not do better unless it + attains the bound: one layer of 30 mm cubes stands 30 mm tall where the volume alone + needs 11, so the stack-height key is missed and the gap says by how much.""" + items = [item("cube", 30, 30, 30, quantity=16)] containers = [container("box", 200, 200, 200)] result = pack(items, containers, PackingConfig.balanced()) assert result.status is PackingStatus.FEASIBLE + assert result.to_dict()["optimality"]["gap_key"] == 4 def test_an_incomplete_answer_within_the_budget_reports_best_found(): @@ -697,7 +703,8 @@ def test_quantity_compression_still_validates_end_to_end_when_requested(): result = pack(items, containers, config) - assert result.status is PackingStatus.FEASIBLE + # Two hundred cubes fill a fifth of the bin; the bound proves nothing beats one bin. + assert result.status is PackingStatus.OPTIMAL assert result.warnings == () assert result.containers[0].placements == () assert result.containers[0].lattice_summary is not None diff --git a/tests/test_request_errors.py b/tests/test_request_errors.py index bad3fae..f21be52 100644 --- a/tests/test_request_errors.py +++ b/tests/test_request_errors.py @@ -161,6 +161,83 @@ def test_null_optionals_are_absent_and_a_valid_request_passes(): check_request(data) +def test_a_request_parses_each_spelling_once(monkeypatch): + """The check hands its parses to deserialisation. A value already parsed passes + through `parse` untouched and is not counted.""" + parsed = [] + + def spy(kind, parse): + def counted(cls, value, *rest): + if not isinstance(value, kind): + parsed.append((kind.__name__, value)) + return parse(cls, value, *rest) + return classmethod(counted) + + monkeypatch.setattr(Length, "parse", spy(Length, Length.parse.__func__)) + monkeypatch.setattr(Weight, "parse", spy(Weight, Weight.parse.__func__)) + pack_from_dict(request()) + assert sorted(parsed, key=str) == sorted([("Length", "100"), ("Length", "200"), ("Length", 0), + ("Weight", "1000"), ("Weight", 0)], key=str) + + +def exact_fit_with_object_measures() -> tuple[dict, list[dict]]: + """The exact-fit scene (eight 100 mm cubes in a 200 mm box) with its six sides written as `{value, unit}`.""" + def sides(value: str) -> dict: + return {side: {"value": value, "unit": "mm"} for side in ("length", "width", "height")} + data = {"configuration": {"solver_profile": "fast"}, + "items": [{"id": "cube", "quantity": 8, "dimensions": sides("100")}], + "containers": [{"id": "box", "inner_dimensions": sides("200")}]} + objects = [*data["items"][0]["dimensions"].values(), *data["containers"][0]["inner_dimensions"].values()] + return data, objects + + +def counted_length_parses(monkeypatch) -> list: + parsed: list = [] + parse = Length.parse.__func__ + + def counted(cls, value, *rest): + parsed.append(value) + return parse(cls, value, *rest) + monkeypatch.setattr(Length, "parse", classmethod(counted)) + return parsed + + +def test_a_request_parses_each_object_measure_once(monkeypatch): + """An object spelling is parsed once per request, as a string is; before, the check + and deserialisation parsed each of these six objects once each. Equal objects share it.""" + data, objects = exact_fit_with_object_measures() + parsed = counted_length_parses(monkeypatch) + answer = pack_from_dict(data) + assert answer["status"] == "optimal" + by_object = [sum(value is spelt for value in parsed) for spelt in objects] + assert by_object == [1, 0, 0, 1, 0, 0] + assert [value for value in parsed if isinstance(value, dict)] == [objects[0], objects[3]] + + +def test_a_refused_object_measure_is_refused_with_the_same_error_every_time(): + data, _ = exact_fit_with_object_measures() + data["containers"][0]["inner_dimensions"]["width"] = {"value": "-1", "unit": "mm"} + data["items"][0]["dimensions"]["width"] = {"value": "-1", "unit": "mm"} + for _ in range(2): + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(data) + assert (caught.value.reason, caught.value.field, caught.value.detail) == ( + "negative_measure", "/items/0/dimensions/width", "cannot be negative") + + +def test_string_and_object_spellings_of_one_value_are_parsed_once_each(monkeypatch): + data, objects = exact_fit_with_object_measures() + data["items"][0]["dimensions"]["width"] = "100" + data["containers"][0]["inner_dimensions"].update(width="200 mm", height={"value": 200}) + parsed = counted_length_parses(monkeypatch) + answer = pack_from_dict(data) + assert answer["status"] == "optimal" + spellings = [value if isinstance(value, str) else (value["value"], value.get("unit")) + for value in parsed if not isinstance(value, (int, Length))] + assert sorted(spellings, key=str) == sorted( + ["100", "200 mm", ("100", "mm"), ("200", "mm"), (200, None)], key=str) + + def test_a_pointer_escapes_tilde_and_slash(): assert pointer("containers", 0, "tag_limits", "a/b~c") == "/containers/0/tag_limits/a~1b~0c" @@ -171,6 +248,28 @@ def test_a_units_object_without_a_length_keeps_millimetres(): check_request(data) +@pytest.mark.parametrize("units, output", [(None, None), ({"length": None}, None), + ({}, {"length_unit": None, "weight_unit": None})]) +def test_a_null_unit_is_the_default_when_packing(units, output): + """Null is the schema's default in `units` and `output` too: before, the rule table admitted + it and deserialisation read it as a unit code, an AttributeError.""" + data = request() + data.update(units=units, output=output) + placed = pack_from_dict(data)["containers"][0]["placements"][0] + assert placed["position"]["x"]["unit"] == "mm" + + +@pytest.mark.parametrize("length", [25, True, {"unit": "mm"}, ["mm"], "", "furlong"]) +def test_a_units_length_that_is_no_known_code_is_an_unknown_unit(length): + """The schema types `units.length` by its enum alone, so a number or an object is an unknown + unit, as at every measure's own `unit`, not `wrong_type`.""" + data = request() + data["units"] = {"length": length} + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(data) + assert (caught.value.reason, caught.value.field) == ("invalid_unit", "/units/length") + + def test_an_unparsable_fixed_coordinate_is_named(): data = request() data["fixed_placements"] = [{"item_type": "cube", "container_type": "box", "orientation": "LWH", diff --git a/tests/test_request_objects.py b/tests/test_request_objects.py index 761ea4e..9faae38 100644 --- a/tests/test_request_objects.py +++ b/tests/test_request_objects.py @@ -143,6 +143,11 @@ def machine(cost: int = 0) -> MadeToMeasure: lambda r: r["configuration"].update(max_candidate_points=8)), ("zero effort limit", lambda: EffortBudget(max_restarts=0), lambda r: r["configuration"].update(effort_budget={"max_restarts": 0})), + # A truthy value is not `True`. + *[(f"{name} not a boolean", lambda n=name: cube(**{n: "no"}), lambda r, n=name: r["items"][0].update({n: "no"})) + for name in ("keep_upright", "stackable", "must_be_on_floor")], + ("require_placement_coordinates not a boolean", lambda: PackingConfig.fast(require_placement_coordinates=0), + lambda r: r["configuration"].update(require_placement_coordinates=0)), ] @@ -178,6 +183,11 @@ def test_a_mistyped_measure_is_still_a_type_error() -> None: Length.mm(1.5) +def test_a_mistyped_flag_is_also_a_type_error() -> None: + with pytest.raises(TypeError, match="stackable must be a boolean"): + cube(stackable="no") + + def test_a_constructor_refusal_keeps_its_own_message() -> None: """A wrapper that quotes the text (a commerce document) reads what it always read.""" assert str(object_refusal(lambda: cube(quantity=0))) == "item quantity must be positive" diff --git a/tests/test_request_objects_corpus.py b/tests/test_request_objects_corpus.py index 71ec9eb..3c07038 100644 --- a/tests/test_request_objects_corpus.py +++ b/tests/test_request_objects_corpus.py @@ -42,6 +42,9 @@ _AS_LETTERS = "the deserializer reads text as the sequence of its letters, so the objects hold other values" _OUTPUT = "the output units are arguments of PackingResult.to_dict, not part of the objects packed" +_UNITS = "the request's length unit is an argument of each measure, not an object packed" +_UNIT_KEY = ("the deserializer's parse cache is keyed by the unit, which an object or a list cannot key; " + "Length.of refuses one as invalid_unit (test_units.py)") #: Cases with no object form: the value cannot be held, or the deserializer changes it. NOT_OBJECTS: dict[str, str] = { @@ -92,6 +95,11 @@ "output-length-unit-unknown": _OUTPUT, "output-weight-unit-unknown": _OUTPUT, "output-before-configuration": _OUTPUT, + # The request's length unit is an argument of each measure, not an object of its own. + "units-not-object": _UNITS, + "units-list": _UNITS, + "units-length-object": _UNIT_KEY, + "units-length-list": _UNIT_KEY, } #: Cases the object path answers differently on purpose (docs/ERRORS.md). @@ -103,10 +111,12 @@ "an-item-id-before-its-quantity": _CONSTRUCTOR_FIRST, "item-ids-before-containers": _CONSTRUCTOR_FIRST, "container-ids-before-fixed-placements": _CONSTRUCTOR_FIRST, + "configuration-boolean-before-effort-budget": ("two faults: the EffortBudget is built, and refuses " + "its own value, before the PackingConfig that holds it"), } #: Pinned, so the skip set cannot grow without this line changing too. -SKIPPED = 57 +SKIPPED = 62 @dataclass(frozen=True) diff --git a/tests/test_result_validation.py b/tests/test_result_validation.py index 70b9440..cb7de44 100644 --- a/tests/test_result_validation.py +++ b/tests/test_result_validation.py @@ -9,6 +9,7 @@ import ast import copy from pathlib import Path +from typing import Any import pytest @@ -85,3 +86,272 @@ def test_the_corridor_replay_unloads_the_near_box_first_and_refuses_a_later_one_ assert corridor_replay_succeeds(boxes, {"near": 0, "far": 0}, inner, ("-x",)) assert corridor_replay_succeeds(boxes, {"near": 0, "far": 1}, inner, ("-x",)) assert not corridor_replay_succeeds(boxes, {"near": 1, "far": 0}, inner, ("-x",)) + + +# ------------------------------------------------- the optimality bound and the gap + +#: Exactly filled by the packing, so every bound position is attained: (0, 1, 0, 0, 1000000). +ATTAINED = REQUEST + +#: One cube in a box for two: the height key misses its bound, 1000000 against 500000 (the +#: unused-volume key attains its 500000). +GAP = { + "items": [{"id": "cube", "quantity": 1, "dimensions": {"length": "10", "width": "10", "height": "10"}}], + "containers": [{"id": "box", "inner_dimensions": {"length": "20", "width": "10", "height": "10"}}], +} + +WORKSPACE = Path(__file__).resolve().parents[2] + + +def _emitting(request: dict) -> dict: + """The engine's result carrying the fields of docs/OPTIMALITY-GAP.md § 1.1 and § 1.3, as an + engine emits them once lands; written here from the document, not the validator.""" + from packvium.result_validation import objective_lower_bound + + result = pack_from_dict(request) + bound = objective_lower_bound(request) + assert bound is not None + score = result["score"] + missed = next((key for key, (achieved, floor) in enumerate(zip(score, bound)) if achieved != floor), None) + fact = {"code": result["optimality"]["code"]} + if missed is None: + fact["lower_bound"] = list(bound) + result["status"], fact["code"] = "optimal", "proven_optimal" + else: + fact.update(lower_bound=list(bound[:missed + 1]), gap_key=missed, + absolute_gap=score[missed] - bound[missed]) + if bound[missed]: + fact["relative_gap"] = {"numerator": score[missed] - bound[missed], "denominator": bound[missed]} + result["optimality"] = fact + return result + + +def _as_an_older_producer(result: dict) -> dict: + """The same result as a producer from before the field states it: no bound and no gap, + and the status its completeness alone gives.""" + for name in ("lower_bound", "gap_key", "absolute_gap", "relative_gap"): + result["optimality"].pop(name, None) + if result["status"] == "optimal": + result["status"], result["optimality"]["code"] = "feasible", "not_proven" + return result + + +def _codes(request: dict, result: dict) -> list[str]: + return [issue.code for issue in validate(request, result)] + + +def test_a_result_stating_an_attained_bound_is_valid() -> None: + result = _emitting(ATTAINED) + assert result["optimality"] == {"code": "proven_optimal", "lower_bound": [0, 1, 0, 0, 1000000]} + assert validate(ATTAINED, result) == [] + + +def test_a_result_stating_a_gap_is_valid() -> None: + result = _emitting(GAP) + assert result["optimality"] == { + "code": "not_proven", "lower_bound": [0, 1, 0, 500000, 500000], "gap_key": 4, + "absolute_gap": 500000, "relative_gap": {"numerator": 500000, "denominator": 500000}, + } + assert validate(GAP, result) == [] + + +def test_the_engine_emits_what_the_document_says() -> None: + """The engine's own fields, against the ones written here from the document.""" + for request in (ATTAINED, GAP): + result = pack_from_dict(request) + expected = _emitting(request) + assert (result["status"], result["optimality"]) == (expected["status"], expected["optimality"]) + + +def test_absence_is_never_an_issue() -> None: + for request in (ATTAINED, GAP): + result = _as_an_older_producer(pack_from_dict(request)) + assert "lower_bound" not in result["optimality"] + assert validate(request, result) == [] + + +def test_a_wrong_bound_value_is_an_optimality_bound_mismatch() -> None: + result = _emitting(GAP) + result["optimality"]["lower_bound"][1] = 2 + assert _codes(GAP, result) == ["optimality_bound_mismatch"] + + +def test_a_bound_where_none_is_stated_is_an_optimality_bound_mismatch() -> None: + request = {**GAP, "configuration": {"objective": "maximum_value"}} + result = pack_from_dict(request) + result["optimality"]["lower_bound"] = [0] + assert [(issue.code, issue.detail) for issue in validate(request, result)] == [( + "optimality_bound_mismatch", + "lower_bound is reported where no bound is stated: objective 'maximum_value' has no bound", + )] + + +def test_a_wrong_absolute_gap_is_an_optimality_gap_mismatch() -> None: + result = _emitting(GAP) + result["optimality"]["absolute_gap"] += 1 + assert [(issue.code, issue.detail) for issue in validate(GAP, result)] == [ + ("optimality_gap_mismatch", "absolute_gap should be 500000"), + ] + + +def test_a_reduced_relative_gap_and_a_short_prefix_are_optimality_gap_mismatches() -> None: + reduced = _emitting(GAP) + reduced["optimality"]["relative_gap"] = {"numerator": 1, "denominator": 1} + assert _codes(GAP, reduced) == ["optimality_gap_mismatch"] + short = _emitting(GAP) + short["optimality"]["lower_bound"].pop() + assert [(issue.code, issue.detail) for issue in validate(GAP, short)] == [ + ("optimality_gap_mismatch", "lower_bound has 4 entries, expected 5"), + ] + + +def test_a_score_below_the_recomputed_bound_is_reported_with_or_without_the_field(monkeypatch: pytest.MonkeyPatch) -> None: + from packvium import result_validation + + # A bound above what a valid packing scores can only be a wrong bound: stand one in. + monkeypatch.setattr(result_validation, "lower_bound", lambda *inputs: (0, 2, 0, 0, 1000000)) + stating = pack_from_dict(ATTAINED) + result = _as_an_older_producer(pack_from_dict(ATTAINED)) + assert [(issue.code, issue.detail) for issue in validate(ATTAINED, result)] == [ + ("score_below_bound", "score[1] is 1, below its lower bound 2"), + ] + assert _codes(ATTAINED, stating)[:2] == ["score_below_bound", "optimality_bound_mismatch"] + + +def test_a_misreported_score_below_the_bound_is_both_a_score_mismatch_and_below_it() -> None: + result = _as_an_older_producer(pack_from_dict(GAP)) + result["score"][1] = 0 + assert _codes(GAP, result) == ["score_mismatch", "score_below_bound"] + + +def test_proven_optimal_without_an_attained_lower_bound_is_an_unproven_claim() -> None: + result = _emitting(ATTAINED) + del result["optimality"]["lower_bound"] + assert _codes(ATTAINED, result) == ["unproven_optimality_claim"] + + +def test_optimal_is_accepted_exactly_when_the_bound_is_attained_and_the_result_complete() -> None: + attained_but_feasible = _emitting(ATTAINED) + attained_but_feasible["status"] = "feasible" + attained_but_feasible["optimality"]["code"] = "not_proven" + assert _codes(ATTAINED, attained_but_feasible) == ["result_fact_mismatch"] + + missed_but_optimal = _emitting(GAP) + missed_but_optimal["status"] = "optimal" + missed_but_optimal["optimality"]["code"] = "proven_optimal" + assert _codes(GAP, missed_but_optimal) == ["result_fact_mismatch", "unproven_optimality_claim"] + + +def test_lowest_cost_reads_the_same_bound_in_its_own_order() -> None: + from packvium.result_validation import objective_lower_bound + + priced = {**GAP, "containers": [{**GAP["containers"][0], "cost_minor": 7}]} + assert objective_lower_bound(priced) == (0, 1, 7, 500000, 500000) + assert objective_lower_bound({**priced, "configuration": {"objective": "lowest_cost"}}) == (0, 7, 1, 500000, 500000) + + +def test_no_bound_is_stated_past_the_declared_ceiling_or_for_a_made_to_measure_container() -> None: + from packvium.result_validation import objective_lower_bound + + huge = {"items": [{"id": "slab", "dimensions": {"length": "700", "width": "700", "height": "700"}}], + "units": {"length": "m"}, + "containers": [{"id": "hall", "inner_dimensions": {"length": "800", "width": "800", "height": "800"}}]} + assert objective_lower_bound(huge) is None + cut = {**GAP, "containers": [{**GAP["containers"][0], "made_to_measure": {"increment": { + "length": "1", "width": "1", "height": "1"}}}]} + assert objective_lower_bound(cut) is None + + +def _oracle() -> Any: + import importlib.util + import sys + + path = WORKSPACE / "scripts" / "optimality_bounds.py" + if not path.is_file(): + pytest.skip("the oracle lives in the workspace only") + spec = importlib.util.spec_from_file_location("optimality_bounds_oracle", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + # `dataclass` resolves the module through `sys.modules` while the class body runs. + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + del sys.modules[spec.name] + return module + + +def _validator_bound(instances: Any, containers: Any) -> "tuple[int, ...] | None": + """The validator's formulas over the oracle's own dataclasses.""" + from packvium.result_validation import BoundContainer, _runs, lower_bound + + return lower_bound( + _runs([(instance.volume, 1) for instance in instances]), + _runs([(instance.weight, 1) for instance in instances]), + any(instance.nests for instance in instances), + [BoundContainer(c.inner_volume, c.base_area, c.height, c.usable_volume, c.cost_minor, + c.payload, c.max_items, c.quantity) for c in containers], + ) + + +def test_the_validator_bound_matches_the_oracle_on_every_worked_and_refusal_example() -> None: + oracle = _oracle() + for example in (*oracle.WORKED_EXAMPLES, oracle.PERMUTATION_EXAMPLE, *oracle.REFUSAL_EXAMPLES): + refused = oracle.refusal(example["instances"], example["containers"]) + expected = None if refused is not None else oracle.bounds( + example["instances"], example["containers"]).as_vector() + assert _validator_bound(example["instances"], example["containers"]) == expected, example["name"] + + +def test_the_validator_bound_matches_the_shared_scene_and_reads_each_fixture_as_it_does() -> None: + import json + + from packvium.result_validation import (BoundContainer, _bound_inputs, _parse_containers, _parse_items, + _runs, lower_bound) + + path = WORKSPACE / "conformance" / "scene" / "objective-bounds.json" + if not path.is_file(): + pytest.skip("the shared scene lives in the workspace only") + scene = json.loads(path.read_text(encoding="utf-8")) + for case in (*scene["cases"], *scene["edges"]): + instances = case["instances"] + computed = lower_bound( + _runs([(int(instance["volume"]), 1) for instance in instances]), + _runs([(int(instance["weight"]), 1) for instance in instances]), + any(instance["shrinks"] for instance in instances), + [BoundContainer(int(c["inner"]), c["base_area"], c["height"], int(c["usable"]), c["cost_minor"], + c["payload"], c["max_items"], c["quantity"]) for c in case["containers"]], + ) + assert (None if computed is None else list(computed)) == case["bounds"], case.get("name", case.get("fixture")) + read = 0 + for case in scene["cases"]: + if case["truncated"]: + continue + request = json.loads((WORKSPACE / "conformance" / "fixtures" / case["fixture"]).read_text(encoding="utf-8")) + unit = request.get("units", {}).get("length", "mm") + inputs = _bound_inputs(_parse_items(request, unit), _parse_containers(request, unit)) + assert lower_bound(*inputs) == tuple(case["bounds"]), case["fixture"] + read += 1 + assert read > 400 + + +def test_degenerate_capacities_are_read_as_every_port_reads_them() -> None: + from packvium.result_validation import BoundContainer, lower_bound + + reserved_past_full = BoundContainer(1000, 100, 10, -1, 3, 5, None, 1) + assert lower_bound(((0, 2),), ((1, 2),), False, [reserved_past_full]) == (2, 0, 0, 0, 0) + unlimited = BoundContainer(1000, 100, 10, -1, 3, 5, None, None) + assert lower_bound(((0, 2),), ((1, 2),), False, [unlimited]) == (0, 1, 3, 1000000, 0) + holds_nothing = BoundContainer(1000, 100, 10, 1000, 0, 0, 0, None) + assert lower_bound(((1, 1),), ((0, 1),), False, [holds_nothing]) == (1, 0, 0, 0, 0) + assert lower_bound(((1, 1),), ((0, 1),), False, []) == (1, 0, 0, 0, 0) + weightless = BoundContainer(1000, 100, 10, 1000, 0, 0, None, None) + assert lower_bound(((1, 1),), ((0, 1),), False, [weightless]) == (0, 1, 0, 999000, 100000) + + +def test_no_bound_is_stated_for_an_invalid_result() -> None: + result = pack_from_dict(GAP) + result["status"] = "invalid_result" + result["optimality"] = {"code": "not_proven", "lower_bound": [0]} + assert ("lower_bound is reported where no bound is stated: status is 'invalid_result'" + in [issue.detail for issue in validate(GAP, result)]) diff --git a/tests/test_solver_edges.py b/tests/test_solver_edges.py index f391195..56885a0 100644 --- a/tests/test_solver_edges.py +++ b/tests/test_solver_edges.py @@ -25,6 +25,7 @@ SolverOrchestrator, Space, TimeLimitReached, _ContainerPlan, _nesting_points, _run_concurrent_start, _subtract, beam_pack, default_constraints, find_candidates) +from packvium import bounds from support import assert_sound, container, item GENEROUS_MS = 60_000 @@ -158,13 +159,22 @@ def test_a_fixed_item_in_an_unlimited_container_type_opens_that_container_first( assert [placement.fixed for placement in first.placements] == [True, False] -def test_a_custom_solver_that_proves_its_search_exhaustive_is_reported_optimal(): - items, containers = [item("a", 50, 100, 100, quantity=2)], [container("c", 100, 100, 100)] +def test_a_custom_solver_claiming_its_search_exhaustive_proves_nothing_by_that_claim(): + """OPTIMALITY-GAP.md Q1: exhaustion covers only the candidates a search generated, so it + is not a proof. The bound is: a perfect fill attains it and is optimal whatever the solver + claimed, and an answer that misses it is not, however exhaustive.""" config = counted(solvers=("extreme_points",)) - result = packer(config, solvers=(ClaimsExhaustive(),)).pack(items, containers) - assert result.algorithm.solver.startswith("a_claims_exhaustive") - assert result.status is PackingStatus.OPTIMAL - assert result.to_dict()["optimality"] == {"code": "proven_optimal"} + fills = packer(config, solvers=(ClaimsExhaustive(),)).pack( + [item("a", 50, 100, 100, quantity=2)], [container("c", 100, 100, 100)]) + assert fills.algorithm.solver.startswith("a_claims_exhaustive") + assert fills.status is PackingStatus.OPTIMAL + assert fills.to_dict()["optimality"] == {"code": "proven_optimal", + "lower_bound": [0, 1, 0, 0, 1000000]} + misses = packer(config, solvers=(ClaimsExhaustive(),)).pack( + [item("a", 30, 30, 30, quantity=2)], [container("c", 100, 100, 100)]) + assert misses.algorithm.solver.startswith("a_claims_exhaustive") + assert misses.status is PackingStatus.FEASIBLE + assert "gap_key" in misses.to_dict()["optimality"] def test_a_custom_solution_the_validator_rejects_never_beats_a_valid_one(): @@ -245,12 +255,13 @@ def test_the_concurrent_portfolio_stops_after_a_lattice_that_placed_everything() def test_a_worker_start_counts_its_own_effort_against_the_shared_budget(): instances = items_of([item("a", 40, 40, 40, quantity=4)]) budget = EffortBudget(max_candidates_evaluated=1) - _, unpacked, _, reached, _, stats = _run_concurrent_start( + _, unpacked, _, reached, _, stats, effort_stopped = _run_concurrent_start( ExtremePointSolver(), instances, (container("c", 100, 100, 100),), PackingConfig.balanced(), DefaultContainerSelector(), budget, generous().started + GENEROUS_MS * 1_000_000, ) assert reached - assert budget.exceeded(stats) + assert effort_stopped + assert stats.candidates_evaluated == 1 assert len(unpacked) > 0 @@ -282,21 +293,11 @@ def test_the_container_lower_bound_ignores_payload_when_no_container_accepts_any assert SolverOrchestrator._additional_container_lower_bound(plan(cube.instances(), [box]), [box]) == 2 -def test_a_root_bound_already_recorded_is_not_recomputed(): - stats = SearchStats() - stats.objective_lower_bound = (1, 2, 3) - instances = items_of([item("a", 10, 10, 10)]) - SolverOrchestrator()._record_root_bound(ExtremePointSolver(), True, instances, - (container("c", 100, 100, 100),), PackingConfig.balanced(), stats) - assert stats.objective_lower_bound == (1, 2, 3) - - def test_a_root_bound_past_its_ceiling_is_left_unset_and_the_pack_still_succeeds(): + # F5's counterpart here: past the declared ceiling the bound is withheld, never raised. heavy = Item.create("heavy", Dimensions.mm(10, 10, 10), weight=10**31) - stats = SearchStats() - SolverOrchestrator()._record_root_bound(ExtremePointSolver(), True, heavy.instances(), - (container("c", 100, 100, 100),), PackingConfig.balanced(), stats) - assert stats.objective_lower_bound is None + assert bounds.for_request(heavy.instances(), (container("c", 100, 100, 100),), + "default") is None result = Packer(counted(solvers=("exact_small",))).pack([heavy], [container("c", 100, 100, 100)]) assert result.complete diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 1416410..1601898 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -813,12 +813,15 @@ class EvaluatedSupport(SupportConstraint): assert (stats.placements_attempted, stats.collision_checks) == pair_by_pair_counts(state, item) -def pair_by_pair_counts(state, item): - """Attempts and collision checks counted one (point, rotation) pair at a time.""" +def pair_by_pair_counts(state, item, limit=None): + """Attempts and collision checks counted one (point, rotation) pair at a time, stopping + before the attempt past `limit`.""" inner = state.container.inner_dimensions attempts = checks = 0 for point in state.ordered_points: for _, dims in item.dimensions.unique_rotations(item.item.allowed_rotations): + if attempts == limit: + return attempts, checks attempts += 1 x2, y2, z2 = point.x + dims.length.ticks, point.y + dims.width.ticks, point.z + dims.height.ticks if x2 > inner.length.ticks or y2 > inner.width.ticks or z2 > inner.height.ticks: @@ -831,12 +834,16 @@ def pair_by_pair_counts(state, item): return attempts, checks -def test_a_stop_mid_scan_leaves_every_counter_where_the_scanned_points_put_it(): +def test_a_stop_mid_scan_lands_on_the_limit_and_only_a_stop_is_recorded(): + """The scan stops on the attempt past the limit, inside a point if need be, so the + counter ends on the limit and never past it, with every other counter where the attempted + pairs put it. A scan that finishes on exactly its limit was not stopped.""" config, constraints = config_and_constraints() state = populated_state(config) item, = instances("x", 30, 20, 10) full = SearchStats() find_candidates(state, item, config, constraints, full, generous(), 1) + assert full.placements_attempted % len(item.dimensions.unique_rotations(item.item.allowed_rotations)) == 0 stops = 0 for limit in range(1, full.placements_attempted + 1): stats = SearchStats() @@ -845,11 +852,10 @@ def test_a_stop_mid_scan_leaves_every_counter_where_the_scanned_points_put_it(): find_candidates(state, item, config, constraints, stats, deadline, 1) except TimeLimitReached: stops += 1 - scanned = state.ordered_points[:stats.candidate_points_considered] - reference = SearchStats() - find_candidates(state, item, config, constraints, reference, generous(), 1, points=scanned) - assert stats == reference - assert stops > 1 + assert stats.placements_attempted == limit + assert (stats.placements_attempted, stats.collision_checks) == pair_by_pair_counts(state, item, limit) + assert deadline.effort_stopped == (limit < full.placements_attempted) + assert stops == full.placements_attempted - 1 def _hull_wedge(id, quantity): @@ -1647,6 +1653,30 @@ def test_the_fast_block_start_enumerates_one_mode_and_balanced_both(): assert orchestrator._block_start(cartons, PackingConfig.balanced(), box).modes == ("count", "volume") +@pytest.mark.parametrize("config", [ + PackingConfig.fast(solvers=("homogeneous_blocks",)), + PackingConfig.balanced(solvers=("homogeneous_blocks",)), +]) +def test_a_named_block_solver_outside_quality_carries_the_block_start_bound(config): + # Both profiles leave `container_plan_node_limit` at one, short of the first block. + box = Container.create("box", Dimensions.mm(600, 400, 250)) + solution = HomogeneousBlockSolver().pack_one( + box, 1, _four_similar_cartons(), config, SearchStats(), generous(), + ) + assert solution.unpacked == () + assert not solution.time_limit_reached + + +def test_a_node_limit_stop_is_the_search_answer_not_a_time_limit(): + box = Container.create("box", Dimensions.mm(600, 400, 250)) + config = PackingConfig.quality(container_plan_node_limit=1) + solution = HomogeneousBlockSolver().pack_one( + box, 1, _four_similar_cartons(), config, SearchStats(), generous(), + ) + assert len(solution.unpacked) == 4 + assert not solution.time_limit_reached + + @pytest.mark.parametrize(("order_name", "greedy"), [ ("volume:greedy", True), ("volume:greedy:late_box", True), diff --git a/tests/test_units.py b/tests/test_units.py index 7f449e4..2a3546c 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -13,8 +13,8 @@ import pytest -from packvium import Length, Rounding, Weight -from packvium.units import _fraction, _rounded +from packvium import InvalidRequestError, Length, Rounding, Weight +from packvium.units import ParsedMeasures, _fraction, _rounded # --------------------------------------------------------------------- constants @@ -164,6 +164,16 @@ def test_unsupported_units_are_rejected(): Length.mm(1).as_fraction("furlong") +@pytest.mark.parametrize("unit", [None, 5, [], {}]) +def test_a_unit_that_is_no_code_is_an_unknown_unit(unit): + """Refused as the JSON path refuses it, never an AttributeError from the lookup.""" + for build in (lambda: Length.of(1, unit), lambda: Length.parse("1", unit), + lambda: Weight.of(1, unit), lambda: Weight.parse("1", unit)): + with pytest.raises(InvalidRequestError) as caught: + build() + assert caught.value.reason == "invalid_unit" + + def test_negative_measures_are_rejected(): with pytest.raises(ValueError): Length(-1) @@ -205,3 +215,75 @@ def test_decimal_rendering_round_trips(text): def test_to_dict_carries_the_exact_ticks_alongside_the_display_value(): assert Length.inches(1).to_dict("mm") == {"ticks": 406_400, "value": "25.4", "unit": "mm"} assert Weight.of(1, "kg").to_dict("g") == {"ticks": 8_000_000_000, "value": "1000", "unit": "g"} + + +# ---------------------------------------------------------------------- parsed once per request + +def test_parsed_measures_answer_a_repeated_spelling_with_the_first_parse(): + measures = ParsedMeasures() + first = measures.read(Length, "500", "mm") + assert measures.read(Length, "500", "mm") is first + assert measures.read(Length, "500", "cm") == Length.of(500, "cm") + assert measures.read(Weight, "500", "g") == Weight.of(500, "g") + + +def test_parsed_measures_never_answer_a_boolean_with_an_integer(): + measures = ParsedMeasures() + assert measures.read(Length, 1, "mm") == Length.mm(1) + with pytest.raises(TypeError): + measures.read(Length, True, "mm") + + +def test_parsed_measures_refuse_a_bad_spelling_on_every_read(): + measures = ParsedMeasures() + for _ in range(2): + with pytest.raises(ValueError, match="cannot be negative"): + measures.read(Length, "-1", "mm") + + +def test_parsed_measures_answer_a_repeated_object_with_the_first_parse(): + measures = ParsedMeasures() + first = measures.read(Length, {"value": "2", "unit": "in"}, "mm") + assert first == Length.inches(2) + assert measures.read(Length, {"value": "2", "unit": "in"}, "mm") is first + assert measures.read(Length, {"value": "2"}, "in") is first + assert measures.read(Length, {"value": "2"}, "mm") == Length.mm(2) + + +def test_parsed_measures_key_an_object_by_each_field_and_its_type(): + measures = ParsedMeasures() + assert measures.read(Length, {"value": 1}, "mm") == Length.mm(1) + assert measures.read(Length, {"value": "1"}, "cm") == Length.of(1, "cm") + assert measures.read(Length, {"value": "1", "unit": "cm"}, "mm") == Length.of(1, "cm") + with pytest.raises(TypeError, match="a boolean is not a measure"): + measures.read(Length, {"value": True}, "mm") + assert measures.read(Weight, {"value": 1}, "g") == Weight.of(1, "g") + + +def test_parsed_measures_leave_what_is_no_spelling_to_the_parser(): + measures = ParsedMeasures() + length = Length.mm(3) + assert measures.read(Length, length, "mm") is length + assert measures.read(Length, Decimal("2.5"), "mm") == Length.of("2.5", "mm") + for _ in range(2): + with pytest.raises(KeyError): + measures.read(Length, {"unit": "mm"}, "mm") + + +@pytest.mark.parametrize("spelling, error, message", [ + ({"value": "-1", "unit": "mm"}, ValueError, "length cannot be negative"), + ({"value": "1", "unit": "furlong"}, ValueError, "unsupported length unit: furlong"), + ({"value": True}, TypeError, "a boolean is not a measure"), + ({"value": [1]}, ValueError, r"invalid decimal or fraction: \[1\]"), + ({"value": "1", "unit": ["mm"]}, ValueError, r"unsupported length unit: \['mm'\]"), +]) +def test_parsed_measures_refuse_a_bad_object_on_every_read(spelling, error, message): + """A refused object is never kept, and one the table cannot hash is the parser's to refuse, + so every read raises what `parse` raises.""" + measures = ParsedMeasures() + with pytest.raises(error, match=message) as direct: + Length.parse(spelling, "mm") + for _ in range(2): + with pytest.raises(type(direct.value), match=message) as caught: + measures.read(Length, spelling, "mm") + assert str(caught.value) == str(direct.value)