diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml index 53394de..5dfb56e 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -10,7 +10,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "packvium 1.8.0 (Python)" + placeholder: "packvium 1.9.0 (Python)" validations: required: true - type: textarea diff --git a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml index fb799e6..dfb5b0b 100644 --- a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml +++ b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml @@ -16,7 +16,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "packvium 1.8.0 (Python)" + placeholder: "packvium 1.9.0 (Python)" validations: required: false - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d375b6e..f7cc97c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,8 +33,8 @@ body: id: version attributes: label: Package version - description: The exact version installed, for example 1.8.0. - placeholder: "1.8.0" + description: The exact version installed, for example 1.9.0. + placeholder: "1.9.0" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 542f8de..90059b4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -29,7 +29,7 @@ body: id: version attributes: label: Package version - placeholder: "1.8.0" + placeholder: "1.9.0" validations: required: true - type: textarea diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72aaaf4..9327437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: 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 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python }} - run: python -m pip install --upgrade pip @@ -37,8 +37,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" - run: python -m pip install --upgrade pip build twine @@ -50,7 +50,7 @@ jobs: /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 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: distributions path: dist/ @@ -67,7 +67,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: low diff --git a/.github/workflows/rc.yml b/.github/workflows/rc.yml index 6989b56..a0275d6 100644 --- a/.github/workflows/rc.yml +++ b/.github/workflows/rc.yml @@ -25,8 +25,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" - run: python -m pip install --upgrade pip build twine @@ -52,7 +52,7 @@ jobs: /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 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: distributions path: dist/ @@ -69,7 +69,7 @@ jobs: 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 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: distributions path: dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 666fd51..6740ab9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" - run: python -m pip install --upgrade pip build twine @@ -53,7 +53,7 @@ jobs: /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 + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: distributions path: dist/ @@ -66,7 +66,7 @@ jobs: 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 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: distributions path: dist/ @@ -85,7 +85,7 @@ jobs: permissions: contents: write # the Release is the only thing this workflow writes to the repository steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - name: Create the GitHub Release from the tag env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c86ae06..592537c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ 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.9.0] + +No API change, but admission is stricter: some requests 1.8 accepted are now refused (see the +weight and length limits below). Answers now agree with the other engines where 1.8 found them +apart. + +### Changed + +- **Under a clearance a compressible item bears its load on its own footprint**, so its crush + check is stricter and `validate` measures its own compressed volume. +- **A weight is at most `2^63 - 1` ticks, and so is every item's weight times its quantity plus + the heaviest tare**: past it, `invalid_request` / `above_maximum`; a weight written as a JSON + number past `2^53 - 1` is `invalid_value` and must be a string. +- **Agrees with the other engines where 1.8 did not:** nesting under a clearance shares the + items' own overlap and a deep nesting column nests in pairs; a length past `2^53 - 1` ticks or + a box past `10^30` cubic ticks is `invalid_request` / `above_maximum`; a side that reads as zero + ticks is `invalid_value` / `must be positive`; an obstacle box outside its container is + `invalid_value` / `lies outside inner_dimensions`, each at its own pointer. +- **`validate` checks the centroid (`centre_of_gravity_unsupported`) and the whole stack's load + (`top_load_exceeded`)**, an item on several bearers shared by contact area. It no longer + holds that item's whole weight against every bearer, so it accepts some results 1.8 rejected + and names the overloaded bearer. +- **A plan beam wider than 1 compares every container type**, whatever the node limit. +- **A JSON request is checked once, not twice**, on the same objects the request check built. + +### Fixed + +- **A narrow nesting item under a clearance reported a negative `used_volume_ticks3`**, and a + deep nesting column on the lattice returned `invalid_result`. +- **An obstacle origin with a wrong type is refused with `invalid_request`**, rather than + being defaulted or raising `AttributeError`; an explicit null origin defaults to zero, + including in additional boxes. Null coordinates default to zero, and null + `additional_boxes` defaults to an empty list, as request admission already permits. + ## [1.8.0] Results now say how far they can be from the best possible answer, `optimal` means a proof, diff --git a/CITATION.cff b/CITATION.cff index 78401b2..cdab3e8 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.8.0" +version: "1.9.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-python" diff --git a/README.md b/README.md index 7ebdef8..bb361f3 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.8.0 — the public API is frozen.** Field names, status codes and the +> **Version 1.9.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. @@ -310,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.8"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.9"` | [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) | @@ -332,7 +332,7 @@ CodeMeta form. @software{packvium_python, author = {{Packvium contributors}}, title = {Packvium for Python}, - version = {1.8.0}, + version = {1.9.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/codemeta.json b/codemeta.json index 986d168..743104f 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.8.0", + "version": "1.9.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/docs/COMMERCE-API.md b/docs/COMMERCE-API.md index 7fcc53d..6f4e4b7 100644 --- a/docs/COMMERCE-API.md +++ b/docs/COMMERCE-API.md @@ -150,6 +150,10 @@ history with a hole or a duplicated version number. All three top-level keys are optional; a document that only needs to price a shipment may carry only `tariffs`. Field-level rules: +- A tariff history is identified by the ordered pair `(carrier_id, service_id)`. + Separators inside either string are ordinary identifier characters: for example, + `("a/b", "c")` and `("a", "b/c")` name different histories. Duplicate detection and + both pinned and effective-dated lookup use the complete pair. - `accessorials` is an ordered **list**, not an object, so no language has to agree about key order; each entry sets exactly one of `flat_charge_minor` or `permille_of_base`. Duplicate `accessorial_id` values are an input error. @@ -165,6 +169,9 @@ may carry only `tariffs`. Field-level rules: `published_at`, `cost_minor`, `minimum_charge_minor`, `fuel_surcharge_permille`, `flat_charge_minor`, `permille_of_base` and each zone rate in `cost_per_dimensional_kg_minor` may be zero but not negative. + Rollback versions follow the same date bounds as snapshot versions, and a catalog + request's `resolved_at` must also be non-negative, including when its catalog or + version does not exist. These are input errors rather than lookup rejections. ### Input errors versus rejections @@ -244,6 +251,16 @@ Success: `false` `allowed` always carries a citation — the model refuses to construct a citation-free rejection. +`equals` and `not_equals` compare JSON values recursively: list order matters, +object key order does not, and different key sets remain different even when values +are `null`. Boolean/integer equality follows the reference model (`true == 1`, +`false == 0`), including inside lists and objects. `in` and `not_in` test list +elements, string substrings, or object keys; object values are not members. A +non-string context value cannot be a substring or an object key. A missing context +field matches neither binary operator, including its negated form. Rust's rule priority +comparison handles the entire signed 64-bit range without negating its minimum; +portable JavaScript inputs remain limited by exact `Number` representation. + ### `catalog_version_info` Request: @@ -368,6 +385,16 @@ The `e log e` term is sorting the three id lists; every other bound is a linear the relevant history. These are the bounds of the underlying models; the wrapper adds parsing and serialization, both linear in the payload, and nothing else. +The policy rows treat predicate values as bounded. For structured values, multiply +their predicate work by the comparison/membership cost: recursive equality visits +the compared values, with an additional logarithmic key lookup per object entry in +Rust's default ordered maps. PHP and JavaScript use expected constant-time object-key +lookup; JavaScript also allocates key arrays during object comparison. Python's +effective-dated model evaluation streams version resolution and winner selection in +constant auxiliary space, excluding value-comparison recursion and the loaded +document. Each matching rule's predicates run once; the other ports retain their +existing rule-pool storage. + ## Limitations - The document is supplied by the caller. Nothing here fetches, scrapes or embeds any @@ -380,6 +407,11 @@ parsing and serialization, both linear in the payload, and nothing else. - Policy evaluation covers the closed `PolicyScope` / `PolicyOperator` vocabulary. An unrecognised scope or operator fails document admission rather than being silently ignored — the guarantee `domain/policy/model.py` already makes. +- PHP's array boundary cannot recover the object/list distinction erased by an + associative JSON decoder: `{}` and `[]`, or an object with consecutive numeric + keys, can become the same PHP array. Portable object-valued predicates should use + nonempty objects with nonnumeric string keys. This limitation does not affect + list ordering or ordinary string-key object comparisons. - A JSON number must be written without a fractional part. `1` is an integer; `1.0` and `1e3` are not, and Python, PHP and Rust refuse them. JavaScript cannot tell the difference — `JSON.parse` gives the same `Number` for `1` and `1.0` — so this is the diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 8cdcba6..0d40bf9 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -61,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.8.0` freezes the public API. Field names, status codes, the objective +Version `1.9.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 d737e88..7eb418e 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -239,6 +239,9 @@ All counters are non-negative integers. A zero means the selected solver did not that phase; it does not mean the metric is unsupported. Compatibility fields `algorithm.placements_attempted` and `algorithm.candidates_evaluated` remain and must equal `metrics.orientations_considered` and `metrics.feasible_candidates`. +For a multi-start portfolio these counters belong to the selected start; the complete +list of starts is recorded separately in `termination.starts`. Counters are not summed +across starts. Metrics are diagnostics: they never participate in feasibility, ranking or the objective vector. @@ -469,9 +472,9 @@ bounds its work by counting must get the same answer on any machine, and a start 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. +the one portfolio deadline. Each start keeps its own counters against the whole budget, in all +four engines; until 1.9.0 the JavaScript fallback handed each later start what the earlier ones +left of one budget, so the same number bought 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 diff --git a/docs/UNITS-AND-NUMERICS.md b/docs/UNITS-AND-NUMERICS.md index fd1e6e6..c09c8e8 100644 --- a/docs/UNITS-AND-NUMERICS.md +++ b/docs/UNITS-AND-NUMERICS.md @@ -59,6 +59,42 @@ a caller would get a number from one and a refusal from another for the same inp carries the guarded sums in a representation that holds `10^30` exactly: Python's `int`, PHP's `BigInt` decimal strings, JavaScript's `BigInt`, Rust's `i128`. +Areas follow the same rule in the search itself (1.9.0). Two lengths below +`PHP_INT_MAX` ticks multiply past a native `int` -- a 10 mm wide item longer than about +3,600 km already does -- and Python and Rust hold that product exactly. PHP forms every such +product (base and contact areas, their sums and the load shared by them, the centre-of-gravity +hull, lattice slot counts, the candidate score's room-left term) through +`Packvium\Support\ExactInteger` and `ExactGeometry`: the native `int` while it fits, an exact +decimal string past it, so an ordinary request pays one `is_int` check. The public +`Dimensions::baseAreaTicks()` and `AxisAlignedBox::overlapAreaXY()` keep their `int` contract. +`support_ratio` and `volume_utilization` are Python's correctly rounded `int / int`, not a +quotient of two doubles. + +**The admission ceiling (1.9.0).** Every engine refuses, before any model is built, a +JSON request with a length past `2^53 - 1` ticks (about 563,000 km) or a box -- an item, a +container's inner or outer dimensions, an obstacle, a made-to-measure step -- enclosing more than +`10^30` cubic ticks (about 244 million cubic metres). Both are `invalid_request` with reason +`above_maximum` at the offending pointer (ERRORS.md). The first is the largest tick +count JavaScript's `Number` holds exactly; the second is the bound path's own ceiling, which keeps +`volume x 10^6` inside Rust's `i128`. Until 1.9.0 each engine failed past its own native limit: Rust +and PHP refused a length past `2^63 - 1` ticks with different errors, Python and JavaScript answered, +Python died with `decimal.InvalidOperation` at `10^20` mm, and Rust overflowed `i128` on a container +past about `1.7 x 10^32` cubic ticks. A whole JSON number past `2^53 - 1` is refused the +same way however a parser stored it, and a value past a native integer is read exactly to tell +which way it is out of range. + +**Weights (1.9.0).** A weight is at most `2^63 - 1` ticks (about 1.15 million +tonnes), and so is every item's weight times its quantity plus the heaviest container tare, so +no container's payload or gross weight can pass a signed 64-bit integer, which is how Rust and +PHP add them. Past either, the request is `invalid_request` / `above_maximum`, at the weight or at +`/items`. A weight written as a JSON number past `2^53 - 1` is `invalid_value`: JavaScript has +rounded such a number before any engine code reads it, so a weight that large is written as a +string. Inside the ceiling every engine is exact: Python's `int`, PHP's `int` and `BigInt`, +Rust's `i64` with `i128` products, and in JavaScript a `BigInt` for every weight sum. A +JavaScript result carries a weight's `ticks`, and a `shipping_cost` score position, as a `Number` +while it is exact and as a `BigInt` past `2^53 - 1`; `stringifyExact` and `parseExact` move such +a result through JSON text without rounding it, and `pack` uses them on both backends. + That intermediate ceiling is deliberately not the result ceiling. The five bound keys can cross JSON and are returned as JavaScript `Number` values, so every engine also refuses a final key above **`2^53 - 1` (`9,007,199,254,740,991`)**. This is the largest integer all four diff --git a/pyproject.toml b/pyproject.toml index e84fb86..6bcc82d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "1.8.0" +version = "1.9.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" diff --git a/src/packvium/artifacts.py b/src/packvium/artifacts.py index ae5a3ad..6e5f967 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.8.0" +SUITE_VERSION = "1.9.0" #: The deterministic part of `result.algorithm`. `duration_ms` is wall-clock time and never #: enters an artifact. diff --git a/src/packvium/commerce/api.py b/src/packvium/commerce/api.py index c8f8cff..a3c64a0 100644 --- a/src/packvium/commerce/api.py +++ b/src/packvium/commerce/api.py @@ -309,6 +309,8 @@ def catalog_version_info(document: Any, request: Any) -> Dict[str, Any]: _keys(fields, "request", ("catalog_id", "resolved_at"), ("version", "as_of")) catalog_id = _text(fields["catalog_id"], "request.catalog_id") resolved_at = _integer(fields["resolved_at"], "request.resolved_at") + if resolved_at < 0: + _fail("request.resolved_at", "cannot be negative") version = None if fields.get("version") is None else _integer(fields["version"], "request.version") as_of = None if fields.get("as_of") is None else _integer(fields["as_of"], "request.as_of") if version is not None and as_of is not None: diff --git a/src/packvium/commerce/policy.py b/src/packvium/commerce/policy.py index 6445860..7f4e162 100644 --- a/src/packvium/commerce/policy.py +++ b/src/packvium/commerce/policy.py @@ -39,7 +39,7 @@ from .._compat import dataclass from enum import Enum -from typing import Any, Mapping, Optional, Sequence +from typing import Any, Iterable, Mapping, Optional, Sequence # --------------------------------------------------------------------------------- errors @@ -158,12 +158,18 @@ def matches(self, context: Mapping[str, Any]) -> bool: if self.operator is PolicyOperator.NOT_EQUALS: return actual != self.value if self.operator is PolicyOperator.IN: - return actual in self.value + return _contains(self.value, actual) if self.operator is PolicyOperator.NOT_IN: - return actual not in self.value + return not _contains(self.value, actual) raise UnsupportedPredicateError(f"unsupported policy operator {self.operator!r}") # pragma: no cover +def _contains(haystack: Any, needle: Any) -> bool: + if isinstance(haystack, (str, Mapping)) and not isinstance(needle, str): + return False + return needle in haystack + + # ----------------------------------------------------------------------------------- rule @dataclass(frozen=True, slots=True) @@ -232,27 +238,34 @@ def _resolve_effective(versions: Sequence[PolicyRule], *, as_of: int) -> PolicyR `as_of` lookups: the highest `effective_at` not after `as_of`, ties broken by the higher (later-published) version number. `None` if no version of this rule id has yet taken effect by `as_of`.""" - candidates = [v for v in versions if v.effective_at <= as_of] - if not candidates: - return None - return max(candidates, key=lambda v: (v.effective_at, v.version)) + winner = None + for rule in versions: + if rule.effective_at > as_of: + continue + if (winner is None or rule.effective_at > winner.effective_at + or (rule.effective_at == winner.effective_at and rule.version > winner.version)): + winner = rule + return winner -def decide(rules: Sequence[PolicyRule], scope: PolicyScope, context: Mapping[str, Any]) -> PolicyDecision: +def decide(rules: Iterable[PolicyRule], scope: PolicyScope, context: Mapping[str, Any]) -> PolicyDecision: """Evaluate an already-resolved immutable rule set. Keeping resolution separate lets a solver adapter pin exact rule versions once per request and reuse them for every placement candidate. Evaluation is ``O(r * p)`` in matching rules and predicates, with no registry lookup and no mutable state. """ - matching = [rule for rule in rules if rule.scope is scope and rule.matches(context)] - rejects = [rule for rule in matching if rule.action is PolicyAction.REJECT] - allows = [rule for rule in matching if rule.action is PolicyAction.ALLOW] - pool = rejects or allows - if not pool: + winner = None + winner_key = None + for rule in rules: + if rule.scope is not scope or not rule.matches(context): + continue + key = (rule.action is not PolicyAction.REJECT, -rule.priority, rule.rule_id) + if winner_key is None or key < winner_key: + winner, winner_key = rule, key + if winner is None: return PolicyDecision(scope=scope, allowed=True, citation=None) - winner = min(pool, key=lambda rule: (-rule.priority, rule.rule_id)) citation = PolicyCitation( rule_id=winner.rule_id, version=winner.version, action=winner.action, priority=winner.priority, reason=winner.reason, @@ -338,12 +351,5 @@ def evaluate(self, scope: PolicyScope, context: Mapping[str, Any], *, as_of: int This method has no side effect and nothing to undo, so it is itself the dry-run interface: dry-run evaluation is available without a second code path. """ - matching: list[PolicyRule] = [] - for rule_id, history in self._versions.items(): - effective = _resolve_effective(history, as_of=as_of) - if effective is None or effective.scope is not scope: - continue - if effective.matches(context): - matching.append(effective) - - return decide(matching, scope, context) + effective = (_resolve_effective(history, as_of=as_of) for history in self._versions.values()) + return decide((rule for rule in effective if rule is not None), scope, context) diff --git a/src/packvium/constraints.py b/src/packvium/constraints.py index c72ae96..9544b79 100644 --- a/src/packvium/constraints.py +++ b/src/packvium/constraints.py @@ -167,6 +167,9 @@ class LoadUnit: # needs, so the crush check rides the graph that is built anyway rather than a second one. compression_ratio_ppm: int | None = None max_compression_pressure_kpa: int | None = None + # The item's own footprint, which the load presses on; `box` is its envelope, grown by the + # clearance, and it carries the support graph. + footprint_ticks2: int | None = None @dataclass(frozen=True, slots=True) @@ -198,6 +201,30 @@ def _same_nesting_column(placement: Placement, item: ItemInstance, box: AxisAlig ) +_PlaneBuckets = dict[int, tuple[tuple[int, AxisAlignedBox, int, int, int, int], ...]] + +#: The last scene's top-face buckets, keyed by the placement tuple's identity. One tuple, +#: replaced by a single assignment, so a thread never pairs one scene's key with another's +#: buckets: concurrent starts and the hosted API pack on several threads at once. +_TOP_PLANE_CACHE: tuple[tuple[Placement, ...] | None, _PlaneBuckets] = (None, {}) + + +def _top_plane_buckets(placements: tuple[Placement, ...]) -> _PlaneBuckets: + global _TOP_PLANE_CACHE + cached_placements, cached_buckets = _TOP_PLANE_CACHE + if cached_placements is placements: + return cached_buckets + buckets: dict[int, list[tuple[int, AxisAlignedBox, int, int, int, int]]] = {} + for index, placement in enumerate(placements): + other = placement.envelope_box + buckets.setdefault(other.z2, []).append( + (index, other, other.origin.x, other.origin.y, other.x2, other.y2) + ) + frozen = {z: tuple(entries) for z, entries in buckets.items()} + _TOP_PLANE_CACHE = (placements, frozen) + return frozen + + def direct_support_view( placements: Sequence[Placement], item: ItemInstance, box: AxisAlignedBox ) -> DirectSupportView: @@ -209,9 +236,24 @@ def direct_support_view( immediate predecessor becomes one full-area supporter. The list stays in placement-index order. """ + nesting = item.item.nesting_height + if nesting is None and type(placements) is tuple: + plane = _top_plane_buckets(placements).get(box.origin.z, ()) + if not plane: + return DirectSupportView(()) + bx1, by1, bx2, by2 = box.origin.x, box.origin.y, box.x2, box.y2 + supporters: list[DirectSupport] = [] + for index, other, ox1, oy1, ox2, oy2 in plane: + dx = (bx2 if bx2 < ox2 else ox2) - (bx1 if bx1 > ox1 else ox1) + if dx <= 0: + continue + dy = (by2 if by2 < oy2 else oy2) - (by1 if by1 > oy1 else oy1) + if dy > 0: + supporters.append(DirectSupport(index, other, dx * dy)) + return DirectSupportView(tuple(supporters)) + face_supporters: list[DirectSupport] = [] nearest_lower: tuple[int, AxisAlignedBox] | None = None - nesting = item.item.nesting_height for index, placement in enumerate(placements): other = placement.envelope_box other_nesting = placement.instance.item.nesting_height @@ -436,7 +478,8 @@ def load_units(placements: Sequence[Placement], extra: LoadUnit | None = None) - None if p.instance.item.nesting_height is None else p.instance.item.id, None if p.instance.item.nesting_height is None else p.instance.item.nesting_height.ticks, p.instance.item.compression_ratio_ppm, - p.instance.item.max_compression_pressure_kpa) + p.instance.item.max_compression_pressure_kpa, + p.dimensions.length.ticks * p.dimensions.width.ticks) for p in placements ] if extra is not None: units.append(extra) @@ -492,8 +535,10 @@ def crushed(units: Sequence[LoadUnit], graph: LoadSupportGraph | None = None) -> limit = unit.max_compression_pressure_kpa if limit is None: continue - box = unit.box - footprint = (box.x2 - box.origin.x) * (box.y2 - box.origin.y) + footprint = unit.footprint_ticks2 + if footprint is None: + box = unit.box + footprint = (box.x2 - box.origin.x) * (box.y2 - box.origin.y) if applied_pressure(Weight(load), footprint).exceeds_kpa(limit): return ("crush_violation", unit.label) return None @@ -819,7 +864,8 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: item.max_stacked_items, context.item.id, None if item.nesting_height is None else item.id, None if item.nesting_height is None else item.nesting_height.ticks, - item.compression_ratio_ppm, item.max_compression_pressure_kpa) + item.compression_ratio_ppm, item.max_compression_pressure_kpa, + context.dimensions.length.ticks * context.dimensions.width.ticks) units, graph = self._support.with_candidate(context.placements, unit) failure = non_stackable_failure( context.placements, context.item, graph, len(units) - 1 diff --git a/src/packvium/nesting.py b/src/packvium/nesting.py index e75836e..60fd084 100644 --- a/src/packvium/nesting.py +++ b/src/packvium/nesting.py @@ -10,6 +10,29 @@ from .models import ItemInstance, Placement +def column_layer_z(layer: int, height: int, nesting: int) -> int: + """The bottom of layer `layer` in a column of identical envelopes `height` tall that each + sink `nesting` into the one beneath. + + Only a directly adjacent pair may overlap. Once `2 x nesting > height` a third + copy sunk into the second would reach into the first, so the column nests in pairs: the + second sinks into the first and the third rests on the pair. O(1). + """ + if 2 * nesting <= height: + return layer * (height - nesting) + return (layer // 2) * (2 * height - nesting) + (layer % 2) * (height - nesting) + + +def column_layers(room: int, height: int, nesting: int) -> int: + """How many layers `column_layer_z` fits under `room`. O(1).""" + if room < height: + return 0 + if 2 * nesting <= height: + return (room - height) // (height - nesting) + 1 + pairs, rest = divmod(room, 2 * height - nesting) + return 2 * pairs + (1 if rest >= height else 0) + + def is_valid_nesting(a: "Placement", b: "Placement") -> bool: """Whether `a` and `b` are exactly the nested-column relationship `Item.nesting_height` describes: the same item type, the same footprint, one @@ -30,6 +53,20 @@ def is_valid_nesting(a: "Placement", b: "Placement") -> bool: return low.z2 - high.origin.z == nesting.ticks +def nested_overlap(lower: "Placement", upper: "Placement") -> int: + """The physical space a valid nesting pair shares. + + The pair sinks by `nesting_height` envelope to envelope, so under a clearance the items + themselves overlap by `2 x clearance` less, over their own footprint rather than the + envelope's. Counting the envelope's overlap took more than a narrow item's own volume + and reported a negative used volume. Equal to `nesting_height x base area` with no + clearance. O(1). + """ + a, b = lower.box, upper.box + height = min(a.z2, b.z2) - max(a.origin.z, b.origin.z) + return a.overlap_area_xy(b) * height if height > 0 else 0 + + def _nesting_overlap_volume(placements: Sequence["Placement"]) -> int: """Total double-counted volume between adjacent nested layers. @@ -54,7 +91,7 @@ def _nesting_overlap_volume(placements: Sequence["Placement"]) -> int: ordered = sorted(group, key=lambda p: p.envelope_box.origin.z) for lower, upper in zip(ordered, ordered[1:]): if is_valid_nesting(lower, upper): - overlap += lower.instance.item.nesting_height.ticks * lower.envelope_dimensions.base_area + overlap += nested_overlap(lower, upper) return overlap @@ -127,5 +164,5 @@ def used_volume_delta(placements: Sequence["Placement"], placement: "Placement") overlap = 0 for existing in placements: if is_valid_nesting(existing, placement): - overlap += nesting.ticks * placement.envelope_dimensions.base_area + overlap += nested_overlap(existing, placement) return placement.dimensions.volume - overlap diff --git a/src/packvium/packer.py b/src/packvium/packer.py index d37191e..195958e 100644 --- a/src/packvium/packer.py +++ b/src/packvium/packer.py @@ -50,6 +50,12 @@ def pack(self, items, containers, fixed_placements=()) -> PackingResult: # The JSON path's rules, with the JSON path's reason and field: a request # built from objects is refused exactly where the same request as JSON would be. check_objects(items, containers, self.config, fixed_placements) + return self._pack_checked(items, containers, fixed_placements) + + def _pack_checked(self, items: tuple, containers: tuple, fixed_placements: tuple) -> PackingResult: + """`pack` once the request's rules have passed. `pack_from_dict` calls it directly: + `check_request` has walked that request already, and `check_objects` refuses only what + those rules refuse, so a second walk could never fire there.""" request = PackingRequest(items, containers, fixed_placements) # Both weight objectives price the same billed weight, so both need the divisor # up front -- a wrong guess would silently misprice every shipment. And rating diff --git a/src/packvium/packing_sequence.py b/src/packvium/packing_sequence.py index 624aa9b..f3636e0 100644 --- a/src/packvium/packing_sequence.py +++ b/src/packvium/packing_sequence.py @@ -37,9 +37,9 @@ from ._compat import dataclass from typing import Sequence -from .constraints import (LoadSupportGraph, _touches_corners, direct_support_view, load_units, - non_stackable_failure, overloaded, stack_density_exceeded, - stack_limit_exceeded, stacked_counts) +from .constraints import (SQUARE_METRE_TICKS, LoadSupportGraph, LoadUnit, _touches_corners, + direct_support_view, load_units, non_stackable_failure, overloaded, + stack_density_exceeded, stack_limit_exceeded, stacked_counts) from .contact import ContactGraph from .geometry import (ALL_DIRECTIONS, AxisAlignedBox, Dimensions, InvalidDirectionError, sweep_intersects, swept_volume) @@ -502,38 +502,82 @@ def verify_loading_prefix_business_rules( """ if sorted(order) != list(range(len(placements))): raise SequenceReplayError(-1, -1, "order is not a permutation of every placement index exactly once") + if not placements: + return max_density_ticks = None if container.max_stack_density is None else container.max_stack_density.ticks # A caller may hand this any scene, validated or not, so the stacking-group rule is # re-checked here too -- only when some item declares a list, and only on the edges # of the step's own new placement, since every earlier edge was judged at its step. grouped = any(placement.instance.item.accepts_stacking_groups for placement in placements) + has_non_stackable = any(not placement.instance.item.stackable for placement in placements) + has_load_rules = ( + max_density_ticks is not None + or grouped + or has_non_stackable + or any( + placement.instance.item.max_top_load is not None + or placement.instance.item.max_stacked_items is not None + for placement in placements + ) + ) + has_ground_rules = any( + placement.instance.item.ground_contact_rule not in (None, "free") + and placement.envelope_box.origin.z != 0 + for placement in placements + ) + if not has_load_rules and not has_ground_rules: + return + all_units = load_units(placements) present: list[Placement] = [] + units: list[LoadUnit] = [] + elevated_bases: set[int] = set() + any_nesting = False for step, index in enumerate(order): - present.append(placements[index]) - units = load_units(present) - graph = LoadSupportGraph(units) - problem = ( - overloaded(units, graph) - or stack_limit_exceeded(units, graph) - or stack_density_exceeded(units, max_density_ticks, graph) + candidate = placements[index] + unit = all_units[index] + present.append(candidate) + units.append(unit) + candidate_box = candidate.envelope_box + if candidate_box.origin.z != 0: + elevated_bases.add(candidate_box.origin.z) + if unit.nesting_item_id is not None: + any_nesting = True + problem = None + isolated_floor_step = ( + candidate_box.origin.z == 0 + and not any_nesting + and candidate_box.z2 not in elevated_bases ) - if problem is None: - 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, graph, - len(present) - 1) - if refusal is not None: - problem = (refusal.code, refusal.detail) - if problem is None: - candidate = present[-1] + if isolated_floor_step: + if ( + max_density_ticks is not None + and unit.weight_ticks * SQUARE_METRE_TICKS + > max_density_ticks * candidate_box.dimensions.base_area + ): + problem = ("stack_density_exceeded", unit.label) + elif has_load_rules: + 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 and has_non_stackable: + counts = stacked_counts(units, graph) + for unit_index, u in enumerate(units): + item = present[unit_index].instance.item + if not item.stackable and counts[unit_index] > 0: + problem = ("non_stackable_item_has_load", u.label) + break + if problem is None and grouped: + refusal = non_stackable_failure( + present, candidate.instance, graph, len(present) - 1 + ) + if refusal is not None: + problem = (refusal.code, refusal.detail) + if problem is None and has_ground_rules: rule = candidate.instance.item.ground_contact_rule - if rule is not None and rule != "free" and candidate.envelope_box.origin.z != 0: - candidate_box = candidate.envelope_box + if rule is not None and rule != "free" and candidate_box.origin.z != 0: support = direct_support_view(present[:-1], candidate.instance, candidate_box) supporters = [entry.box for entry in support.supporters] violated = ( diff --git a/src/packvium/rebalance.py b/src/packvium/rebalance.py index 2cff5ef..2ae1316 100644 --- a/src/packvium/rebalance.py +++ b/src/packvium/rebalance.py @@ -199,15 +199,15 @@ def rebalance_weight( spread = max(weights) - min(weights) if spread <= 0: break - source_index = max(range(len(working)), key=lambda i: weights[i]) + source_index = max(range(len(working)), key=lambda i: (weights[i], -i)) source = working[source_index] ranked_items = sorted( range(len(source.placements)), - key=lambda i: -source.placements[i].instance.weight.ticks, + key=lambda i: (-source.placements[i].instance.weight.ticks, i), ) destinations = sorted( (i for i in range(len(working)) if i != source_index), - key=lambda i: weights[i], + key=lambda i: (weights[i], i), ) committed = None diff --git a/src/packvium/request_errors.py b/src/packvium/request_errors.py index d4d86d2..e6b3a2e 100644 --- a/src/packvium/request_errors.py +++ b/src/packvium/request_errors.py @@ -26,6 +26,7 @@ from .flat_bag import DEFORMABLE_BOUNDARY_KINDS from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length, ParsedMeasures, Weight from .made_to_measure import box_cost_minor_for +from .bounds import MAX_BOUND_SUM REASONS = ("missing_field", "wrong_type", "below_minimum", "above_maximum", "negative_measure", "invalid_unit", "duplicate_id", "not_allowed", "invalid_value") @@ -41,6 +42,14 @@ WEIGHT_UNITS = WEIGHT_UNIT_CODES GROUND_CONTACT_RULES = ("free", "covered", "single", "multiple") SHAPE_TYPES = ("rigid_cuboid", "convex_hull", "compressible") +#: The admission ceiling every engine refuses past. A length is a tick count +#: every engine holds exactly, JavaScript's `Number` included; a box encloses no more than the +#: bound path can sum, so `volume x 10^6` stays inside Rust's `i128`. +MAX_LENGTH_TICKS = MAX_EXACT_MAGNITUDE +MAX_VOLUME_TICKS3 = MAX_BOUND_SUM +#: A weight, and every item's weight times its quantity plus the heaviest tare, fit a signed +#: 64-bit integer, which is how Rust and PHP add a container's payload (R19-01). +MAX_WEIGHT_TICKS = 2**63 - 1 def pointer(*parts: Any) -> str: @@ -112,9 +121,11 @@ def _list(value: Any, field: str) -> Sequence[Any]: 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.""" + """A measure is an integer, a string or `{value, unit}`, and never negative; a length is + at most `MAX_LENGTH_TICKS` and a weight at most `MAX_WEIGHT_TICKS`.""" if isinstance(value, bool) or not isinstance(value, (int, float, str, Mapping)) or ( - isinstance(value, float) and json_integer(value) is None): + isinstance(value, float) and json_integer(value) is None + and not _whole_beyond_exact(value)): raise InvalidRequestError("wrong_type", field, "must be a measure") if isinstance(value, Mapping): if "value" not in value: @@ -122,12 +133,36 @@ def _measure(value: Any, field: str, kind: Type[Length] | Type[Weight], unit: st stated = value.get("unit", unit) 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)}") + amount = value["value"] if isinstance(value, Mapping) else value + if kind is Length and _whole_beyond_exact(amount): + # Past 2^53 - 1 a JSON number is the same whole number to every engine however its + # parser stored it, and its ticks are past the ceiling in every unit. + if amount < 0: + raise InvalidRequestError("negative_measure", field, "cannot be negative") + raise InvalidRequestError("above_maximum", field, f"must be at most {MAX_LENGTH_TICKS} ticks") + if kind is Weight and _whole_beyond_exact(amount): + # Past 2^53 - 1 JavaScript has already rounded a JSON number before it reads it, so a + # weight that large is written as a string, which every engine reads exactly. + if amount < 0: + raise InvalidRequestError("negative_measure", field, "cannot be negative") + raise InvalidRequestError("invalid_value", field, + f"must be written as a string past {MAX_EXACT_MAGNITUDE}") try: - measures.read(kind, value, unit) + ticks = measures.read(kind, value, unit).ticks except (ValueError, TypeError, ArithmeticError) as error: if "cannot be negative" in str(error): raise InvalidRequestError("negative_measure", field, "cannot be negative") from error raise InvalidRequestError("wrong_type", field, "must be a measure") from error + if kind is Length: + length_ticks(ticks, field) + else: + weight_ticks(ticks, field) + + +def _whole_beyond_exact(value: Any) -> bool: + whole = not isinstance(value, bool) and ( + isinstance(value, int) or (isinstance(value, float) and value.is_integer())) + return whole and abs(value) > MAX_EXACT_MAGNITUDE # ------------------------------------------------------------------------------ the rules @@ -169,6 +204,7 @@ def check_request(data: Any) -> ParsedMeasures: for index, raw in enumerate(containers): _check_container(raw, pointer("containers", index), unit, measures) _require_unique_ids(containers, "containers") + _total_weight(items, containers, measures) return measures @@ -267,7 +303,8 @@ def _check_container(raw: Any, where: str, unit: str, measures: ParsedMeasures) _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, measures)) + _optional(container, "obstacles", where, lambda v, f: _obstacles( + v, f, unit, measures, _ticks(container["inner_dimensions"], unit, measures))) _optional(container, "deformable_boundary", where, lambda v, f: _deformable_boundary(container, v, f, where)) _optional(container, "made_to_measure", where, @@ -304,7 +341,7 @@ 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, measures)) + _optional(machine, name, field, lambda v, f: _dimensions(v, f, unit, measures, positive=False)) _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", measures)) if container.get("outer_dimensions") is not None: @@ -354,10 +391,61 @@ def _access_directions(raw: Any, where: str) -> None: _each_one_of(raw, where, ACCESS_DIRECTIONS) -def _dimensions(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: +def _dimensions(raw: Any, where: str, unit: str, measures: ParsedMeasures, positive: bool = True) -> None: + """A box's three sides, each a measure; a box that holds or is something has no empty side +, while a made-to-measure step states its own rule for zero.""" sides = _object(raw, where) for side in _SIDES: - _measure(_required(sides, side, where), pointer_join(where, side), Length, unit, measures) + field = pointer_join(where, side) + value = _required(sides, side, where) + _measure(value, field, Length, unit, measures) + if positive and measures.read(Length, value, unit).ticks == 0: + raise InvalidRequestError("invalid_value", field, "must be positive") + _enclosure(_ticks(sides, unit, measures), where) + + +def box_ticks(sides: Tuple[int, int, int], where: str) -> None: + """A box's sides in ticks, held to the admission ceiling as the request spells them: each + side, then what they enclose. Shared with `request_objects`. O(1).""" + for side, ticks in zip(_SIDES, sides): + length_ticks(ticks, pointer_join(where, side)) + _enclosure(sides, where) + + +def length_ticks(ticks: int, field: str) -> None: + """A length the object path holds as ticks, against `MAX_LENGTH_TICKS`.""" + if ticks > MAX_LENGTH_TICKS: + raise InvalidRequestError("above_maximum", field, f"must be at most {MAX_LENGTH_TICKS} ticks") + + +def weight_ticks(ticks: int, field: str) -> None: + """A weight the object path holds as ticks, against `MAX_WEIGHT_TICKS`.""" + if ticks > MAX_WEIGHT_TICKS: + raise InvalidRequestError("above_maximum", field, f"must be at most {MAX_WEIGHT_TICKS} ticks") + + +def total_weight_ticks(item_weights: Iterable[Tuple[int, int]], tares: Iterable[int]) -> None: + """Every item's weight times its quantity, plus the heaviest tare, against + `MAX_WEIGHT_TICKS`: no container's gross weight can pass it. Shared with + `request_objects`. O(items + containers).""" + total = sum(weight * quantity for weight, quantity in item_weights) + max(tares, default=0) + if total > MAX_WEIGHT_TICKS: + raise InvalidRequestError("above_maximum", "/items", + f"must weigh at most {MAX_WEIGHT_TICKS} ticks in all, with the heaviest tare") + + +def _total_weight(items: list, containers: list, measures: ParsedMeasures) -> None: + def ticks(holder: Mapping[str, Any], name: str) -> int: + value = holder.get(name) + return 0 if value is None else measures.read(Weight, value, "g").ticks + total_weight_ticks(((ticks(item, "weight"), int(item.get("quantity", 1))) for item in items), + (ticks(container, "tare_weight") for container in containers)) + + +def _enclosure(sides: Tuple[int, int, int], where: str) -> None: + length, width, height = sides + if length * width * height > MAX_VOLUME_TICKS3: + raise InvalidRequestError("above_maximum", where, "must enclose at most 10^30 cubic ticks") def _stacking_group_name(value: Any, field: str) -> None: @@ -391,16 +479,36 @@ 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, measures: ParsedMeasures) -> None: +def _obstacles(raw: Any, where: str, unit: str, measures: ParsedMeasures, + inner: Tuple[int, int, int]) -> None: for index, entry in enumerate(_list(raw, where)): at = pointer_join(where, index) obstacle = _object(entry, at) - origin = obstacle.get("origin") - 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, measures)) - _dimensions(_required(obstacle, "dimensions", at), at + "/dimensions", unit, measures) + _placed_box(obstacle, at, unit, measures, inner) + extra = obstacle.get("additional_boxes") + if extra is not None: + boxes = pointer_join(at, "additional_boxes") + for position, box in enumerate(_list(extra, boxes)): + part = pointer_join(boxes, position) + _placed_box(_object(box, part), part, unit, measures, inner) + + +def _placed_box(box: Mapping[str, Any], at: str, unit: str, measures: ParsedMeasures, + inner: Tuple[int, int, int]) -> None: + """An optional `origin` and required `dimensions`: an obstacle, or one more box of it +, whose measures are read in the request's unit like every other length, and + which lies inside the container.""" + origin = {} if box.get("origin") is None else box["origin"] + if box.get("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, measures)) + _dimensions(_required(box, "dimensions", at), at + "/dimensions", unit, measures) + sides = _ticks(box["dimensions"], unit, measures) + for axis, side, room in zip(("x", "y", "z"), sides, inner): + start = origin.get(axis) + if (0 if start is None else measures.read(Length, start, unit).ticks) + side > room: + raise InvalidRequestError("invalid_value", at, "lies outside inner_dimensions") # ------------------------------------------------------------------------------ plumbing diff --git a/src/packvium/request_objects.py b/src/packvium/request_objects.py index 6e821f0..4dabf96 100644 --- a/src/packvium/request_objects.py +++ b/src/packvium/request_objects.py @@ -24,8 +24,8 @@ from .made_to_measure import MadeToMeasure, as_triple, box_cost_minor_for from .models import Container, FixedPlacement, Item, RateTable from .request_errors import (BAG_EXCLUSIONS, InvalidRequestError, _integer, _one_of, - _ratio, _set_beside_a_bag, _stacking_group_name, pointer, - pointer_join) + _ratio, _set_beside_a_bag, _stacking_group_name, box_ticks, + length_ticks, pointer, pointer_join, total_weight_ticks, weight_ticks) #: (JSON name, PackingConfig attribute, floor), in `check_request`'s order. _CONFIGURATION_INTEGERS = (("time_limit_ms", "time_limit_ms", 1), ("alternatives", "top_k", 1), @@ -51,6 +51,8 @@ def check_objects(items: Sequence[Item], containers: Sequence[Container], config for index, container in enumerate(containers): _check_container(container, pointer("containers", index)) _require_unique_ids(containers, "containers") + total_weight_ticks(((item.weight.ticks, item.quantity) for item in items), + (container.tare_weight.ticks for container in containers)) for index, placement in enumerate(fixed_placements): _check_fixed_placement(placement, f"fixed_placements[{index}]", pointer("fixed_placements", index)) @@ -61,6 +63,7 @@ def _check_configuration(config: PackingConfig) -> None: where = "/configuration" for name, attribute, minimum in _CONFIGURATION_INTEGERS: _optional(getattr(config, attribute), pointer_join(where, name), minimum) + length_ticks(config.clearance.ticks, pointer_join(where, "clearance")) _ratio(config.minimum_support_ratio, pointer_join(where, "minimum_support_ratio")) if config.effort_budget is not None: budget = pointer_join(where, "effort_budget") @@ -71,6 +74,10 @@ def _check_configuration(config: PackingConfig) -> None: def _check_item(item: Item, where: str) -> None: _string_id(item.id, where) _integer(item.quantity, pointer_join(where, "quantity"), 1) + box_ticks(as_triple(item.dimensions), pointer_join(where, "dimensions")) + if item.nesting_height is not None: + length_ticks(item.nesting_height.ticks, pointer_join(where, "nesting_height")) + _weights(item, where, ("weight", "max_top_load")) _optional(item.max_stacked_items, pointer_join(where, "max_stacked_items"), 1) _optional(item.stop_index, pointer_join(where, "stop_index"), 0) _optional(item.value, pointer_join(where, "value"), 0) @@ -86,6 +93,10 @@ def _check_item(item: Item, where: str) -> None: def _check_container(container: Container, where: str) -> None: _string_id(container.id, where) _optional(container.quantity, pointer_join(where, "quantity"), 1) + box_ticks(as_triple(container.inner_dimensions), pointer_join(where, "inner_dimensions")) + if container.outer_dimensions is not None: + box_ticks(as_triple(container.outer_dimensions), pointer_join(where, "outer_dimensions")) + _weights(container, where, ("tare_weight", "max_payload", "max_stack_density")) _optional(container.max_items, pointer_join(where, "max_items"), 1) _integer(container.cost_minor, pointer_join(where, "cost_minor"), 0) _ratio(container.void_fill_reserve_ratio, pointer_join(where, "void_fill_reserve_ratio")) @@ -100,6 +111,13 @@ def _check_container(container: Container, where: str) -> None: _check_made_to_measure(container, container.made_to_measure, where) +def _weights(holder: object, where: str, names: Sequence[str]) -> None: + for name in names: + weight = getattr(holder, name) + if weight is not None: + weight_ticks(weight.ticks, pointer_join(where, name)) + + def _check_rate_table(table: RateTable, where: str) -> None: for name, minimum in (("weight_brackets_g", 1), ("prices_minor", 0)): for index, value in enumerate(getattr(table, name)): @@ -120,7 +138,10 @@ def _check_flat_bag(container: Container, where: str) -> None: def _check_made_to_measure(container: Container, machine: MadeToMeasure, where: str) -> None: field = pointer_join(where, "made_to_measure") price = pointer_join(field, "cost_per_square_metre_minor") + for name in ("minimum", "increment", "outer_allowance"): + box_ticks(getattr(machine, name), pointer_join(field, name)) _integer(machine.cost_per_square_metre_minor, price, 0) + weight_ticks(machine.tare_per_square_metre, pointer_join(field, "tare_per_square_metre")) if container.outer_dimensions is not None: raise InvalidRequestError("not_allowed", pointer_join(where, "outer_dimensions"), "cannot be combined with made_to_measure") diff --git a/src/packvium/result_validation.py b/src/packvium/result_validation.py index 340f3a9..212c2cc 100644 --- a/src/packvium/result_validation.py +++ b/src/packvium/result_validation.py @@ -1084,7 +1084,9 @@ def _occupied_volume( and a compressible one never shows the height it gave up under load. """ if spec is not None and spec.max_compression_pressure_kpa is not None: - return envelope.base_area * _compressed_height(envelope, spec, load) + # The load presses on the item itself, not on the clearance around it. + length, width, height = _physical_sides(placement) + return length * width * _compressed_height(height, length * width, spec, load) if spec is not None and spec.shape_type == "convex_hull" and spec.hull_vertices is not None: # Collision intentionally uses the box for routes and clearance. Volume is a property # of the physical hull and must not inherit that conservative collision fallback. @@ -1097,7 +1099,24 @@ def _occupied_volume( return _hull_volume(solid) -def _compressed_height(envelope: Box, spec: ItemSpec, load: int) -> int: +def _physical_sides(placement: dict) -> tuple[int, int, int]: + """The placed item's own sides, without the clearance its envelope adds.""" + dimensions = placement["dimensions"] + return (dimensions["length"]["ticks"], dimensions["width"]["ticks"], + dimensions["height"]["ticks"]) + + +def _pressure(load: int, footprint: int) -> Fraction: + """kPa on `footprint` square ticks under `load` weight ticks, exactly.""" + # P(kPa) = load_ticks * g * (ticks per metre)^2 / (ticks per kg * g_denominator + # * pascals per kPa * footprint_ticks^2) + return Fraction( + load * 980_665 * (LENGTH_TICKS["mm"] * 1000) ** 2, + WEIGHT_TICKS["kg"] * 100_000 * 1_000 * footprint, + ) + + +def _compressed_height(height: int, footprint: int, spec: ItemSpec, load: int) -> int: """Occupied height under load, rounded up, never below one tick. The formula of docs/IRREGULAR-ITEMS.md, in exact rationals. A crushed item keeps its @@ -1106,15 +1125,12 @@ def _compressed_height(envelope: Box, spec: ItemSpec, load: int) -> int: """ assert spec.compression_ratio_ppm is not None assert spec.max_compression_pressure_kpa is not None - pressure = Fraction( - load * 980_665 * (LENGTH_TICKS["mm"] * 1000) ** 2, - WEIGHT_TICKS["kg"] * 100_000 * 1_000 * envelope.base_area, - ) + pressure = _pressure(load, footprint) if pressure > spec.max_compression_pressure_kpa or spec.max_compression_pressure_kpa == 0: - return envelope.height + return height divisor = spec.max_compression_pressure_kpa * 1_000_000 * pressure.denominator retained = divisor - spec.compression_ratio_ppm * pressure.numerator - return max(1, -(-envelope.height * retained // divisor)) + return max(1, -(-height * retained // divisor)) def _hull_volume(vertices: tuple[tuple[int, int, int], ...]) -> int: @@ -1268,6 +1284,7 @@ def _check_container( packed, envelopes, supporters, direct_above, global_support, issues ) loads = _propagate_load(envelopes, supporters) + _check_stack_top_load(envelopes, loads, issues) _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, @@ -1308,7 +1325,11 @@ def _check_container( 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 + # The envelopes sink by the nesting height; the items themselves overlap by + # twice the clearance less, over their own footprint. + overlap = left_spec.nesting_height - 2 * clearance + if overlap > 0: + used -= overlap * (left.length - 2 * clearance) * (left.width - 2 * clearance) reported_volume = packed.get("used_volume_ticks3") if reported_volume is not None and int(reported_volume) != used: issues.append(Issue("used_volume_mismatch", @@ -1503,6 +1524,41 @@ def _support_graph( return supporters, direct_above +def _hull_cross(o: tuple[int, int], a: tuple[int, int], b: tuple[int, int]) -> int: + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + +def _centroid_supported(envelope: Box, surfaces: Sequence[Box]) -> bool: + """Whether the base centroid lies in the convex hull of the contact areas. + + The rule the four searches apply once a support ratio is enforced, and + only reached here at or below half the base, where it can refuse: above half a centrally + symmetric footprint keeps its centroid in the hull. Coordinates are doubled so + the centroid is an integer for odd extents. The corners come four to a positive-area + overlap, so the hull is empty or has at least three vertices. O(s log s) time and O(s) + space for s supporters. + """ + points = sorted({ + (2 * x, 2 * y) + for surface in surfaces + for x in (max(envelope.x, surface.x), min(envelope.x2, surface.x2)) + for y in (max(envelope.y, surface.y), min(envelope.y2, surface.y2)) + }) + lower: list[tuple[int, int]] = [] + upper: list[tuple[int, int]] = [] + for chain, order in ((lower, points), (upper, reversed(points))): + for point in order: + while len(chain) >= 2 and _hull_cross(chain[-2], chain[-1], point) <= 0: + chain.pop() + chain.append(point) + hull = lower[:-1] + upper[:-1] + centroid = (envelope.x + envelope.x2, envelope.y + envelope.y2) + return bool(hull) and all( + _hull_cross(hull[index], hull[(index + 1) % len(hull)], centroid) >= 0 + for index in range(len(hull)) + ) + + def _check_support_and_load( packed: dict, envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], @@ -1558,6 +1614,11 @@ def _check_support_and_load( ratio = Fraction(area, envelope.base_area) issues.append(Issue("insufficient_support", f"{item_id}: {float(ratio):.6f} < {float(required):.6f}")) + elif area * 2 <= envelope.base_area and not _centroid_supported( + envelope, [surface for _, surface, _, _ in supporting] + ): + issues.append(Issue("centre_of_gravity_unsupported", + f"{item_id}: centroid outside its support hull")) for lower_id, _, lower_spec, _ in supporting: if lower_spec is None: @@ -1567,10 +1628,6 @@ def _check_support_and_load( elif (lower_spec.accepts_stacking_groups and spec.stacking_group not in lower_spec.accepts_stacking_groups): issues.append(Issue("stacking_group_incompatible", f"{item_id} rests on {lower_id}")) - if lower_spec.max_top_load is not None and spec.weight > lower_spec.max_top_load: - issues.append(Issue("top_load_exceeded", - f"{item_id} ({spec.weight} ticks) on {lower_id} " - f"(limit {lower_spec.max_top_load})")) for item_id, (_, _, spec) in envelopes.items(): if spec is None or spec.max_stacked_items is None: @@ -1622,6 +1679,26 @@ def _propagate_load( return loads +def _check_stack_top_load( + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + loads: dict[str, int], + issues: list[Issue], +) -> None: + """`max_top_load` against the whole stack an item bears, as every engine checks it. + + The only top-load rule since 1.9.0: an item resting on several bearers shares its + weight among them by contact area, so comparing its whole weight with each bearer's limit + rejected answers every engine returns. O(n) over the loads `_propagate_load` built. + """ + for item_id, (_, _, spec) in envelopes.items(): + if spec is None or spec.max_top_load is None or loads[item_id] <= spec.max_top_load: + continue + issues.append(Issue( + "top_load_exceeded", + f"{item_id} bears {loads[item_id]} ticks (limit {spec.max_top_load})", + )) + + def _check_crush( packed: dict, envelopes: dict[str, tuple[Box, dict, "ItemSpec | None"]], @@ -1642,15 +1719,11 @@ def _check_crush( if all(spec is None or spec.max_compression_pressure_kpa is None for _, _, spec in envelopes.values()): return - for item_id, (envelope, _, spec) in envelopes.items(): + for item_id, (_, placement, spec) in envelopes.items(): if spec is None or spec.max_compression_pressure_kpa is None: continue - # P(kPa) = load_ticks * g * (ticks per metre)^2 / (ticks per kg * g_denominator - # * pascals per kPa * footprint_ticks^2) - pressure = Fraction( - loads[item_id] * 980_665 * (LENGTH_TICKS["mm"] * 1000) ** 2, - WEIGHT_TICKS["kg"] * 100_000 * 1_000 * envelope.base_area, - ) + length, width, _ = _physical_sides(placement) + pressure = _pressure(loads[item_id], length * width) if pressure > spec.max_compression_pressure_kpa: issues.append(Issue( "crush_violation", diff --git a/src/packvium/serialization.py b/src/packvium/serialization.py index f1405aa..1503559 100644 --- a/src/packvium/serialization.py +++ b/src/packvium/serialization.py @@ -105,13 +105,14 @@ def _item(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) -> It 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) + x, y, z = (measures.read(Length, 0 if raw.get(axis) is None else raw[axis], unit).ticks + for axis in ("x", "y", "z")) + return x, y, z def _box(raw: dict, unit: str, measures: ParsedMeasures) -> AxisAlignedBox: return AxisAlignedBox( - Point(*_point_ticks(raw.get("origin", {}), unit, measures)), + Point(*_point_ticks({} if raw.get("origin") is None else raw["origin"], unit, measures)), _dimensions(raw["dimensions"], unit, measures), ) @@ -144,7 +145,8 @@ def _container(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) measures = _or_fresh(measures) obstacles = tuple( Obstacle(str(o["id"]), _box(o, unit, measures), - additional_boxes=tuple(_box(b, unit, measures) for b in o.get("additional_boxes", []))) + additional_boxes=tuple(_box(b, unit, measures) for b in + (() if o.get("additional_boxes") is None else o["additional_boxes"]))) for o in raw.get("obstacles", []) ) return Container( @@ -321,7 +323,7 @@ def pack_from_dict(data: dict, *, 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 = Packer(config, extensions)._pack_checked(tuple(items), tuple(containers), tuple(fixed)) result = replace(result, catalog_versions_used=references) output = data.get("output", {}) return result.to_dict(output.get("length_unit", output_length_unit(unit)), output.get("weight_unit", "g")) diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index 31c1610..c33bc39 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -29,8 +29,8 @@ from .lattice_summary import LatticeSummary from .models import (Container, ItemInstance, PackedContainer, Placement, UnpackedItem, carries_itself, hull_collision_is_exact, is_stack_sensitive) -from .nesting import (is_valid_nesting, item_occupied_volume, occupied_volume, - used_volume as nesting_used_volume, used_volume_delta) +from .nesting import (column_layer_z, column_layers, is_valid_nesting, item_occupied_volume, + occupied_volume, used_volume as nesting_used_volume, used_volume_delta) from .result import SolverMetrics, StartRecord from .spatial_index import SpatialIndex from . import trace @@ -140,12 +140,17 @@ def expired(self) -> bool: return self.limit_ns - (self._clock() - self.started) <= 0 def slice(self, parts: int) -> "Deadline": + return self.share(1, parts) + + def share(self, weight: int, total: int) -> "Deadline": + """`weight / total` of what is left, never below the minimum slice; all of it when + this is the last share.""" remaining = self.remaining_ns - if parts <= 1: + if total <= weight: return Deadline(0, limit_ns=remaining, clock=self._clock, effort_budget=self._effort_budget, stats=self._stats) return Deadline( 0, - limit_ns=max(remaining // parts, min(remaining, MINIMUM_SLICE_NS)), + limit_ns=max(remaining * weight // total, min(remaining, MINIMUM_SLICE_NS)), clock=self._clock, effort_budget=self._effort_budget, stats=self._stats, @@ -1425,8 +1430,7 @@ def pack_one(self, container, sequence, items, config, stats, deadline): nx = container.inner_dimensions.length.ticks // envelope.length.ticks ny = container.inner_dimensions.width.ticks // envelope.width.ticks layer_step = envelope.height.ticks - nesting_ticks - inner_height = container.inner_dimensions.height.ticks - nz = 0 if inner_height < envelope.height.ticks else (inner_height - envelope.height.ticks) // layer_step + 1 + nz = column_layers(container.inner_dimensions.height.ticks, envelope.height.ticks, nesting_ticks) if single_layer: nz = min(nz, 1) if prototype.max_top_load is not None and prototype.weight.ticks > 0: # In a uniform column the bottom item bears every item above it, so @@ -1485,7 +1489,8 @@ def pack_one(self, container, sequence, items, config, stats, deadline): stats.search_nodes_expanded += 1 stats.candidate_points_considered += 1 x, y, z = index % nx, (index // nx) % ny, index // (nx * ny) - point = Point(x * envelope.length.ticks, y * envelope.width.ticks, z * layer_step) + point = Point(x * envelope.length.ticks, y * envelope.width.ticks, + column_layer_z(z, envelope.height.ticks, nesting_ticks)) position = Point(point.x + clearance, point.y + clearance, point.z + clearance) # A mixed-declared-type lattice group is only reached when every # item shares the same *achievable footprint set*, not necessarily the same @@ -1841,15 +1846,6 @@ def _supports(self, container, items, config) -> bool: for item in items ) - @staticmethod - def _member_support(state: ContainerState, instance: ItemInstance, point: Point, - envelope: Dimensions) -> float: - """The share of a member's base that rests on the floor or on a box beneath it.""" - if point.z == 0: - return 1.0 - support = direct_support_view(state.placements, instance, AxisAlignedBox(point, envelope)) - return support.supporting_area / envelope.base_area - @staticmethod def _solution_key(solution: SingleContainerSolution, by_volume: bool = False) -> tuple: state = solution.state @@ -2092,21 +2088,58 @@ def _commit(self, state, best, remaining, config, stats, deadline) -> bool: del remaining[best.item_id][:members] clearance = config.clearance.ticks per_layer = best.nx * best.ny + dx = best.envelope.length.ticks + dy = best.envelope.width.ticks + dz = best.envelope.height.ticks + base_z = best.space.origin.z + base_area = best.envelope.base_area + supporters: list[tuple[int, int, int, int]] = [] + if base_z > 0 and members > 0: + bx1 = best.space.origin.x + by1 = best.space.origin.y + bx2 = bx1 + best.nx * dx + by2 = by1 + best.ny * dy + for existing in state.placements: + e_origin = existing.envelope_origin + e_dims = existing.envelope_dimensions + if e_origin.z + e_dims.height.ticks != base_z: + continue + ox1 = e_origin.x + ox2 = ox1 + e_dims.length.ticks + if ox2 <= bx1 or ox1 >= bx2: + continue + oy1 = e_origin.y + oy2 = oy1 + e_dims.width.ticks + if oy2 <= by1 or oy1 >= by2: + continue + supporters.append((ox1, oy1, ox2, oy2)) 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) + px1 = best.space.origin.x + x * dx + py1 = best.space.origin.y + y * dy + pz1 = base_z + z * dz + point = Point(px1, py1, pz1) + position = Point(px1 + clearance, py1 + clearance, pz1 + clearance) + if z == 0 and base_z > 0: + px2 = px1 + dx + py2 = py1 + dy + area = 0 + for ox1, oy1, ox2, oy2 in supporters: + ix = (px2 if px2 < ox2 else ox2) - (px1 if px1 > ox1 else ox1) + if ix <= 0: + continue + iy = (py2 if py2 < oy2 else oy2) - (py1 if py1 > oy1 else oy1) + if iy > 0: + area += ix * iy + support_ratio = area / base_area + else: + support_ratio = 1.0 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, + support_ratio, ) ) stats.candidates_evaluated += 1 @@ -2490,8 +2523,20 @@ def _run_concurrent_start(solver, order, containers, config, container_selector, return packed, unpacked, exhaustive, reached, dominant_lattice, stats, budget.effort_stopped +def _slice_weights(starts: Sequence[tuple]) -> list[int]: + """Each start's weight in the time slicing. The late-type neighbourhoods are greedy re-runs + of one order, so together they weigh one start: with T of them, each weighs 1 and every + other start T. Without neighbourhoods every start weighs 1, the even share it always had. + Otherwise twenty item types would halve every other start's time, the first start's + lattice included (1.9.0 release frontier, br7 `quality`).""" + neighbourhoods = sum(1 for _, name, _ in starts if name.startswith("neighborhood:")) + if neighbourhoods == 0: + return [1] * len(starts) + return [1 if name.startswith("neighborhood:") else neighbourhoods for _, name, _ in starts] + + def _start_budget(deadline: Deadline, effort_budget: EffortBudget | None, stats: SearchStats, - starts_left: int) -> Deadline: + starts_left: int, weight: int = 1) -> 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 @@ -2501,7 +2546,7 @@ def _start_budget(deadline: Deadline, effort_budget: EffortBudget | None, stats: lose quietly, making a budgeted answer depend on the machine's speed. """ if effort_budget is None: - return deadline.slice(starts_left) + return deadline.share(weight, starts_left) return deadline.with_effort(effort_budget, stats) @@ -2709,6 +2754,7 @@ def _eligible_for_concurrent_execution(self, deadline, config) -> bool: def _solve_sequential(self, items, containers, config, deadline, starts, effort_budget, preloaded=()): results = [] + weights = _slice_weights(starts) completed_orders: dict[str, tuple] = {} records = [ StartRecord(f"{solver.name}:{order_name}", False, False, False) @@ -2745,7 +2791,7 @@ def _solve_sequential(self, items, containers, config, deadline, starts, effort_ else: order_name = f"{base_name}:beam" stats = SearchStats() - budget = _start_budget(deadline, effort_budget, stats, len(starts) - position) + budget = _start_budget(deadline, effort_budget, stats, sum(weights[position:]), weights[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) wall_reached, effort_reached = _limits_reached(reached, budget.effort_stopped, budget.remaining_ns) @@ -3215,8 +3261,12 @@ def _across_containers_beam(self, solver, items, containers, config, stats, dead incumbent = initial incumbent_score = self._score_plan(incumbent.packed, incumbent.remaining, config) plan_nodes = 0 + # A beam wider than its node budget used to stop after trying the + # first container in static order, so the order chose it, not the objective. The + # budget is at least one trial per container type for every plan the beam keeps. + node_limit = max(config.container_plan_node_limit, config.container_plan_beam_width * len(containers)) - while beam and plan_nodes < config.container_plan_node_limit: + while beam and plan_nodes < node_limit: expansions: list[_ContainerPlan] = [] for plan in beam: if not plan.remaining or len(plan.packed) >= maximum: @@ -3224,7 +3274,7 @@ def _across_containers_beam(self, solver, items, containers, config, stats, dead # and the incumbent only ever improves, so it cannot win now. continue for container in ordered: - if plan_nodes >= config.container_plan_node_limit: + if plan_nodes >= node_limit: break if deadline.expired: reached = True diff --git a/tests/test_commerce_catalog_dates.py b/tests/test_commerce_catalog_dates.py new file mode 100644 index 0000000..8df34fe --- /dev/null +++ b/tests/test_commerce_catalog_dates.py @@ -0,0 +1,40 @@ +"""Catalog date validation at the exported commerce boundary.""" + +import pytest + +from packvium.commerce import CommerceInputError, catalog_version_info + + +@pytest.mark.parametrize("catalog_id", ["c", "missing"]) +def test_negative_resolution_time_is_an_input_error_before_catalog_lookup(catalog_id): + document = {"catalogs": [{"catalog_id": "c", "versions": [ + {"effective_at": 0, "published_at": 0, "snapshot": {}}, + ]}]} + with pytest.raises(CommerceInputError, match="resolved_at"): + catalog_version_info(document, {"catalog_id": catalog_id, "version": 1, "resolved_at": -1}) + + +@pytest.mark.parametrize("rollback", [ + {"rollback_to": 1, "published_at": 0, "effective_at": -1}, + {"rollback_to": 1, "published_at": -1, "effective_at": 0}, + {"rollback_to": 1, "published_at": -1}, +]) +def test_negative_rollback_dates_are_input_errors(rollback): + document = {"catalogs": [{"catalog_id": "c", "versions": [ + {"effective_at": 0, "published_at": 0, "snapshot": {}}, rollback, + ]}]} + with pytest.raises(CommerceInputError): + catalog_version_info(document, {"catalog_id": "c", "version": 2, "resolved_at": 0}) + + +def test_zero_dates_are_valid_for_a_rollback(): + document = {"catalogs": [{"catalog_id": "c", "versions": [ + {"effective_at": 0, "published_at": 0, "snapshot": {}}, + {"rollback_to": 1, "published_at": 0}, + ]}]} + result = catalog_version_info(document, {"catalog_id": "c", "as_of": 0, "resolved_at": 0}) + assert result["status"] == "ok" + assert result["catalog"]["version"] == 2 + assert result["catalog"]["effective_at"] == 0 + assert result["catalog"]["published_at"] == 0 + assert result["catalog"]["resolved_at"] == 0 diff --git a/tests/test_commerce_models.py b/tests/test_commerce_models.py index f720852..2df3940 100644 --- a/tests/test_commerce_models.py +++ b/tests/test_commerce_models.py @@ -378,3 +378,14 @@ def test_a_well_formed_predicate_still_publishes(self): ) assert rule.version == 1, "the guard must not reject the ordinary case" + + def test_a_later_version_with_an_earlier_effective_date_does_not_displace_the_newer_one(self): + registry = PolicyRegistry() + registry.publish("r", scope=PolicyScope.HAZMAT, action=PolicyAction.REJECT, + predicates=(predicate(),), priority=0, effective_at=10) + registry.publish("r", scope=PolicyScope.HAZMAT, action=PolicyAction.ALLOW, + predicates=(predicate(),), priority=0, effective_at=5) + + decision = registry.evaluate(PolicyScope.HAZMAT, {"f": 1}, as_of=20) + + assert decision.citation.version == 1, "the latest effective date wins, not the latest version" diff --git a/tests/test_commerce_policy_values.py b/tests/test_commerce_policy_values.py new file mode 100644 index 0000000..6490152 --- /dev/null +++ b/tests/test_commerce_policy_values.py @@ -0,0 +1,45 @@ +"""Policy values must produce the same business decision in every language.""" + +import pytest + +from packvium.commerce import evaluate_policy + + +def decision(operator, value, context): + document = {"policy_rules": [{"rule_id": "restriction", "versions": [{ + "scope": "facility", "action": "reject", "priority": 1, "effective_at": 0, + "predicates": [{"scope": "facility", "field": "x", "operator": operator, + "value": value}], + }]}]} + return evaluate_policy(document, {"scope": "facility", "context": context, "as_of": 0})["decision"] + + +@pytest.mark.parametrize("actual", [1, True, None, [], {"a": 1}]) +@pytest.mark.parametrize("operator,allowed", [("in", True), ("not_in", False)]) +def test_non_string_context_values_are_not_substrings(actual, operator, allowed): + assert decision(operator, "abc", {"x": actual})["allowed"] is allowed + + +@pytest.mark.parametrize("operator", ["equals", "not_equals", "in", "not_in"]) +def test_missing_context_fields_do_not_match_binary_operators(operator): + assert decision(operator, {"a": 1}, {})["citation"] is None + + +@pytest.mark.parametrize("operator,value,actual,allowed", [ + ("equals", {"a": [1, {"flag": True}], "b": None}, + {"b": None, "a": [True, {"flag": 1}]}, False), + ("not_equals", {"a": None}, {"b": None}, False), + ("equals", {"a": None}, {"b": None}, True), + ("equals", {"a": [1, 2]}, {"a": [2, 1]}, True), + ("in", [{"a": 1, "b": True}], {"b": 1, "a": True}, False), + ("not_in", [{"a": 1}], {"a": 2}, False), + ("in", {"a": None}, "a", False), + ("in", {"a": "b"}, "b", True), + ("not_in", {"a": 1}, "missing", False), + ("in", {"a": 1}, ["a"], True), + ("in", {"a": 1}, {"a": 1}, True), +]) +def test_structured_values_follow_reference_semantics(operator, value, actual, allowed): + result = decision(operator, value, {"x": actual}) + assert result["allowed"] is allowed + assert (result["citation"] is None) is allowed diff --git a/tests/test_fixed_placements.py b/tests/test_fixed_placements.py index 2d18928..7c2ee8c 100644 --- a/tests/test_fixed_placements.py +++ b/tests/test_fixed_placements.py @@ -197,6 +197,8 @@ def test_the_concurrent_portfolio_keeps_fixed_items(): or d["fixed_placements"].append(fixed(instance=2)), "2 box named, 1 available"), (lambda d: d.update(configuration={"max_containers": 1}) or d["fixed_placements"].append(fixed(instance=2)), "max_containers is 1"), + (lambda d: d.update(configuration={"minimum_support_ratio": 0.25}) + or d["fixed_placements"].append(fixed("40", z="100")), "centre_of_gravity_unsupported"), ]) def test_a_fixed_set_that_cannot_hold_is_refused_before_search(mutate, fragment): data = request() diff --git a/tests/test_nesting.py b/tests/test_nesting.py index db33f5a..dcb2f16 100644 --- a/tests/test_nesting.py +++ b/tests/test_nesting.py @@ -9,7 +9,7 @@ from __future__ import annotations from packvium import Dimensions, Item, Length, Placement, Point, Rotation, Weight -from packvium.nesting import is_valid_nesting, used_volume, used_volume_delta +from packvium.nesting import column_layer_z, column_layers, is_valid_nesting, used_volume, used_volume_delta def placement(item_id: str, x: int, y: int, z: int, size: int, nesting_height=None, item=None) -> Placement: @@ -74,3 +74,53 @@ def test_two_nestable_items_at_the_same_height_are_not_a_nested_pair(): one = Placement(first, position, Rotation.LWH, dims, position, dims) other = Placement(second, position, Rotation.LWH, dims, position, dims) assert not is_valid_nesting(one, other) + + +def test_under_a_clearance_only_the_items_own_overlap_is_subtracted(): + # 10x1x10 cups nesting 9 under a 1 mm clearance. The 12x3x12 envelopes sink by 9, + # so the cups overlap by 7 over their own 10 mm^2 -- 70, not the envelope's 9 x 36 = 324, + # which is more than a cup's own volume of 100. + item = Item.create("cup", Dimensions(Length(10), Length(1), Length(10)), Weight(0), nesting_height=Length(9)) + physical = item.dimensions + envelope = Dimensions(Length(12), Length(3), Length(12)) + lower_instance, = item.instances() + upper_instance, = Item.create("cup", physical, Weight(0), nesting_height=Length(9)).instances() + lower = Placement(lower_instance, Point(1, 1, 1), Rotation.LWH, physical, Point(0, 0, 0), envelope) + upper = Placement(upper_instance, Point(1, 1, 4), Rotation.LWH, physical, Point(0, 0, 3), envelope) + assert is_valid_nesting(lower, upper) + assert used_volume([lower, upper]) == 2 * 100 - 70 + assert used_volume_delta([lower], upper) == 100 - 70 + + +def test_a_clearance_twice_the_nesting_height_leaves_nothing_to_subtract(): + item = Item.create("tray", Dimensions(Length(10), Length(10), Length(10)), Weight(0), nesting_height=Length(2)) + physical = item.dimensions + envelope = Dimensions(Length(12), Length(12), Length(12)) + lower_instance, = item.instances() + upper_instance, = Item.create("tray", physical, Weight(0), nesting_height=Length(2)).instances() + lower = Placement(lower_instance, Point(1, 1, 1), Rotation.LWH, physical, Point(0, 0, 0), envelope) + upper = Placement(upper_instance, Point(1, 1, 11), Rotation.LWH, physical, Point(0, 0, 10), envelope) + assert is_valid_nesting(lower, upper) + assert used_volume([lower, upper]) == 2 * 1000 + + +def test_a_shallow_nest_chains_every_layer(): + # 2 x 40 <= 100: a third crate sunk into the second only touches the first. + assert [column_layer_z(layer, 100, 40) for layer in range(4)] == [0, 60, 120, 180] + assert column_layers(280, 100, 40) == 4 + assert column_layers(279, 100, 40) == 3 + + +def test_a_nest_deeper_than_half_the_height_goes_in_pairs(): + # 120 mm cups nesting 100. A third cup sunk into the second would overlap the first + # by 80, so each pair is 140 tall and the next pair rests on it. + assert [column_layer_z(layer, 120, 100) for layer in range(5)] == [0, 20, 140, 160, 280] + assert column_layers(400, 120, 100) == 5 + assert column_layers(399, 120, 100) == 4 + assert column_layers(140, 120, 100) == 2 + assert column_layers(119, 120, 100) == 0 + + +def test_a_nest_of_exactly_half_the_height_still_chains(): + assert [column_layer_z(layer, 100, 50) for layer in range(3)] == [0, 50, 100] + assert column_layers(200, 100, 50) == 3 diff --git a/tests/test_optimality_bounds.py b/tests/test_optimality_bounds.py index 02de2ca..ebb49a6 100644 --- a/tests/test_optimality_bounds.py +++ b/tests/test_optimality_bounds.py @@ -142,9 +142,11 @@ def test_a_custom_solution_scorer_gets_no_bound(): 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) + # A weight cannot pass the sum ceiling since admission caps the total at 2^63 - 1 ticks + #, so two cubes at the 10^30 cubic-tick volume ceiling pass it instead. + vast = packvium.Dimensions.mm(625_000, 625_000, 625_000) result = packvium.Packer(packvium.PackingConfig.quality(time_limit_ms=20000)).pack( - [heavy], [packvium.Container.create("c", packvium.Dimensions.mm(100, 100, 100))]) + [packvium.Item.create("vast", vast, quantity=2)], [packvium.Container.create("c", vast)]) assert recorded == [None] assert result.complete diff --git a/tests/test_packing_sequence.py b/tests/test_packing_sequence.py index eff929a..c301f7d 100644 --- a/tests/test_packing_sequence.py +++ b/tests/test_packing_sequence.py @@ -645,6 +645,17 @@ def test_a_loading_prefix_that_stacks_onto_a_non_stackable_item_is_caught(): assert "non_stackable_item_has_load" in excinfo.value.reason +def test_a_non_stackable_item_that_carries_nothing_passes_the_prefix_replay(): + base = Item.create("base", Dimensions.mm(10, 10, 5)) + lid = Item.create("lid", Dimensions.mm(10, 10, 5), stackable=False) + placements = [stacked_placement(base, 0, 0, 0), stacked_placement(lid, 0, 0, 5)] + verify_loading_prefix_business_rules(placements, [0, 1], Container.create("c", Dimensions.mm(10, 10, 20))) + + +def test_an_empty_scene_passes_the_prefix_replay(): + verify_loading_prefix_business_rules([], [], Container.create("c", Dimensions.mm(10, 10, 20))) + + def test_a_loading_prefix_that_breaks_the_stacking_group_rule_is_caught(): base = Item.create("base", Dimensions.mm(10, 10, 5), accepts_stacking_groups=["A"]) rider = Item.create("rider", Dimensions.mm(10, 10, 5), stacking_group="C") diff --git a/tests/test_request_errors.py b/tests/test_request_errors.py index f21be52..44a993d 100644 --- a/tests/test_request_errors.py +++ b/tests/test_request_errors.py @@ -337,3 +337,72 @@ def test_without_an_output_block_the_result_is_in_the_request_unit_spelt_as_its_ def test_a_nesting_height_not_below_the_height_is_refused_at_its_pointer(nesting, detail): error = refusal(lambda d: d["items"][0].update(nesting_height=nesting)) assert (error.reason, error.field, error.detail) == ("invalid_value", "/items/0/nesting_height", detail) + + +@pytest.mark.parametrize("origin", [False, 0, [], ""]) +@pytest.mark.parametrize("additional", [False, True], ids=["obstacle", "additional-box"]) +def test_falsy_non_object_obstacle_origins_have_typed_refusals(origin, additional): + data = request() + box = {"origin": origin, "dimensions": {"length": "1", "width": "1", "height": "1"}} + obstacle = {"id": "o", **box} + field = "/containers/0/obstacles/0/origin" + if additional: + obstacle = {"id": "o", "dimensions": box["dimensions"], "additional_boxes": [box]} + field = "/containers/0/obstacles/0/additional_boxes/0/origin" + data["containers"][0]["obstacles"] = [obstacle] + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(data) + assert (caught.value.reason, caught.value.field, caught.value.detail) == ( + "wrong_type", field, "must be an object") + + +@pytest.mark.parametrize("additional", [False, True], ids=["obstacle", "additional-box"]) +def test_null_obstacle_origin_packs_as_the_default_origin(additional): + data = request() + box = {"origin": None, "dimensions": {"length": "1", "width": "1", "height": "1"}} + obstacle = {"id": "o", **box} + if additional: + obstacle = {"id": "o", "dimensions": box["dimensions"], "additional_boxes": [box]} + data["containers"][0]["obstacles"] = [obstacle] + defaulted = copy.deepcopy(data) + target = defaulted["containers"][0]["obstacles"][0] + if additional: + target = target["additional_boxes"][0] + target["origin"] = {} + result = pack_from_dict(data) + expected = pack_from_dict(defaulted) + result["algorithm"]["duration_ms"] = expected["algorithm"]["duration_ms"] + assert result == expected + + +@pytest.mark.parametrize("axis", ["x", "y", "z"]) +@pytest.mark.parametrize("additional", [False, True], ids=["obstacle", "additional-box"]) +def test_null_obstacle_coordinate_packs_as_zero(axis, additional): + data = request() + box = {"origin": {axis: None}, "dimensions": {"length": "1", "width": "1", "height": "1"}} + obstacle = {"id": "o", **box} + if additional: + obstacle = {"id": "o", "dimensions": box["dimensions"], "additional_boxes": [box]} + data["containers"][0]["obstacles"] = [obstacle] + defaulted = copy.deepcopy(data) + target = defaulted["containers"][0]["obstacles"][0] + if additional: + target = target["additional_boxes"][0] + target["origin"][axis] = 0 + result = pack_from_dict(data) + expected = pack_from_dict(defaulted) + result["algorithm"]["duration_ms"] = expected["algorithm"]["duration_ms"] + assert result == expected + + +def test_null_obstacle_additional_boxes_packs_as_an_empty_list(): + data = request() + data["containers"][0]["obstacles"] = [{ + "id": "o", "dimensions": {"length": "1", "width": "1", "height": "1"}, + "additional_boxes": None}] + defaulted = copy.deepcopy(data) + defaulted["containers"][0]["obstacles"][0]["additional_boxes"] = [] + result = pack_from_dict(data) + expected = pack_from_dict(defaulted) + result["algorithm"]["duration_ms"] = expected["algorithm"]["duration_ms"] + assert result == expected diff --git a/tests/test_request_objects_corpus.py b/tests/test_request_objects_corpus.py index 3c07038..da44abd 100644 --- a/tests/test_request_objects_corpus.py +++ b/tests/test_request_objects_corpus.py @@ -37,6 +37,8 @@ _COERCED = "the deserializer coerces it, so the objects it builds are a different, valid request" _CONSTRUCTOR_FIRST = ("two faults: a constructor refuses its own value as the object is built, " "before Packer.pack walks the request in the JSON order") +_CEILING_SECOND = ("a length past the admission ceiling also breaks its constructor's own rule, which " + "refuses it as the object is built, before Packer.pack reaches the ceiling") _NEGATIVE_POSITION = ("a Point cannot hold a negative coordinate; the measure it is read with " "refuses it as negative_measure, where the JSON path names the placement") @@ -48,6 +50,15 @@ #: Cases with no object form: the value cannot be held, or the deserializer changes it. NOT_OBJECTS: dict[str, str] = { + "obstacle-origin-false": "a Point cannot hold a boolean instead of its coordinates", + "obstacle-origin-zero": "a Point cannot hold a number instead of its coordinates", + "obstacle-origin-nonempty-list": "a Point cannot hold a list instead of its coordinates", + "obstacle-origin-empty-string": "a Point cannot hold text instead of its coordinates", + "obstacle-additional-origin-false": "a Point cannot hold a boolean instead of its coordinates", + "obstacle-additional-origin-zero": "a Point cannot hold a number instead of its coordinates", + "obstacle-additional-origin-nonempty-list": "a Point cannot hold a list instead of its coordinates", + "obstacle-additional-origin-empty-string": "a Point cannot hold text instead of its coordinates", + "obstacle-additional-boxes-not-list": "an Obstacle holds its additional boxes as a tuple", "request-not-object": "a request that is not an object has no objects", "items-missing": "Packer.pack takes the items as an argument", "items-not-list": "Packer.pack takes the items as a sequence of Item", @@ -111,12 +122,23 @@ "an-item-id-before-its-quantity": _CONSTRUCTOR_FIRST, "item-ids-before-containers": _CONSTRUCTOR_FIRST, "container-ids-before-fixed-placements": _CONSTRUCTOR_FIRST, + **{name: _CEILING_SECOND for name in ( + "item.nesting_height=2^53 ticks", + *(f"container.made_to_measure.minimum.{axis}=2^53 ticks" for axis in ("length", "width", "height")), + *(f"container.made_to_measure.increment.{axis}=2^53 ticks" for axis in ("length", "height")), + *(f"container.obstacles.origin.{axis}=2^53 ticks" for axis in "xyz"), + "container.obstacles.dimensions.length=2^53 ticks", + )}, "configuration-boolean-before-effort-budget": ("two faults: the EffortBudget is built, and refuses " "its own value, before the PackingConfig that holds it"), + **{f"{name}=2^53 as a number": ("a JSON number past 2^53 - 1 is a rule of the JSON text; an object " + "holds the exact integer") for name in ( + "item.weight", "item.max_top_load", "container.tare_weight", "container.max_payload", + "container.max_stack_density", "container.made_to_measure.tare_per_square_metre")}, } #: Pinned, so the skip set cannot grow without this line changing too. -SKIPPED = 62 +SKIPPED = 87 @dataclass(frozen=True) diff --git a/tests/test_solver_edges.py b/tests/test_solver_edges.py index 56885a0..68b8e1a 100644 --- a/tests/test_solver_edges.py +++ b/tests/test_solver_edges.py @@ -295,10 +295,12 @@ def test_the_container_lower_bound_ignores_payload_when_no_container_accepts_any 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) - assert bounds.for_request(heavy.instances(), (container("c", 100, 100, 100),), + # A weight cannot pass the sum ceiling since admission caps the total at 2^63 - 1 ticks + #, so two cubes at the 10^30 cubic-tick volume ceiling pass it instead. + vast = item("vast", 625_000, 625_000, 625_000, quantity=2) + assert bounds.for_request(vast.instances(), (container("c", 625_000, 625_000, 625_000),), "default") is None - result = Packer(counted(solvers=("exact_small",))).pack([heavy], [container("c", 100, 100, 100)]) + result = Packer(counted(solvers=("exact_small",))).pack([vast], [container("c", 625_000, 625_000, 625_000)]) assert result.complete @@ -312,13 +314,18 @@ def test_the_container_plan_beam_prefers_one_container_that_holds_everything(): assert_sound(result, items, containers) -def test_the_container_plan_beam_stops_expanding_at_its_node_limit(): - items = [item("a", 100, 100, 100, quantity=3)] - containers = [container("x", 100, 100, 100, quantity=3), container("y", 100, 100, 100, quantity=3)] - config = counted(solvers=("extreme_points",), container_plan_beam_width=4, container_plan_node_limit=1) +@pytest.mark.parametrize(("node_limit", "packed_count"), [(1, 4), (4, 4), (5, 5)]) +def test_the_container_plan_beam_stops_expanding_at_its_effective_node_limit(node_limit, packed_count): + # One type makes every trial add exactly one container. The beam's four-trial + # floor applies to small budgets; a larger declared budget still stops search. + items = [item("a", 100, 100, 100, quantity=6)] + containers = [container("x", 100, 100, 100, quantity=6)] + config = counted(solvers=("extreme_points",), container_plan_beam_width=4, + container_plan_node_limit=node_limit) result = Packer(config).pack(items, containers) - assert len(result.containers) == 1 - assert len(result.unpacked) == 2 + assert len(result.containers) == packed_count + assert len(result.unpacked) == 6 - packed_count + assert_sound(result, items, containers) def test_the_container_plan_beam_never_opens_more_of_a_type_than_the_request_has(): diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 1601898..a7a2913 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -1714,3 +1714,17 @@ def test_a_late_type_refinement_never_widens_a_narrower_request(): config = replace(PackingConfig.quality(), container_plan_beam_width=2, max_candidates_per_item=3) narrowed = _start_config("volume:greedy:late_box:beam", config) assert (narrowed.container_plan_beam_width, narrowed.max_candidates_per_item) == (2, 3) + + +def test_late_type_neighbourhoods_weigh_one_start_together_in_the_time_slicing(): + # 1.9.0 release frontier: twenty item types used to add twenty even shares and halve every + # other start's time, the first start's lattice included. + from packvium.solvers import _slice_weights + starts = [(None, "small_edge:greedy", ()), (None, "volume:greedy", ()), + (None, "neighborhood:a", ()), (None, "neighborhood:b", ()), (None, "best:beam", ())] + assert _slice_weights(starts) == [2, 2, 1, 1, 2] + assert _slice_weights(starts[:2]) == [1, 1] + frozen = Deadline(0, limit_ns=1_000_000_000, clock=lambda: 0) + assert frozen.share(2, 8).limit_ns == 250_000_000 + assert frozen.share(3, 3).limit_ns == 1_000_000_000 + assert frozen.slice(4).limit_ns == frozen.share(1, 4).limit_ns diff --git a/tests/test_support_polygon.py b/tests/test_support_polygon.py index 3f4b3bc..c37f878 100644 --- a/tests/test_support_polygon.py +++ b/tests/test_support_polygon.py @@ -7,6 +7,8 @@ from __future__ import annotations +import math + from packvium import AxisAlignedBox, Dimensions, Length, Point from packvium.support_polygon import (contact_hull_points, convex_hull, doubled_centroid, eight_times_area, point_in_hull) @@ -155,3 +157,58 @@ def test_one_centred_strip_is_where_the_two_polygon_rules_part(): assert point_in_hull(doubled_centroid(candidate), hull) assert eight_times_area(hull) == 8 * 100 * 20 assert eight_times_area(hull) <= 8 * (100 * 100) // 2 + + +# ------------------------------------------------------------ rare and absurd supports + +def supported(candidate, supporters) -> bool: + return point_in_hull(doubled_centroid(candidate), convex_hull(contact_hull_points(candidate, supporters))) + + +def test_a_one_tick_knife_edge_under_the_centroid_carries_the_box(): + assert supported(box(0, 0, 10, 10), [box(5, 0, 1, 10)]) + assert not supported(box(0, 0, 10, 10), [box(6, 0, 1, 10)]) + + +def test_a_supporter_wider_than_the_footprint_is_clipped_to_it(): + assert contact_hull_points(box(10, 10, 10, 10), [box(0, 0, 100, 100)]) == [ + (20, 20), (20, 40), (40, 20), (40, 40), + ] + + +def test_a_corner_square_whose_inner_corner_is_the_centroid_carries_it(): + assert supported(box(0, 0, 40, 40), [box(0, 0, 20, 20)]) + assert not supported(box(0, 0, 40, 40), [box(0, 0, 20, 19)]) + + +def legs_on_a_circle(keep) -> list[AxisAlignedBox]: + angles = (math.radians(degree) for degree in range(360)) + return [ + box(round(500 + 400 * math.cos(angle)), round(500 + 400 * math.sin(angle)), 1, 1) + for angle in angles if keep(angle) + ] + + +def test_three_hundred_and_sixty_legs_on_a_circle_carry_the_centroid(): + assert supported(box(0, 0, 1000, 1000), legs_on_a_circle(lambda _: True)) + + +def test_legs_on_half_a_circle_leave_the_centroid_hanging(): + assert not supported(box(0, 0, 1000, 1000), legs_on_a_circle(lambda angle: math.sin(angle) > 0.1)) + + +def test_repeated_and_reordered_supporters_give_the_same_hull(): + legs = [box(0, 0, 5, 5), box(35, 0, 5, 5), box(0, 35, 5, 5)] + candidate = box(0, 0, 40, 40) + hulls = { + tuple(convex_hull(contact_hull_points(candidate, order))) + for order in (legs, legs[::-1], legs + legs) + } + assert len(hulls) == 1 + assert supported(candidate, legs) + + +def test_coordinates_beyond_any_fixed_width_integer_stay_exact(): + far = 2**80 + assert supported(box(far, far, 101, 10), [box(far, far, 51, 10)]) + assert not supported(box(far, far, 101, 10), [box(far, far, 50, 10)])